MessageCompose.java revision c0033f24a26a08c47aa38d957f42cf63cfa3c345
1/*
2 * Copyright (C) 2008 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.activity;
18
19import com.android.email.Email;
20import com.android.email.EmailAddressAdapter;
21import com.android.email.EmailAddressValidator;
22import com.android.email.MessagingController;
23import com.android.email.MessagingListener;
24import com.android.email.R;
25import com.android.email.Utility;
26import com.android.email.mail.Address;
27import com.android.email.mail.Body;
28import com.android.email.mail.Message;
29import com.android.email.mail.MessagingException;
30import com.android.email.mail.Multipart;
31import com.android.email.mail.Part;
32import com.android.email.mail.Message.RecipientType;
33import com.android.email.mail.internet.EmailHtmlUtil;
34import com.android.email.mail.internet.MimeBodyPart;
35import com.android.email.mail.internet.MimeHeader;
36import com.android.email.mail.internet.MimeMessage;
37import com.android.email.mail.internet.MimeMultipart;
38import com.android.email.mail.internet.MimeUtility;
39import com.android.email.mail.internet.TextBody;
40import com.android.email.mail.store.LocalStore;
41import com.android.email.mail.store.LocalStore.LocalAttachmentBody;
42import com.android.email.provider.EmailStore;
43
44import android.app.Activity;
45import android.content.ActivityNotFoundException;
46import android.content.ContentResolver;
47import android.content.Context;
48import android.content.Intent;
49import android.content.pm.ActivityInfo;
50import android.database.Cursor;
51import android.net.Uri;
52import android.os.Bundle;
53import android.os.Handler;
54import android.os.Parcelable;
55import android.provider.OpenableColumns;
56import android.text.InputFilter;
57import android.text.SpannableStringBuilder;
58import android.text.Spanned;
59import android.text.TextWatcher;
60import android.text.util.Rfc822Tokenizer;
61import android.util.Config;
62import android.util.Log;
63import android.view.Menu;
64import android.view.MenuItem;
65import android.view.View;
66import android.view.Window;
67import android.view.View.OnClickListener;
68import android.view.View.OnFocusChangeListener;
69import android.webkit.WebView;
70import android.widget.Button;
71import android.widget.EditText;
72import android.widget.ImageButton;
73import android.widget.LinearLayout;
74import android.widget.MultiAutoCompleteTextView;
75import android.widget.TextView;
76import android.widget.Toast;
77import android.widget.AutoCompleteTextView.Validator;
78
79import java.io.Serializable;
80import java.io.UnsupportedEncodingException;
81import java.net.URLDecoder;
82import java.util.ArrayList;
83import java.util.Date;
84import java.util.List;
85
86public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener {
87    private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
88    private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
89    private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
90    private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
91
92    private static final String EXTRA_ACCOUNT_ID = "account_id";
93    private static final String EXTRA_FOLDER = "folder";
94    private static final String EXTRA_MESSAGE = "message";
95
96    private static final String STATE_KEY_ATTACHMENTS =
97        "com.android.email.activity.MessageCompose.attachments";
98    private static final String STATE_KEY_CC_SHOWN =
99        "com.android.email.activity.MessageCompose.ccShown";
100    private static final String STATE_KEY_BCC_SHOWN =
101        "com.android.email.activity.MessageCompose.bccShown";
102    private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
103        "com.android.email.activity.MessageCompose.quotedTextShown";
104    private static final String STATE_KEY_SOURCE_MESSAGE_PROCED =
105        "com.android.email.activity.MessageCompose.stateKeySourceMessageProced";
106    private static final String STATE_KEY_DRAFT_UID =
107        "com.android.email.activity.MessageCompose.draftUid";
108
109    private static final int MSG_PROGRESS_ON = 1;
110    private static final int MSG_PROGRESS_OFF = 2;
111    private static final int MSG_UPDATE_TITLE = 3;
112    private static final int MSG_SKIPPED_ATTACHMENTS = 4;
113    private static final int MSG_SAVED_DRAFT = 5;
114    private static final int MSG_DISCARDED_DRAFT = 6;
115
116    private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
117
118    private long mAccountId;
119    private EmailStore.Account mAccount;
120    private String mFolder;
121    private String mSourceMessageUid;
122    private Message mSourceMessage;
123    /**
124     * Indicates that the source message has been processed at least once and should not
125     * be processed on any subsequent loads. This protects us from adding attachments that
126     * have already been added from the restore of the view state.
127     */
128    private boolean mSourceMessageProcessed = false;
129
130    private MultiAutoCompleteTextView mToView;
131    private MultiAutoCompleteTextView mCcView;
132    private MultiAutoCompleteTextView mBccView;
133    private EditText mSubjectView;
134    private EditText mMessageContentView;
135    private Button mSendButton;
136    private Button mDiscardButton;
137    private Button mSaveButton;
138    private LinearLayout mAttachments;
139    private View mQuotedTextBar;
140    private ImageButton mQuotedTextDelete;
141    private WebView mQuotedText;
142
143    private boolean mDraftNeedsSaving = false;
144
145    /**
146     * The draft uid of this message. This is used when saving drafts so that the same draft is
147     * overwritten instead of being created anew. This property is null until the first save.
148     */
149    private String mDraftUid;
150
151    private Handler mHandler = new Handler() {
152        @Override
153        public void handleMessage(android.os.Message msg) {
154            switch (msg.what) {
155                case MSG_PROGRESS_ON:
156                    setProgressBarIndeterminateVisibility(true);
157                    break;
158                case MSG_PROGRESS_OFF:
159                    setProgressBarIndeterminateVisibility(false);
160                    break;
161                case MSG_UPDATE_TITLE:
162                    updateTitle();
163                    break;
164                case MSG_SKIPPED_ATTACHMENTS:
165                    Toast.makeText(
166                            MessageCompose.this,
167                            getString(R.string.message_compose_attachments_skipped_toast),
168                            Toast.LENGTH_LONG).show();
169                    break;
170                case MSG_SAVED_DRAFT:
171                    Toast.makeText(
172                            MessageCompose.this,
173                            getString(R.string.message_saved_toast),
174                            Toast.LENGTH_LONG).show();
175                    break;
176                case MSG_DISCARDED_DRAFT:
177                    Toast.makeText(
178                            MessageCompose.this,
179                            getString(R.string.message_discarded_toast),
180                            Toast.LENGTH_LONG).show();
181                    break;
182                default:
183                    super.handleMessage(msg);
184                    break;
185            }
186        }
187    };
188
189    private Listener mListener = new Listener();
190    private EmailAddressAdapter mAddressAdapter;
191    private Validator mAddressValidator;
192
193    /**
194     * Encapsulates known information about a single attachment.
195     */
196    private static class Attachment implements Serializable {
197        public String name;
198        public String contentType;
199        public long size;
200        public Uri uri;
201    }
202
203    /**
204     * Compose a new message using the given account. If account is -1 the default account
205     * will be used.
206     * @param context
207     * @param account
208     */
209    public static void actionCompose(Context context, long accountId) {
210       try {
211           Intent i = new Intent(context, MessageCompose.class);
212           i.putExtra(EXTRA_ACCOUNT_ID, accountId);
213           /*
214            * TODO handle drafts via role # note via string
215            * Is this used?  Do we ever compose anywhere else but in drafts?
216            */
217           i.putExtra(EXTRA_FOLDER, context.getString(R.string.special_mailbox_name_drafts));
218           context.startActivity(i);
219       } catch (ActivityNotFoundException anfe) {
220           // Swallow it - this is usually a race condition, especially under automated test.
221           // (The message composer might have been disabled)
222           if (Config.LOGD) {
223               Log.d(Email.LOG_TAG, anfe.toString());
224           }
225       }
226    }
227
228    /**
229     * Compose a new message using the given account, or the default account if -1
230     * @param context
231     * @param defaultAccountId
232     */
233    public static void actionCompose(Activity context, long accountId) {
234        // TODO Auto-generated method stub
235        // DO NOT CHECK IN until written
236    }
237
238    /**
239     * Compose a new message as a reply to the given message. If replyAll is true the function
240     * is reply all instead of simply reply.
241     * @param context
242     * @param account
243     * @param message
244     * @param replyAll
245     */
246    public static void actionReply(
247            Context context,
248            long accountId,
249            Message message,
250            boolean replyAll) {
251        Intent i = new Intent(context, MessageCompose.class);
252        i.putExtra(EXTRA_ACCOUNT_ID, accountId);
253        i.putExtra(EXTRA_FOLDER, message.getFolder().getName());
254        i.putExtra(EXTRA_MESSAGE, message.getUid());
255        if (replyAll) {
256            i.setAction(ACTION_REPLY_ALL);
257        }
258        else {
259            i.setAction(ACTION_REPLY);
260        }
261        context.startActivity(i);
262    }
263
264    /**
265     * Compose a new message as a forward of the given message.
266     * @param context
267     * @param account
268     * @param message
269     */
270    public static void actionForward(Context context, long accountId, Message message) {
271        Intent i = new Intent(context, MessageCompose.class);
272        i.putExtra(EXTRA_ACCOUNT_ID, accountId);
273        i.putExtra(EXTRA_FOLDER, message.getFolder().getName());
274        i.putExtra(EXTRA_MESSAGE, message.getUid());
275        i.setAction(ACTION_FORWARD);
276        context.startActivity(i);
277    }
278
279    /**
280     * Continue composition of the given message. This action modifies the way this Activity
281     * handles certain actions.
282     * Save will attempt to replace the message in the given folder with the updated version.
283     * Discard will delete the message from the given folder.
284     * @param context
285     * @param account
286     * @param message
287     */
288    public static void actionEditDraft(Context context, long accountId, Message message) {
289        Intent i = new Intent(context, MessageCompose.class);
290        i.putExtra(EXTRA_ACCOUNT_ID, accountId);
291        i.putExtra(EXTRA_FOLDER, message.getFolder().getName());
292        i.putExtra(EXTRA_MESSAGE, message.getUid());
293        i.setAction(ACTION_EDIT_DRAFT);
294        context.startActivity(i);
295    }
296
297    @Override
298    public void onCreate(Bundle savedInstanceState) {
299        super.onCreate(savedInstanceState);
300
301        requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
302
303        setContentView(R.layout.message_compose);
304
305        mAddressAdapter = new EmailAddressAdapter(this);
306        mAddressValidator = new EmailAddressValidator();
307
308        mToView = (MultiAutoCompleteTextView)findViewById(R.id.to);
309        mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc);
310        mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc);
311        mSubjectView = (EditText)findViewById(R.id.subject);
312        mMessageContentView = (EditText)findViewById(R.id.message_content);
313        mSendButton = (Button)findViewById(R.id.send);
314        mDiscardButton = (Button)findViewById(R.id.discard);
315        mSaveButton = (Button)findViewById(R.id.save);
316        mAttachments = (LinearLayout)findViewById(R.id.attachments);
317        mQuotedTextBar = findViewById(R.id.quoted_text_bar);
318        mQuotedTextDelete = (ImageButton)findViewById(R.id.quoted_text_delete);
319        mQuotedText = (WebView)findViewById(R.id.quoted_text);
320
321        TextWatcher watcher = new TextWatcher() {
322            public void beforeTextChanged(CharSequence s, int start,
323                                          int before, int after) { }
324
325            public void onTextChanged(CharSequence s, int start,
326                                          int before, int count) {
327                mDraftNeedsSaving = true;
328            }
329
330            public void afterTextChanged(android.text.Editable s) { }
331        };
332
333        /**
334         * Implements special address cleanup rules:
335         * The first space key entry following an "@" symbol that is followed by any combination
336         * of letters and symbols, including one+ dots and zero commas, should insert an extra
337         * comma (followed by the space).
338         */
339        InputFilter recipientFilter = new InputFilter() {
340
341            public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
342                    int dstart, int dend) {
343
344                // quick check - did they enter a single space?
345                if (end-start != 1 || source.charAt(start) != ' ') {
346                    return null;
347                }
348
349                // determine if the characters before the new space fit the pattern
350                // follow backwards and see if we find a comma, dot, or @
351                int scanBack = dstart;
352                boolean dotFound = false;
353                while (scanBack > 0) {
354                    char c = dest.charAt(--scanBack);
355                    switch (c) {
356                        case '.':
357                            dotFound = true;    // one or more dots are req'd
358                            break;
359                        case ',':
360                            return null;
361                        case '@':
362                            if (!dotFound) {
363                                return null;
364                            }
365                            // we have found a comma-insert case.  now just do it
366                            // in the least expensive way we can.
367                            if (source instanceof Spanned) {
368                                SpannableStringBuilder sb = new SpannableStringBuilder(",");
369                                sb.append(source);
370                                return sb;
371                            } else {
372                                return ", ";
373                            }
374                        default:
375                            // just keep going
376                    }
377                }
378
379                // no termination cases were found, so don't edit the input
380                return null;
381            }
382        };
383        InputFilter[] recipientFilters = new InputFilter[] { recipientFilter };
384
385        mToView.addTextChangedListener(watcher);
386        mCcView.addTextChangedListener(watcher);
387        mBccView.addTextChangedListener(watcher);
388        mSubjectView.addTextChangedListener(watcher);
389        mMessageContentView.addTextChangedListener(watcher);
390
391        // NOTE: assumes no other filters are set
392        mToView.setFilters(recipientFilters);
393        mCcView.setFilters(recipientFilters);
394        mBccView.setFilters(recipientFilters);
395
396        /*
397         * We set this to invisible by default. Other methods will turn it back on if it's
398         * needed.
399         */
400        mQuotedTextBar.setVisibility(View.GONE);
401        mQuotedText.setVisibility(View.GONE);
402
403        mQuotedTextDelete.setOnClickListener(this);
404
405        mToView.setAdapter(mAddressAdapter);
406        mToView.setTokenizer(new Rfc822Tokenizer());
407        mToView.setValidator(mAddressValidator);
408
409        mCcView.setAdapter(mAddressAdapter);
410        mCcView.setTokenizer(new Rfc822Tokenizer());
411        mCcView.setValidator(mAddressValidator);
412
413        mBccView.setAdapter(mAddressAdapter);
414        mBccView.setTokenizer(new Rfc822Tokenizer());
415        mBccView.setValidator(mAddressValidator);
416
417        mSendButton.setOnClickListener(this);
418        mDiscardButton.setOnClickListener(this);
419        mSaveButton.setOnClickListener(this);
420
421        mSubjectView.setOnFocusChangeListener(this);
422
423        if (savedInstanceState != null) {
424            /*
425             * This data gets used in onCreate, so grab it here instead of onRestoreIntstanceState
426             */
427            mSourceMessageProcessed =
428                savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false);
429        }
430
431        Intent intent = getIntent();
432
433        String action = intent.getAction();
434
435        // Handle the various intents that launch the message composer
436        if (Intent.ACTION_VIEW.equals(action) || Intent.ACTION_SENDTO.equals(action) ||
437                (Intent.ACTION_SEND.equals(action))) {
438
439            // Check first for a valid account
440            mAccount = EmailStore.Account.getDefaultAccount(this);
441            if (mAccount == null) {
442                // There are no accounts set up. This should not have happened. Prompt the
443                // user to set up an account as an acceptable bailout.
444                Accounts.actionShowAccounts(this);
445                mDraftNeedsSaving = false;
446                finish();
447                return;
448            }
449
450            // Use the fields found in the Intent to prefill as much of the message as possible
451            initFromIntent(intent);
452        }
453        else {
454            // Otherwise, handle the internal cases (Message Composer invoked from within app)
455            mAccountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1);
456            mAccount = EmailStore.Account.restoreAccountWithId(this, mAccountId);
457            mFolder = intent.getStringExtra(EXTRA_FOLDER);
458            mSourceMessageUid = intent.getStringExtra(EXTRA_MESSAGE);
459        }
460
461        if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action) ||
462                ACTION_FORWARD.equals(action) || ACTION_EDIT_DRAFT.equals(action)) {
463            /*
464             * If we need to load the message we add ourself as a message listener here
465             * so we can kick it off. Normally we add in onResume but we don't
466             * want to reload the message every time the activity is resumed.
467             * There is no harm in adding twice.
468             */
469            MessagingController.getInstance(getApplication()).addListener(mListener);
470            MessagingController.getInstance(getApplication()).loadMessageForView(
471                    mAccount,
472                    mFolder,
473                    mSourceMessageUid,
474                    mListener);
475        }
476
477        updateTitle();
478    }
479
480    @Override
481    public void onResume() {
482        super.onResume();
483        MessagingController.getInstance(getApplication()).addListener(mListener);
484    }
485
486    @Override
487    public void onPause() {
488        super.onPause();
489        saveIfNeeded();
490        MessagingController.getInstance(getApplication()).removeListener(mListener);
491    }
492
493    /**
494     * We override onDestroy to make sure that the WebView gets explicitly destroyed.
495     * Otherwise it can leak native references.
496     */
497    @Override
498    public void onDestroy() {
499        super.onDestroy();
500        mQuotedText.destroy();
501        mQuotedText = null;
502    }
503
504    /**
505     * The framework handles most of the fields, but we need to handle stuff that we
506     * dynamically show and hide:
507     * Attachment list,
508     * Cc field,
509     * Bcc field,
510     * Quoted text,
511     */
512    @Override
513    protected void onSaveInstanceState(Bundle outState) {
514        super.onSaveInstanceState(outState);
515        saveIfNeeded();
516        ArrayList<Uri> attachments = new ArrayList<Uri>();
517        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
518            View view = mAttachments.getChildAt(i);
519            Attachment attachment = (Attachment) view.getTag();
520            attachments.add(attachment.uri);
521        }
522        outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, attachments);
523        outState.putBoolean(STATE_KEY_CC_SHOWN, mCcView.getVisibility() == View.VISIBLE);
524        outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccView.getVisibility() == View.VISIBLE);
525        outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
526                mQuotedTextBar.getVisibility() == View.VISIBLE);
527        outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed);
528        outState.putString(STATE_KEY_DRAFT_UID, mDraftUid);
529    }
530
531    @Override
532    protected void onRestoreInstanceState(Bundle savedInstanceState) {
533        super.onRestoreInstanceState(savedInstanceState);
534        ArrayList<Parcelable> attachments =
535                savedInstanceState.getParcelableArrayList(STATE_KEY_ATTACHMENTS);
536        mAttachments.removeAllViews();
537        for (Parcelable p : attachments) {
538            Uri uri = (Uri) p;
539            addAttachment(uri);
540        }
541
542        mCcView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN) ?
543                View.VISIBLE : View.GONE);
544        mBccView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN) ?
545                View.VISIBLE : View.GONE);
546        mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
547                View.VISIBLE : View.GONE);
548        mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
549                View.VISIBLE : View.GONE);
550        mDraftUid = savedInstanceState.getString(STATE_KEY_DRAFT_UID);
551        mDraftNeedsSaving = false;
552    }
553
554    private void updateTitle() {
555        if (mSubjectView.getText().length() == 0) {
556            setTitle(R.string.compose_title);
557        } else {
558            setTitle(mSubjectView.getText().toString());
559        }
560    }
561
562    public void onFocusChange(View view, boolean focused) {
563        if (!focused) {
564            updateTitle();
565        }
566    }
567
568    private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
569        if (addresses == null) {
570            return;
571        }
572        for (Address address : addresses) {
573            addAddress(view, address.toString());
574        }
575    }
576
577    private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) {
578        if (addresses == null) {
579            return;
580        }
581        for (String oneAddress : addresses) {
582            addAddress(view, oneAddress);
583        }
584    }
585
586    private void addAddress(MultiAutoCompleteTextView view, String address) {
587        view.append(address + ", ");
588    }
589
590    private Address[] getAddresses(MultiAutoCompleteTextView view) {
591        Address[] addresses = Address.parse(view.getText().toString().trim());
592        return addresses;
593    }
594
595    private MimeMessage createMessage() throws MessagingException {
596        MimeMessage message = new MimeMessage();
597        message.setSentDate(new Date());
598        Address from = new Address(mAccount.getEmail(), mAccount.getName());
599        message.setFrom(from);
600        message.setRecipients(RecipientType.TO, getAddresses(mToView));
601        message.setRecipients(RecipientType.CC, getAddresses(mCcView));
602        message.setRecipients(RecipientType.BCC, getAddresses(mBccView));
603        message.setSubject(mSubjectView.getText().toString());
604
605        // Preserve Message-ID header if found
606        // This makes sure that multiply-saved drafts are identified as the same message
607        if (mSourceMessage != null && mSourceMessage instanceof MimeMessage) {
608            String messageIdHeader = ((MimeMessage)mSourceMessage).getMessageId();
609            if (messageIdHeader != null) {
610                message.setMessageId(messageIdHeader);
611            }
612        }
613
614        /*
615         * Build the Body that will contain the text of the message. We'll decide where to
616         * include it later.
617         */
618
619        String text = mMessageContentView.getText().toString();
620
621        if (mQuotedTextBar.getVisibility() == View.VISIBLE) {
622            String action = getIntent().getAction();
623            String quotedText = null;
624            Part part = MimeUtility.findFirstPartByMimeType(mSourceMessage,
625                    "text/plain");
626            if (part != null) {
627                quotedText = MimeUtility.getTextFromPart(part);
628            }
629            if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) {
630                text += String.format(
631                        getString(R.string.message_compose_reply_header_fmt),
632                        Address.toString(mSourceMessage.getFrom()));
633                if (quotedText != null) {
634                    text += quotedText.replaceAll("(?m)^", ">");
635                }
636            }
637            else if (ACTION_FORWARD.equals(action)) {
638                text += String.format(
639                        getString(R.string.message_compose_fwd_header_fmt),
640                        mSourceMessage.getSubject(),
641                        Address.toString(mSourceMessage.getFrom()),
642                        Address.toString(
643                                mSourceMessage.getRecipients(RecipientType.TO)),
644                        Address.toString(
645                                mSourceMessage.getRecipients(RecipientType.CC)));
646                if (quotedText != null) {
647                    text += quotedText;
648                }
649            }
650        }
651
652        TextBody body = new TextBody(text);
653
654        if (mAttachments.getChildCount() > 0) {
655            /*
656             * The message has attachments that need to be included. First we add the part
657             * containing the text that will be sent and then we include each attachment.
658             */
659
660            MimeMultipart mp;
661
662            mp = new MimeMultipart();
663            mp.addBodyPart(new MimeBodyPart(body, "text/plain"));
664
665            for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
666                Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag();
667                MimeBodyPart bp = new MimeBodyPart(
668                        new LocalStore.LocalAttachmentBody(attachment.uri, getApplication()));
669                bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n name=\"%s\"",
670                        attachment.contentType,
671                        attachment.name));
672                bp.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64");
673                bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION,
674                        String.format("attachment;\n filename=\"%s\"",
675                        attachment.name));
676                mp.addBodyPart(bp);
677            }
678
679            message.setBody(mp);
680        }
681        else {
682            /*
683             * No attachments to include, just stick the text body in the message and call
684             * it good.
685             */
686            message.setBody(body);
687        }
688
689        return message;
690    }
691
692    private void sendOrSaveMessage(boolean save) {
693        /*
694         * Create the message from all the data the user has entered.
695         */
696        MimeMessage message;
697        try {
698            message = createMessage();
699        }
700        catch (MessagingException me) {
701            Log.e(Email.LOG_TAG, "Failed to create new message for send or save.", me);
702            throw new RuntimeException("Failed to create a new message for send or save.", me);
703        }
704
705        if (save) {
706            /*
707             * Save a draft
708             */
709            if (mDraftUid != null) {
710                message.setUid(mDraftUid);
711            }
712            else if (ACTION_EDIT_DRAFT.equals(getIntent().getAction())) {
713                /*
714                 * We're saving a previously saved draft, so update the new message's uid
715                 * to the old message's uid.
716                 */
717                message.setUid(mSourceMessageUid);
718            }
719            MessagingController.getInstance(getApplication()).saveDraft(mAccount, message);
720            mDraftUid = message.getUid();
721
722            // Don't display the toast if the user is just changing the orientation
723            if ((getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
724                mHandler.sendEmptyMessage(MSG_SAVED_DRAFT);
725            }
726        }
727        else {
728            /*
729             * Send the message
730             * If the source message is in other folder than draft, it should not be deleted while
731             * sending message.
732             */
733            if (ACTION_EDIT_DRAFT.equals(getIntent().getAction())
734                    && mSourceMessageUid != null
735                    && mFolder.equals(mAccount.getDraftsFolderName(this))) {
736                /*
737                 * We're sending a previously saved draft, so delete the old draft first.
738                 */
739                MessagingController.getInstance(getApplication()).deleteMessage(
740                        mAccount,
741                        mFolder,
742                        mSourceMessage,
743                        null);
744            }
745            MessagingController.getInstance(getApplication()).sendMessage(mAccount, message, null);
746        }
747    }
748
749    private void saveIfNeeded() {
750        if (!mDraftNeedsSaving) {
751            return;
752        }
753        mDraftNeedsSaving = false;
754        sendOrSaveMessage(true);
755    }
756
757    private void onSend() {
758        if (getAddresses(mToView).length == 0 &&
759                getAddresses(mCcView).length == 0 &&
760                getAddresses(mBccView).length == 0) {
761            mToView.setError(getString(R.string.message_compose_error_no_recipients));
762            Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
763                    Toast.LENGTH_LONG).show();
764            return;
765        }
766        sendOrSaveMessage(false);
767        mDraftNeedsSaving = false;
768        finish();
769    }
770
771    private void onDiscard() {
772        if (mSourceMessageUid != null) {
773            if (ACTION_EDIT_DRAFT.equals(getIntent().getAction()) && mSourceMessageUid != null) {
774                MessagingController.getInstance(getApplication()).deleteMessage(
775                        mAccount,
776                        mFolder,
777                        mSourceMessage,
778                        null);
779            }
780        }
781        mHandler.sendEmptyMessage(MSG_DISCARDED_DRAFT);
782        mDraftNeedsSaving = false;
783        finish();
784    }
785
786    private void onSave() {
787        saveIfNeeded();
788        finish();
789    }
790
791    private void onAddCcBcc() {
792        mCcView.setVisibility(View.VISIBLE);
793        mBccView.setVisibility(View.VISIBLE);
794    }
795
796    /**
797     * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
798     */
799    private void onAddAttachment() {
800        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
801        i.addCategory(Intent.CATEGORY_OPENABLE);
802        i.setType(Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES[0]);
803        startActivityForResult(
804                Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)),
805                ACTIVITY_REQUEST_PICK_ATTACHMENT);
806    }
807
808    private void addAttachment(Uri uri) {
809        addAttachment(uri, -1, null);
810    }
811
812    private void addAttachment(Uri uri, int size, String name) {
813        ContentResolver contentResolver = getContentResolver();
814
815        String contentType = contentResolver.getType(uri);
816
817        if (contentType == null) {
818            contentType = "";
819        }
820
821        Attachment attachment = new Attachment();
822        attachment.name = name;
823        attachment.contentType = contentType;
824        attachment.size = size;
825        attachment.uri = uri;
826
827        if (attachment.size == -1 || attachment.name == null) {
828            Cursor metadataCursor = contentResolver.query(
829                    uri,
830                    new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE },
831                    null,
832                    null,
833                    null);
834            if (metadataCursor != null) {
835                try {
836                    if (metadataCursor.moveToFirst()) {
837                        if (attachment.name == null) {
838                            attachment.name = metadataCursor.getString(0);
839                        }
840                        if (attachment.size == -1) {
841                            attachment.size = metadataCursor.getInt(1);
842                        }
843                    }
844                } finally {
845                    metadataCursor.close();
846                }
847            }
848        }
849
850        if (attachment.name == null) {
851            attachment.name = uri.getLastPathSegment();
852        }
853
854        // Before attaching the attachment, make sure it meets any other pre-attach criteria
855        if (attachment.size > Email.MAX_ATTACHMENT_UPLOAD_SIZE) {
856            Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG)
857                    .show();
858            return;
859        }
860
861        View view = getLayoutInflater().inflate(
862                R.layout.message_compose_attachment,
863                mAttachments,
864                false);
865        TextView nameView = (TextView)view.findViewById(R.id.attachment_name);
866        ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete);
867        nameView.setText(attachment.name);
868        delete.setOnClickListener(this);
869        delete.setTag(view);
870        view.setTag(attachment);
871        mAttachments.addView(view);
872    }
873
874    @Override
875    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
876        if (data == null) {
877            return;
878        }
879        addAttachment(data.getData());
880        mDraftNeedsSaving = true;
881    }
882
883    public void onClick(View view) {
884        switch (view.getId()) {
885            case R.id.send:
886                onSend();
887                break;
888            case R.id.save:
889                onSave();
890                break;
891            case R.id.discard:
892                onDiscard();
893                break;
894            case R.id.attachment_delete:
895                /*
896                 * The view is the delete button, and we have previously set the tag of
897                 * the delete button to the view that owns it. We don't use parent because the
898                 * view is very complex and could change in the future.
899                 */
900                mAttachments.removeView((View) view.getTag());
901                mDraftNeedsSaving = true;
902                break;
903            case R.id.quoted_text_delete:
904                mQuotedTextBar.setVisibility(View.GONE);
905                mQuotedText.setVisibility(View.GONE);
906                mDraftNeedsSaving = true;
907                break;
908        }
909    }
910
911    @Override
912    public boolean onOptionsItemSelected(MenuItem item) {
913        switch (item.getItemId()) {
914            case R.id.send:
915                onSend();
916                break;
917            case R.id.save:
918                onSave();
919                break;
920            case R.id.discard:
921                onDiscard();
922                break;
923            case R.id.add_cc_bcc:
924                onAddCcBcc();
925                break;
926            case R.id.add_attachment:
927                onAddAttachment();
928                break;
929            default:
930                return super.onOptionsItemSelected(item);
931        }
932        return true;
933    }
934
935    @Override
936    public boolean onCreateOptionsMenu(Menu menu) {
937        super.onCreateOptionsMenu(menu);
938        getMenuInflater().inflate(R.menu.message_compose_option, menu);
939        return true;
940    }
941
942    /**
943     * Returns true if all attachments were able to be attached, otherwise returns false.
944     */
945    private boolean loadAttachments(Part part, int depth) throws MessagingException {
946        if (part.getBody() instanceof Multipart) {
947            Multipart mp = (Multipart) part.getBody();
948            boolean ret = true;
949            for (int i = 0, count = mp.getCount(); i < count; i++) {
950                if (!loadAttachments(mp.getBodyPart(i), depth + 1)) {
951                    ret = false;
952                }
953            }
954            return ret;
955        } else {
956            String contentType = MimeUtility.unfoldAndDecode(part.getContentType());
957            String name = MimeUtility.getHeaderParameter(contentType, "name");
958            if (name != null) {
959                Body body = part.getBody();
960                if (body != null && body instanceof LocalAttachmentBody) {
961                    final Uri uri = ((LocalAttachmentBody) body).getContentUri();
962                    mHandler.post(new Runnable() {
963                        public void run() {
964                            addAttachment(uri);
965                        }
966                    });
967                }
968                else {
969                    return false;
970                }
971            }
972            return true;
973        }
974    }
975
976    /**
977     * Fill all the widgets with the content found in the Intent Extra, if any.
978     *
979     * Note that we don't actually check the intent action  (typically VIEW, SENDTO, or SEND).
980     * There is enough overlap in the definitions that it makes more sense to simply check for
981     * all available data and use as much of it as possible.
982     *
983     * With one exception:  EXTRA_STREAM is defined as only valid for ACTION_SEND.
984     *
985     * @param intent the launch intent
986     */
987    /* package */ void initFromIntent(Intent intent) {
988
989        // First, add values stored in top-level extras
990
991        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
992        if (extraStrings != null) {
993            addAddresses(mToView, extraStrings);
994        }
995        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
996        if (extraStrings != null) {
997            addAddresses(mCcView, extraStrings);
998        }
999        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1000        if (extraStrings != null) {
1001            addAddresses(mBccView, extraStrings);
1002        }
1003        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1004        if (extraString != null) {
1005            mSubjectView.setText(extraString);
1006        }
1007
1008        // Next, if we were invoked with a URI, try to interpret it
1009        // We'll take two courses here.  If it's mailto:, there is a specific set of rules
1010        // that define various optional fields.  However, for any other scheme, we'll simply
1011        // take the entire scheme-specific part and interpret it as a possible list of addresses.
1012
1013        final Uri dataUri = intent.getData();
1014        if (dataUri != null) {
1015            if ("mailto".equals(dataUri.getScheme())) {
1016                initializeFromMailTo(dataUri.toString());
1017            } else {
1018                String toText = dataUri.getSchemeSpecificPart();
1019                if (toText != null) {
1020                    addAddresses(mToView, toText.split(","));
1021                }
1022            }
1023        }
1024
1025        // Next, fill in the plaintext (note, this will override mailto:?body=)
1026
1027        CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
1028        if (text != null) {
1029            mMessageContentView.setText(text);
1030        }
1031
1032        // Next, convert EXTRA_STREAM into an attachment
1033
1034        if (Intent.ACTION_SEND.equals(intent.getAction()) && intent.hasExtra(Intent.EXTRA_STREAM)) {
1035            String type = intent.getType();
1036            Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
1037            if (stream != null && type != null) {
1038                if (MimeUtility.mimeTypeMatches(type, Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES)) {
1039                    addAttachment(stream);
1040                }
1041            }
1042        }
1043
1044        // Finally - expose fields that were filled in but are normally hidden, and set focus
1045
1046        if (mCcView.length() > 0) {
1047            mCcView.setVisibility(View.VISIBLE);
1048        }
1049        if (mBccView.length() > 0) {
1050            mBccView.setVisibility(View.VISIBLE);
1051        }
1052        setNewMessageFocus();
1053        mDraftNeedsSaving = false;
1054    }
1055
1056    /**
1057     * When we are launched with an intent that includes a mailto: URI, we can actually
1058     * gather quite a few of our message fields from it.
1059     *
1060     * @mailToString the href (which must start with "mailto:").
1061     */
1062    private void initializeFromMailTo(String mailToString) {
1063
1064        // Chop up everything between mailto: and ? to find recipients
1065        int index = mailToString.indexOf("?");
1066        int length = "mailto".length() + 1;
1067        String to;
1068        try {
1069            // Extract the recipient after mailto:
1070            if (index == -1) {
1071                to = decode(mailToString.substring(length));
1072            } else {
1073                to = decode(mailToString.substring(length, index));
1074            }
1075            addAddresses(mToView, to.split(" ,"));
1076        } catch (UnsupportedEncodingException e) {
1077            Log.e(Email.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'");
1078        }
1079
1080        // Extract the other parameters
1081
1082        // We need to disguise this string as a URI in order to parse it
1083        Uri uri = Uri.parse("foo://" + mailToString);
1084
1085        List<String> cc = uri.getQueryParameters("cc");
1086        addAddresses(mCcView, cc.toArray(new String[cc.size()]));
1087
1088        List<String> otherTo = uri.getQueryParameters("to");
1089        addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()]));
1090
1091        List<String> bcc = uri.getQueryParameters("bcc");
1092        addAddresses(mBccView, bcc.toArray(new String[bcc.size()]));
1093
1094        List<String> subject = uri.getQueryParameters("subject");
1095        if (subject.size() > 0) {
1096            mSubjectView.setText(subject.get(0));
1097        }
1098
1099        List<String> body = uri.getQueryParameters("body");
1100        if (body.size() > 0) {
1101            mMessageContentView.setText(body.get(0));
1102        }
1103    }
1104
1105    private String decode(String s) throws UnsupportedEncodingException {
1106        return URLDecoder.decode(s, "UTF-8");
1107    }
1108
1109    /**
1110     * Pull out the parts of the now loaded source message and apply them to the new message
1111     * depending on the type of message being composed.
1112     * @param message
1113     */
1114    /* package */ /**
1115     * @param message
1116     */
1117    void processSourceMessage(Message message) {
1118        String action = getIntent().getAction();
1119        if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) {
1120            try {
1121                if (message.getSubject() != null &&
1122                        !message.getSubject().toLowerCase().startsWith("re:")) {
1123                    mSubjectView.setText("Re: " + message.getSubject());
1124                }
1125                else {
1126                    mSubjectView.setText(message.getSubject());
1127                }
1128                /*
1129                 * If a reply-to was included with the message use that, otherwise use the from
1130                 * or sender address.
1131                 */
1132                Address[] replyToAddresses;
1133                if (message.getReplyTo().length > 0) {
1134                    addAddresses(mToView, replyToAddresses = message.getReplyTo());
1135                }
1136                else {
1137                    addAddresses(mToView, replyToAddresses = message.getFrom());
1138                }
1139                if (ACTION_REPLY_ALL.equals(action)) {
1140                    for (Address address : message.getRecipients(RecipientType.TO)) {
1141                        if (!address.getAddress().equalsIgnoreCase(mAccount.getEmail())) {
1142                            addAddress(mToView, address.toString());
1143                        }
1144                    }
1145                    if (message.getRecipients(RecipientType.CC).length > 0) {
1146                        for (Address address : message.getRecipients(RecipientType.CC)) {
1147                            if (!Utility.arrayContains(replyToAddresses, address)) {
1148                                addAddress(mCcView, address.toString());
1149                            }
1150                        }
1151                        mCcView.setVisibility(View.VISIBLE);
1152                    }
1153                }
1154
1155                Boolean plainTextFlag = false;
1156                Part part = MimeUtility.findFirstPartByMimeType(message, "text/html");
1157                if (part == null) {
1158                    part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
1159                    plainTextFlag = true;
1160                }
1161
1162                if (part != null) {
1163                    String text = MimeUtility.getTextFromPart(part);
1164                    if (text != null) {
1165                        if (!plainTextFlag) {
1166                            text = EmailHtmlUtil.resolveInlineImage(
1167                                    getContentResolver(), mAccount, text, message, 0);
1168                        } else {
1169                            text = EmailHtmlUtil.escapeCharacterToDisplay(text);
1170                        }
1171                        mQuotedTextBar.setVisibility(View.VISIBLE);
1172                        mQuotedText.setVisibility(View.VISIBLE);
1173                        mQuotedText.loadDataWithBaseURL("email://", text, "text/html",
1174                                "utf-8", null);
1175                    }
1176                }
1177            }
1178            catch (MessagingException me) {
1179                /*
1180                 * This really should not happen at this point but if it does it's okay.
1181                 * The user can continue composing their message.
1182                 */
1183            }
1184        }
1185        else if (ACTION_FORWARD.equals(action)) {
1186            try {
1187                if (message.getSubject() != null &&
1188                        !message.getSubject().toLowerCase().startsWith("fwd:")) {
1189                    mSubjectView.setText("Fwd: " + message.getSubject());
1190                }
1191                else {
1192                    mSubjectView.setText(message.getSubject());
1193                }
1194
1195
1196                Boolean plainTextFlag = false;
1197                Part part = MimeUtility.findFirstPartByMimeType(message, "text/html");
1198                if (part == null) {
1199                    part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
1200                    plainTextFlag = true;
1201                }
1202
1203                if (part != null) {
1204                    String text = MimeUtility.getTextFromPart(part);
1205                    if (text != null) {
1206                        if (!plainTextFlag) {
1207                            text = EmailHtmlUtil.resolveInlineImage(
1208                                    getContentResolver(), mAccount, text, message, 0);
1209                        } else {
1210                            text = EmailHtmlUtil.escapeCharacterToDisplay(text);
1211                        }
1212                        mQuotedTextBar.setVisibility(View.VISIBLE);
1213                        mQuotedText.setVisibility(View.VISIBLE);
1214                        mQuotedText.loadDataWithBaseURL("email://", text, "text/html",
1215                                "utf-8", null);
1216                    }
1217                }
1218                if (!mSourceMessageProcessed) {
1219                    if (!loadAttachments(message, 0)) {
1220                        mHandler.sendEmptyMessage(MSG_SKIPPED_ATTACHMENTS);
1221                    }
1222                }
1223            }
1224            catch (MessagingException me) {
1225                /*
1226                 * This really should not happen at this point but if it does it's okay.
1227                 * The user can continue composing their message.
1228                 */
1229            }
1230        }
1231        else if (ACTION_EDIT_DRAFT.equals(action)) {
1232            try {
1233                mSubjectView.setText(message.getSubject());
1234                addAddresses(mToView, message.getRecipients(RecipientType.TO));
1235                if (message.getRecipients(RecipientType.CC).length > 0) {
1236                    addAddresses(mCcView, message.getRecipients(RecipientType.CC));
1237                    mCcView.setVisibility(View.VISIBLE);
1238                }
1239                if (message.getRecipients(RecipientType.BCC).length > 0) {
1240                    addAddresses(mBccView, message.getRecipients(RecipientType.BCC));
1241                    mBccView.setVisibility(View.VISIBLE);
1242                }
1243                Part part = MimeUtility.findFirstPartByMimeType(message, "text/plain");
1244                if (part != null) {
1245                    String text = MimeUtility.getTextFromPart(part);
1246                    mMessageContentView.setText(text);
1247                }
1248                if (!mSourceMessageProcessed) {
1249                    loadAttachments(message, 0);
1250                }
1251            }
1252            catch (MessagingException me) {
1253                // TODO
1254            }
1255        }
1256
1257        setNewMessageFocus();
1258
1259        mSourceMessageProcessed = true;
1260        mDraftNeedsSaving = mFolder != null && !mFolder.equals(mAccount.getDraftsFolderName(this));
1261    }
1262
1263    /**
1264     * In order to accelerate typing, position the cursor in the first empty field,
1265     * or at the end of the body composition field if none are empty.  Typically, this will
1266     * play out as follows:
1267     *   Reply / Reply All - put cursor in the empty message body
1268     *   Forward - put cursor in the empty To field
1269     *   Edit Draft - put cursor in whatever field still needs entry
1270     */
1271    private void setNewMessageFocus() {
1272        if (mToView.length() == 0) {
1273            mToView.requestFocus();
1274        } else if (mSubjectView.length() == 0) {
1275            mSubjectView.requestFocus();
1276        } else {
1277            mMessageContentView.requestFocus();
1278            // when selecting the message content, explicitly move IP to the end, so you can
1279            // quickly resume typing into a draft
1280            int selection = mMessageContentView.length();
1281            mMessageContentView.setSelection(selection, selection);
1282        }
1283    }
1284
1285    class Listener extends MessagingListener {
1286        @Override
1287        public void loadMessageForViewStarted(EmailStore.Account account, String folder,
1288                String uid) {
1289            mHandler.sendEmptyMessage(MSG_PROGRESS_ON);
1290        }
1291
1292        @Override
1293        public void loadMessageForViewFinished(EmailStore.Account account, String folder,
1294                String uid, Message message) {
1295            mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
1296        }
1297
1298        @Override
1299        public void loadMessageForViewBodyAvailable(EmailStore.Account account, String folder,
1300                String uid, final Message message) {
1301            mSourceMessage = message;
1302            runOnUiThread(new Runnable() {
1303                public void run() {
1304                    processSourceMessage(message);
1305                }
1306            });
1307        }
1308
1309        @Override
1310        public void loadMessageForViewFailed(EmailStore.Account account, String folder, String uid,
1311                final String message) {
1312            mHandler.sendEmptyMessage(MSG_PROGRESS_OFF);
1313            // TODO show network error
1314        }
1315
1316        @Override
1317        public void messageUidChanged(EmailStore.Account account, String folder,String oldUid,
1318                String newUid) {
1319            if (account.equals(mAccount) && (folder.equals(mFolder)
1320                    || (mFolder == null
1321                            && folder.equals(mAccount.getDraftsFolderName(MessageCompose.this))))) {
1322                if (oldUid.equals(mDraftUid)) {
1323                    mDraftUid = newUid;
1324                }
1325                if (oldUid.equals(mSourceMessageUid)) {
1326                    mSourceMessageUid = newUid;
1327                }
1328                if (mSourceMessage != null && (oldUid.equals(mSourceMessage.getUid()))) {
1329                    mSourceMessage.setUid(newUid);
1330                }
1331            }
1332        }
1333    }
1334}
1335