MessageCompose.java revision 2193962ca2b3157e79f731736afa2a0c972e778a
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.Controller;
20import com.android.email.Email;
21import com.android.email.EmailAddressAdapter;
22import com.android.email.EmailAddressValidator;
23import com.android.email.R;
24import com.android.email.Utility;
25import com.android.email.mail.internet.EmailHtmlUtil;
26import com.android.email.provider.EmailContent;
27import com.android.email.provider.EmailContent.Account;
28import com.android.email.provider.EmailContent.Attachment;
29import com.android.email.provider.EmailContent.Body;
30import com.android.email.provider.EmailContent.BodyColumns;
31import com.android.email.provider.EmailContent.Message;
32import com.android.email.provider.EmailContent.MessageColumns;
33import com.android.emailcommon.internet.MimeUtility;
34import com.android.emailcommon.mail.Address;
35
36import android.app.ActionBar;
37import android.app.Activity;
38import android.app.ActivityManager;
39import android.content.ActivityNotFoundException;
40import android.content.ContentResolver;
41import android.content.ContentUris;
42import android.content.ContentValues;
43import android.content.Context;
44import android.content.Intent;
45import android.content.pm.ActivityInfo;
46import android.database.Cursor;
47import android.net.Uri;
48import android.os.AsyncTask;
49import android.os.Bundle;
50import android.os.Parcelable;
51import android.provider.OpenableColumns;
52import android.text.InputFilter;
53import android.text.SpannableStringBuilder;
54import android.text.Spanned;
55import android.text.TextUtils;
56import android.text.TextWatcher;
57import android.text.util.Rfc822Tokenizer;
58import android.util.Log;
59import android.view.Menu;
60import android.view.MenuItem;
61import android.view.View;
62import android.view.View.OnClickListener;
63import android.view.View.OnFocusChangeListener;
64import android.webkit.WebView;
65import android.widget.CheckBox;
66import android.widget.EditText;
67import android.widget.ImageButton;
68import android.widget.LinearLayout;
69import android.widget.MultiAutoCompleteTextView;
70import android.widget.TextView;
71import android.widget.Toast;
72
73import java.io.File;
74import java.io.UnsupportedEncodingException;
75import java.net.URLDecoder;
76import java.util.ArrayList;
77import java.util.List;
78
79
80/**
81 * Activity to compose a message.
82 *
83 * TODO Revive shortcuts command for removed menu options.
84 * C: add cc/bcc
85 * N: add attachment
86 */
87public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener,
88        DeleteMessageConfirmationDialog.Callback {
89    private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
90    private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
91    private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
92    private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
93
94    private static final String EXTRA_ACCOUNT_ID = "account_id";
95    private static final String EXTRA_MESSAGE_ID = "message_id";
96    /** If the intent is sent from the email app itself, it should have this boolean extra. */
97    private static final String EXTRA_FROM_WITHIN_APP = "from_within_app";
98
99    private static final String STATE_KEY_CC_SHOWN =
100        "com.android.email.activity.MessageCompose.ccShown";
101    private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
102        "com.android.email.activity.MessageCompose.quotedTextShown";
103    private static final String STATE_KEY_SOURCE_MESSAGE_PROCED =
104        "com.android.email.activity.MessageCompose.stateKeySourceMessageProced";
105    private static final String STATE_KEY_DRAFT_ID =
106        "com.android.email.activity.MessageCompose.draftId";
107
108    private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
109
110    private static final String[] ATTACHMENT_META_SIZE_PROJECTION = {
111        OpenableColumns.SIZE
112    };
113    private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0;
114
115    // Is set while the draft is saved by a background thread.
116    // Is static in order to be shared between the two activity instances
117    // on orientation change.
118    private static boolean sSaveInProgress = false;
119    // lock and condition for sSaveInProgress
120    private static final Object sSaveInProgressCondition = new Object();
121
122    private Account mAccount;
123
124    // mDraft has mId > 0 after the first draft save.
125    private Message mDraft = new Message();
126
127    // mSource is only set for REPLY, REPLY_ALL and FORWARD, and contains the source message.
128    private Message mSource;
129
130    // we use mAction instead of Intent.getAction() because sometimes we need to
131    // re-write the action to EDIT_DRAFT.
132    private String mAction;
133
134    /**
135     * Indicates that the source message has been processed at least once and should not
136     * be processed on any subsequent loads. This protects us from adding attachments that
137     * have already been added from the restore of the view state.
138     */
139    private boolean mSourceMessageProcessed = false;
140
141    private ActionBar mActionBar;
142    private TextView mFromView;
143    private MultiAutoCompleteTextView mToView;
144    private MultiAutoCompleteTextView mCcView;
145    private MultiAutoCompleteTextView mBccView;
146    private View mCcBccContainer;
147    private EditText mSubjectView;
148    private EditText mMessageContentView;
149    private View mAttachmentContainer;
150    private LinearLayout mAttachments;
151    private View mQuotedTextBar;
152    private CheckBox mIncludeQuotedTextCheckBox;
153    private WebView mQuotedText;
154
155    private Controller mController;
156    private boolean mDraftNeedsSaving;
157    private boolean mMessageLoaded;
158    private AsyncTask<Long, Void, Attachment[]> mLoadAttachmentsTask;
159    private AsyncTask<Void, Void, Object[]> mLoadMessageTask;
160
161    private EmailAddressAdapter mAddressAdapterTo;
162    private EmailAddressAdapter mAddressAdapterCc;
163    private EmailAddressAdapter mAddressAdapterBcc;
164
165    /** Whether the save command should be enabled. */
166    private boolean mSaveEnabled;
167
168    private static Intent getBaseIntent(Context context) {
169        Intent i = new Intent(context, MessageCompose.class);
170        i.putExtra(EXTRA_FROM_WITHIN_APP, true);
171        return i;
172    }
173
174    /**
175     * Create an {@link Intent} that can start the message compose activity. If accountId -1,
176     * the default account will be used; otherwise, the specified account is used.
177     */
178    public static Intent getMessageComposeIntent(Context context, long accountId) {
179        Intent i = getBaseIntent(context);
180        i.putExtra(EXTRA_ACCOUNT_ID, accountId);
181        return i;
182    }
183
184    /**
185     * Compose a new message using the given account. If account is -1 the default account
186     * will be used.
187     * @param context
188     * @param accountId
189     */
190    public static void actionCompose(Context context, long accountId) {
191       try {
192           Intent i = getMessageComposeIntent(context, accountId);
193           context.startActivity(i);
194       } catch (ActivityNotFoundException anfe) {
195           // Swallow it - this is usually a race condition, especially under automated test.
196           // (The message composer might have been disabled)
197           Email.log(anfe.toString());
198       }
199    }
200
201    /**
202     * Compose a new message using a uri (mailto:) and a given account.  If account is -1 the
203     * default account will be used.
204     * @param context
205     * @param uriString
206     * @param accountId
207     * @return true if startActivity() succeeded
208     */
209    public static boolean actionCompose(Context context, String uriString, long accountId) {
210        try {
211            Intent i = getMessageComposeIntent(context, accountId);
212            i.setAction(Intent.ACTION_SEND);
213            i.setData(Uri.parse(uriString));
214            context.startActivity(i);
215            return true;
216        } catch (ActivityNotFoundException anfe) {
217            // Swallow it - this is usually a race condition, especially under automated test.
218            // (The message composer might have been disabled)
219            Email.log(anfe.toString());
220            return false;
221        }
222    }
223
224    /**
225     * Compose a new message as a reply to the given message. If replyAll is true the function
226     * is reply all instead of simply reply.
227     * @param context
228     * @param messageId
229     * @param replyAll
230     */
231    public static void actionReply(Context context, long messageId, boolean replyAll) {
232        startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId);
233    }
234
235    /**
236     * Compose a new message as a forward of the given message.
237     * @param context
238     * @param messageId
239     */
240    public static void actionForward(Context context, long messageId) {
241        startActivityWithMessage(context, ACTION_FORWARD, messageId);
242    }
243
244    /**
245     * Continue composition of the given message. This action modifies the way this Activity
246     * handles certain actions.
247     * Save will attempt to replace the message in the given folder with the updated version.
248     * Discard will delete the message from the given folder.
249     * @param context
250     * @param messageId the message id.
251     */
252    public static void actionEditDraft(Context context, long messageId) {
253        startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId);
254    }
255
256    private static void startActivityWithMessage(Context context, String action, long messageId) {
257        Intent i = getBaseIntent(context);
258        i.putExtra(EXTRA_MESSAGE_ID, messageId);
259        i.setAction(action);
260        context.startActivity(i);
261    }
262
263    private void setAccount(Intent intent) {
264        long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1);
265        if (accountId == -1) {
266            accountId = Account.getDefaultAccountId(this);
267        }
268        if (accountId == -1) {
269            // There are no accounts set up. This should not have happened. Prompt the
270            // user to set up an account as an acceptable bailout.
271            AccountFolderList.actionShowAccounts(this);
272            finish();
273        } else {
274            setAccount(Account.restoreAccountWithId(this, accountId));
275        }
276    }
277
278    private void setAccount(Account account) {
279        if (account == null) {
280            throw new IllegalArgumentException();
281        }
282        mAccount = account;
283        mFromView.setText(account.mEmailAddress);
284        mAddressAdapterTo.setAccount(account);
285        mAddressAdapterCc.setAccount(account);
286        mAddressAdapterBcc.setAccount(account);
287    }
288
289    @Override
290    public void onCreate(Bundle savedInstanceState) {
291        super.onCreate(savedInstanceState);
292        ActivityHelper.debugSetWindowFlags(this);
293        setContentView(R.layout.message_compose);
294
295        mController = Controller.getInstance(getApplication());
296        initViews();
297        setDraftNeedsSaving(false);
298
299        long draftId = -1;
300        if (savedInstanceState != null) {
301            // This data gets used in onCreate, so grab it here instead of onRestoreInstanceState
302            mSourceMessageProcessed =
303                savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false);
304            draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, -1);
305        }
306
307        // Show the back arrow on the action bar.
308        getActionBar().setDisplayOptions(
309                ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
310
311        Intent intent = getIntent();
312        mAction = intent.getAction();
313
314        if (draftId != -1) {
315            // this means that we saved the draft earlier,
316            // so now we need to disregard the intent action and do
317            // EDIT_DRAFT instead.
318            mAction = ACTION_EDIT_DRAFT;
319            mDraft.mId = draftId;
320        }
321
322        // Handle the various intents that launch the message composer
323        if (Intent.ACTION_VIEW.equals(mAction)
324                || Intent.ACTION_SENDTO.equals(mAction)
325                || Intent.ACTION_SEND.equals(mAction)
326                || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) {
327            setAccount(intent);
328            // Use the fields found in the Intent to prefill as much of the message as possible
329            initFromIntent(intent);
330            setDraftNeedsSaving(true);
331            mMessageLoaded = true;
332            mSourceMessageProcessed = true;
333        } else {
334            // Otherwise, handle the internal cases (Message Composer invoked from within app)
335            long messageId = draftId != -1 ? draftId : intent.getLongExtra(EXTRA_MESSAGE_ID, -1);
336            if (messageId != -1) {
337                mLoadMessageTask = new LoadMessageTask(messageId).execute();
338            } else {
339                setAccount(intent);
340                // Since this is a new message, we don't need to call LoadMessageTask.
341                // But we DO need to set mMessageLoaded to indicate the message can be sent
342                mMessageLoaded = true;
343                mSourceMessageProcessed = true;
344            }
345            setInitialComposeText(null, (mAccount != null) ? mAccount.mSignature : null);
346        }
347
348        if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) ||
349                ACTION_FORWARD.equals(mAction) || ACTION_EDIT_DRAFT.equals(mAction)) {
350            /*
351             * If we need to load the message we add ourself as a message listener here
352             * so we can kick it off. Normally we add in onResume but we don't
353             * want to reload the message every time the activity is resumed.
354             * There is no harm in adding twice.
355             */
356            // TODO: signal the controller to load the message
357        }
358    }
359
360    // needed for unit tests
361    @Override
362    public void setIntent(Intent intent) {
363        super.setIntent(intent);
364        mAction = intent.getAction();
365    }
366
367    @Override
368    public void onResume() {
369        super.onResume();
370
371        // Exit immediately if the accounts list has changed (e.g. externally deleted)
372        if (Email.getNotifyUiAccountsChanged()) {
373            Welcome.actionStart(this);
374            finish();
375            return;
376        }
377    }
378
379    @Override
380    public void onPause() {
381        super.onPause();
382        saveIfNeeded();
383    }
384
385    /**
386     * We override onDestroy to make sure that the WebView gets explicitly destroyed.
387     * Otherwise it can leak native references.
388     */
389    @Override
390    public void onDestroy() {
391        super.onDestroy();
392        mQuotedText.destroy();
393        mQuotedText = null;
394
395        Utility.cancelTaskInterrupt(mLoadAttachmentsTask);
396        mLoadAttachmentsTask = null;
397        Utility.cancelTaskInterrupt(mLoadMessageTask);
398        mLoadMessageTask = null;
399
400        if (mAddressAdapterTo != null) {
401            mAddressAdapterTo.close();
402        }
403        if (mAddressAdapterCc != null) {
404            mAddressAdapterCc.close();
405        }
406        if (mAddressAdapterBcc != null) {
407            mAddressAdapterBcc.close();
408        }
409    }
410
411    /**
412     * The framework handles most of the fields, but we need to handle stuff that we
413     * dynamically show and hide:
414     * Cc field,
415     * Bcc field,
416     * Quoted text,
417     */
418    @Override
419    protected void onSaveInstanceState(Bundle outState) {
420        super.onSaveInstanceState(outState);
421        long draftId = getOrCreateDraftId();
422        if (draftId != -1) {
423            outState.putLong(STATE_KEY_DRAFT_ID, draftId);
424        }
425        outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE);
426        outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
427                mQuotedTextBar.getVisibility() == View.VISIBLE);
428        outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed);
429    }
430
431    @Override
432    protected void onRestoreInstanceState(Bundle savedInstanceState) {
433        super.onRestoreInstanceState(savedInstanceState);
434        if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) {
435            showCcBccFields();
436        }
437        mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
438                View.VISIBLE : View.GONE);
439        mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ?
440                View.VISIBLE : View.GONE);
441        setDraftNeedsSaving(false);
442    }
443
444    /**
445     * @return true if the activity was opened by the email app itself.
446     */
447    private boolean isOpenedFromWithinApp() {
448        Intent i = getIntent();
449        return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false));
450    }
451
452    private void setDraftNeedsSaving(boolean needsSaving) {
453        mDraftNeedsSaving = needsSaving;
454        mSaveEnabled = needsSaving;
455        invalidateOptionsMenu();
456    }
457
458    public void setFocusShifter(int fromViewId, final int targetViewId) {
459        View label = findViewById(fromViewId);
460        // Labels don't exist on the phone UI, so null check.
461        if (label != null) {
462            label.setOnClickListener(new View.OnClickListener() {
463                @Override
464                public void onClick(View v) {
465                    findViewById(targetViewId).requestFocus();
466                }
467            });
468        }
469    }
470
471    private void initViews() {
472        mActionBar = getActionBar();
473        mFromView = (TextView)findViewById(R.id.from);
474        mToView = (MultiAutoCompleteTextView)findViewById(R.id.to);
475        mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc);
476        mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc);
477        mCcBccContainer = findViewById(R.id.cc_bcc_container);
478        mSubjectView = (EditText)findViewById(R.id.subject);
479        mMessageContentView = (EditText)findViewById(R.id.message_content);
480        mAttachments = (LinearLayout)findViewById(R.id.attachments);
481        mAttachmentContainer = (LinearLayout)findViewById(R.id.attachment_container);
482        mQuotedTextBar = findViewById(R.id.quoted_text_bar);
483        mIncludeQuotedTextCheckBox = (CheckBox) findViewById(R.id.include_quoted_text);
484        mQuotedText = (WebView)findViewById(R.id.quoted_text);
485
486        TextWatcher watcher = new TextWatcher() {
487            public void beforeTextChanged(CharSequence s, int start,
488                                          int before, int after) { }
489
490            public void onTextChanged(CharSequence s, int start,
491                                          int before, int count) {
492                setDraftNeedsSaving(true);
493            }
494
495            public void afterTextChanged(android.text.Editable s) { }
496        };
497
498        /**
499         * Implements special address cleanup rules:
500         * The first space key entry following an "@" symbol that is followed by any combination
501         * of letters and symbols, including one+ dots and zero commas, should insert an extra
502         * comma (followed by the space).
503         */
504        InputFilter recipientFilter = new InputFilter() {
505
506            public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
507                    int dstart, int dend) {
508
509                // quick check - did they enter a single space?
510                if (end-start != 1 || source.charAt(start) != ' ') {
511                    return null;
512                }
513
514                // determine if the characters before the new space fit the pattern
515                // follow backwards and see if we find a comma, dot, or @
516                int scanBack = dstart;
517                boolean dotFound = false;
518                while (scanBack > 0) {
519                    char c = dest.charAt(--scanBack);
520                    switch (c) {
521                        case '.':
522                            dotFound = true;    // one or more dots are req'd
523                            break;
524                        case ',':
525                            return null;
526                        case '@':
527                            if (!dotFound) {
528                                return null;
529                            }
530
531                            // we have found a comma-insert case.  now just do it
532                            // in the least expensive way we can.
533                            if (source instanceof Spanned) {
534                                SpannableStringBuilder sb = new SpannableStringBuilder(",");
535                                sb.append(source);
536                                return sb;
537                            } else {
538                                return ", ";
539                            }
540                        default:
541                            // just keep going
542                    }
543                }
544
545                // no termination cases were found, so don't edit the input
546                return null;
547            }
548        };
549        InputFilter[] recipientFilters = new InputFilter[] { recipientFilter };
550
551        mToView.addTextChangedListener(watcher);
552        mCcView.addTextChangedListener(watcher);
553        mBccView.addTextChangedListener(watcher);
554        mSubjectView.addTextChangedListener(watcher);
555        mMessageContentView.addTextChangedListener(watcher);
556
557        // NOTE: assumes no other filters are set
558        mToView.setFilters(recipientFilters);
559        mCcView.setFilters(recipientFilters);
560        mBccView.setFilters(recipientFilters);
561
562        /*
563         * We set this to invisible by default. Other methods will turn it back on if it's
564         * needed.
565         */
566        mQuotedTextBar.setVisibility(View.GONE);
567        setIncludeQuotedText(false, false);
568
569        mIncludeQuotedTextCheckBox.setOnClickListener(this);
570
571        EmailAddressValidator addressValidator = new EmailAddressValidator();
572
573        setupAddressAdapters();
574        mToView.setAdapter(mAddressAdapterTo);
575        mToView.setTokenizer(new Rfc822Tokenizer());
576        mToView.setValidator(addressValidator);
577
578        mCcView.setAdapter(mAddressAdapterCc);
579        mCcView.setTokenizer(new Rfc822Tokenizer());
580        mCcView.setValidator(addressValidator);
581
582        mBccView.setAdapter(mAddressAdapterBcc);
583        mBccView.setTokenizer(new Rfc822Tokenizer());
584        mBccView.setValidator(addressValidator);
585
586        findViewById(R.id.add_cc_bcc).setOnClickListener(this);
587        findViewById(R.id.add_attachment).setOnClickListener(this);
588
589        setFocusShifter(R.id.to_label, R.id.to);
590        setFocusShifter(R.id.cc_label, R.id.cc);
591        setFocusShifter(R.id.bcc_label, R.id.bcc);
592        setFocusShifter(R.id.subject_label, R.id.subject);
593        setFocusShifter(R.id.tap_trap, R.id.message_content);
594
595        mSubjectView.setOnFocusChangeListener(this);
596        mMessageContentView.setOnFocusChangeListener(this);
597
598        updateAttachmentContainer();
599    }
600
601    /**
602     * Set up address auto-completion adapters.
603     */
604    private void setupAddressAdapters() {
605        mAddressAdapterTo = new EmailAddressAdapter(this);
606        mAddressAdapterCc = new EmailAddressAdapter(this);
607        mAddressAdapterBcc = new EmailAddressAdapter(this);
608    }
609
610    private class LoadMessageTask extends AsyncTask<Void, Void, Object[]> {
611        private final long mMessageId;
612
613        public LoadMessageTask(long messageId) {
614            mMessageId = messageId;
615        }
616
617        @Override
618        protected Object[] doInBackground(Void... params) {
619            synchronized (sSaveInProgressCondition) {
620                while (sSaveInProgress) {
621                    try {
622                        sSaveInProgressCondition.wait();
623                    } catch (InterruptedException e) {
624                        // ignore & retry loop
625                    }
626                }
627            }
628            Message message = Message.restoreMessageWithId(MessageCompose.this, mMessageId);
629            if (message == null) {
630                return new Object[] {null, null};
631            }
632            long accountId = message.mAccountKey;
633            Account account = Account.restoreAccountWithId(MessageCompose.this, accountId);
634            try {
635                // Body body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId);
636                message.mHtml = Body.restoreBodyHtmlWithMessageId(MessageCompose.this, message.mId);
637                message.mText = Body.restoreBodyTextWithMessageId(MessageCompose.this, message.mId);
638                boolean isEditDraft = ACTION_EDIT_DRAFT.equals(mAction);
639                // the reply fields are only filled/used for Drafts.
640                if (isEditDraft) {
641                    message.mHtmlReply =
642                        Body.restoreReplyHtmlWithMessageId(MessageCompose.this, message.mId);
643                    message.mTextReply =
644                        Body.restoreReplyTextWithMessageId(MessageCompose.this, message.mId);
645                    message.mIntroText =
646                        Body.restoreIntroTextWithMessageId(MessageCompose.this, message.mId);
647                    message.mSourceKey = Body.restoreBodySourceKey(MessageCompose.this,
648                                                                   message.mId);
649                } else {
650                    message.mHtmlReply = null;
651                    message.mTextReply = null;
652                    message.mIntroText = null;
653                }
654            } catch (RuntimeException e) {
655                Log.d(Email.LOG_TAG, "Exception while loading message body: " + e);
656                return new Object[] {null, null};
657            }
658            return new Object[]{message, account};
659        }
660
661        @Override
662        protected void onPostExecute(Object[] messageAndAccount) {
663            if (messageAndAccount == null) {
664                return;
665            }
666
667            final Message message = (Message) messageAndAccount[0];
668            final Account account = (Account) messageAndAccount[1];
669            if (message == null && account == null) {
670                // Something unexpected happened:
671                // the message or the body couldn't be loaded by SQLite.
672                // Bail out.
673                Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
674                finish();
675                return;
676            }
677
678            // Drafts and "forwards" need to include attachments from the original unless the
679            // account is marked as supporting smart forward
680            if (ACTION_EDIT_DRAFT.equals(mAction) || ACTION_FORWARD.equals(mAction)) {
681                final boolean draft = ACTION_EDIT_DRAFT.equals(mAction);
682                if (draft) {
683                    mDraft = message;
684                } else {
685                    mSource = message;
686                }
687                mLoadAttachmentsTask = new AsyncTask<Long, Void, Attachment[]>() {
688                    @Override
689                    protected Attachment[] doInBackground(Long... messageIds) {
690                        return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this,
691                                messageIds[0]);
692                    }
693                    @Override
694                    protected void onPostExecute(Attachment[] attachments) {
695                        if (attachments == null) {
696                            return;
697                        }
698                        boolean smartForward =
699                            (account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0;
700
701                        for (Attachment attachment : attachments) {
702                            if (smartForward && !draft) {
703                                attachment.mFlags |= Attachment.FLAG_SMART_FORWARD;
704                            }
705                            addAttachment(attachment, !smartForward);
706                        }
707                    }
708                }.execute(message.mId);
709            } else if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) {
710                mSource = message;
711            } else if (Email.LOGD) {
712                Email.log("Action " + mAction + " has unexpected EXTRA_MESSAGE_ID");
713            }
714
715            setAccount(account);
716            processSourceMessageGuarded(message, mAccount);
717            mMessageLoaded = true;
718        }
719    }
720
721    public void onFocusChange(View view, boolean focused) {
722        if (focused) {
723            switch (view.getId()) {
724                case R.id.message_content:
725                    setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null);
726            }
727        }
728    }
729
730    private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
731        if (addresses == null) {
732            return;
733        }
734        for (Address address : addresses) {
735            addAddress(view, address.toString());
736        }
737    }
738
739    private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) {
740        if (addresses == null) {
741            return;
742        }
743        for (String oneAddress : addresses) {
744            addAddress(view, oneAddress);
745        }
746    }
747
748    private void addAddress(MultiAutoCompleteTextView view, String address) {
749        view.append(address + ", ");
750    }
751
752    private String getPackedAddresses(TextView view) {
753        Address[] addresses = Address.parse(view.getText().toString().trim());
754        return Address.pack(addresses);
755    }
756
757    private Address[] getAddresses(TextView view) {
758        Address[] addresses = Address.parse(view.getText().toString().trim());
759        return addresses;
760    }
761
762    /*
763     * Computes a short string indicating the destination of the message based on To, Cc, Bcc.
764     * If only one address appears, returns the friendly form of that address.
765     * Otherwise returns the friendly form of the first address appended with "and N others".
766     */
767    private String makeDisplayName(String packedTo, String packedCc, String packedBcc) {
768        Address first = null;
769        int nRecipients = 0;
770        for (String packed: new String[] {packedTo, packedCc, packedBcc}) {
771            Address[] addresses = Address.unpack(packed);
772            nRecipients += addresses.length;
773            if (first == null && addresses.length > 0) {
774                first = addresses[0];
775            }
776        }
777        if (nRecipients == 0) {
778            return "";
779        }
780        String friendly = first.toFriendly();
781        if (nRecipients == 1) {
782            return friendly;
783        }
784        return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1);
785    }
786
787    private ContentValues getUpdateContentValues(Message message) {
788        ContentValues values = new ContentValues();
789        values.put(MessageColumns.TIMESTAMP, message.mTimeStamp);
790        values.put(MessageColumns.FROM_LIST, message.mFrom);
791        values.put(MessageColumns.TO_LIST, message.mTo);
792        values.put(MessageColumns.CC_LIST, message.mCc);
793        values.put(MessageColumns.BCC_LIST, message.mBcc);
794        values.put(MessageColumns.SUBJECT, message.mSubject);
795        values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName);
796        values.put(MessageColumns.FLAG_READ, message.mFlagRead);
797        values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded);
798        values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment);
799        values.put(MessageColumns.FLAGS, message.mFlags);
800        return values;
801    }
802
803    /**
804     * @param message The message to be updated.
805     * @param account the account (used to obtain From: address).
806     * @param hasAttachments true if it has one or more attachment.
807     * @param sending set true if the message is about to sent, in which case we perform final
808     *        clean up;
809     */
810    private void updateMessage(Message message, Account account, boolean hasAttachments,
811            boolean sending) {
812        if (message.mMessageId == null || message.mMessageId.length() == 0) {
813            message.mMessageId = Utility.generateMessageId();
814        }
815        message.mTimeStamp = System.currentTimeMillis();
816        message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack();
817        message.mTo = getPackedAddresses(mToView);
818        message.mCc = getPackedAddresses(mCcView);
819        message.mBcc = getPackedAddresses(mBccView);
820        message.mSubject = mSubjectView.getText().toString();
821        message.mText = mMessageContentView.getText().toString();
822        message.mAccountKey = account.mId;
823        message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc);
824        message.mFlagRead = true;
825        message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
826        message.mFlagAttachment = hasAttachments;
827        // Use the Intent to set flags saying this message is a reply or a forward and save the
828        // unique id of the source message
829        if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) {
830            if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)
831                    || ACTION_FORWARD.equals(mAction)) {
832                message.mSourceKey = mSource.mId;
833                // Get the body of the source message here
834                message.mHtmlReply = mSource.mHtml;
835                message.mTextReply = mSource.mText;
836            }
837
838            String fromAsString = Address.unpackToString(mSource.mFrom);
839            if (ACTION_FORWARD.equals(mAction)) {
840                message.mFlags |= Message.FLAG_TYPE_FORWARD;
841                String subject = mSource.mSubject;
842                String to = Address.unpackToString(mSource.mTo);
843                String cc = Address.unpackToString(mSource.mCc);
844                message.mIntroText =
845                    getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString,
846                            to != null ? to : "", cc != null ? cc : "");
847            } else {
848                message.mFlags |= Message.FLAG_TYPE_REPLY;
849                message.mIntroText =
850                    getString(R.string.message_compose_reply_header_fmt, fromAsString);
851            }
852        }
853
854        if (includeQuotedText()) {
855            message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
856        } else {
857            message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
858            if (sending) {
859                // If we are about to send a message, and not including the original message,
860                // clear the related field.
861                // We can't do this until the last minutes, so that the user can change their
862                // mind later and want to include it again.
863                mDraft.mIntroText = null;
864                mDraft.mTextReply = null;
865                mDraft.mHtmlReply = null;
866                mDraft.mSourceKey = 0;
867                mDraft.mFlags &= ~Message.FLAG_TYPE_MASK;
868            }
869        }
870    }
871
872    private Attachment[] getAttachmentsFromUI() {
873        int count = mAttachments.getChildCount();
874        Attachment[] attachments = new Attachment[count];
875        for (int i = 0; i < count; ++i) {
876            attachments[i] = (Attachment) mAttachments.getChildAt(i).getTag();
877        }
878        return attachments;
879    }
880
881    /* This method does DB operations in UI thread because
882       the draftId is needed by onSaveInstanceState() which can't wait for it
883       to be saved in the background.
884       TODO: This will cause ANRs, so we need to find a better solution.
885    */
886    private long getOrCreateDraftId() {
887        synchronized (mDraft) {
888            if (mDraft.mId > 0) {
889                return mDraft.mId;
890            }
891            // don't save draft if the source message did not load yet
892            if (!mMessageLoaded) {
893                return -1;
894            }
895            final Attachment[] attachments = getAttachmentsFromUI();
896            updateMessage(mDraft, mAccount, attachments.length > 0, false);
897            mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS);
898            return mDraft.mId;
899        }
900    }
901
902    private class SendOrSaveMessageTask extends AsyncTask<Void, Void, Void> {
903        private final boolean mSend;
904
905        public SendOrSaveMessageTask(boolean send) {
906            if (send && ActivityManager.isUserAMonkey()) {
907                Log.d(Email.LOG_TAG, "Inhibiting send while monkey is in charge.");
908                send = false;
909            }
910            mSend = send;
911        }
912
913        @Override
914        protected Void doInBackground(Void... params) {
915            synchronized (mDraft) {
916                final Attachment[] attachments = getAttachmentsFromUI();
917                updateMessage(mDraft, mAccount, attachments.length > 0, mSend);
918                ContentResolver resolver = getContentResolver();
919                if (mDraft.isSaved()) {
920                    // Update the message
921                    Uri draftUri =
922                        ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId);
923                    resolver.update(draftUri, getUpdateContentValues(mDraft), null, null);
924                    // Update the body
925                    ContentValues values = new ContentValues();
926                    values.put(BodyColumns.TEXT_CONTENT, mDraft.mText);
927                    values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply);
928                    values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply);
929                    values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText);
930                    values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey);
931                    Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values);
932                } else {
933                    // mDraft.mId is set upon return of saveToMailbox()
934                    mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS);
935                }
936                // For any unloaded attachment, set the flag saying we need it loaded
937                boolean hasUnloadedAttachments = false;
938                for (Attachment attachment : attachments) {
939
940                    if (attachment.mContentUri == null &&
941                            ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) != 0)) {
942                        attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD;
943                        hasUnloadedAttachments = true;
944                        if (Email.DEBUG){
945                            Log.d("MessageCompose",
946                                    "Requesting download of attachment #" + attachment.mId);
947                        }
948                    }
949                    // Make sure the UI version of the attachment has the now-correct id; we will
950                    // use the id again when coming back from picking new attachments
951                    if (!attachment.isSaved()) {
952                        // this attachment is new so save it to DB.
953                        attachment.mMessageKey = mDraft.mId;
954                        attachment.save(MessageCompose.this);
955                    } else if (attachment.mMessageKey != mDraft.mId) {
956                        // We clone the attachment and save it again; otherwise, it will
957                        // continue to point to the source message.  From this point forward,
958                        // the attachments will be independent of the original message in the
959                        // database; however, we still need the message on the server in order
960                        // to retrieve unloaded attachments
961                        attachment.mMessageKey = mDraft.mId;
962                        ContentValues cv = attachment.toContentValues();
963                        cv.put(Attachment.FLAGS, attachment.mFlags);
964                        cv.put(Attachment.MESSAGE_KEY, mDraft.mId);
965                        getContentResolver().insert(Attachment.CONTENT_URI, cv);
966                    }
967                }
968
969                if (mSend) {
970                    // Let the user know if message sending might be delayed by background
971                    // downlading of unloaded attachments
972                    if (hasUnloadedAttachments) {
973                        Utility.showToast(MessageCompose.this,
974                                R.string.message_view_attachment_background_load);
975                    }
976                    mController.sendMessage(mDraft.mId, mDraft.mAccountKey);
977                }
978                return null;
979            }
980        }
981
982        @Override
983        protected void onPostExecute(Void param) {
984            synchronized (sSaveInProgressCondition) {
985                sSaveInProgress = false;
986                sSaveInProgressCondition.notify();
987            }
988            if (isCancelled()) {
989                return;
990            }
991            // Don't display the toast if the user is just changing the orientation
992            if (!mSend && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
993                Toast.makeText(MessageCompose.this, R.string.message_saved_toast,
994                        Toast.LENGTH_LONG).show();
995            }
996        }
997    }
998
999    /**
1000     * Send or save a message:
1001     * - out of the UI thread
1002     * - write to Drafts
1003     * - if send, invoke Controller.sendMessage()
1004     * - when operation is complete, display toast
1005     */
1006    private void sendOrSaveMessage(boolean send) {
1007        if (!mMessageLoaded) {
1008            // early save, before the message was loaded: do nothing
1009            return;
1010        }
1011        synchronized (sSaveInProgressCondition) {
1012            sSaveInProgress = true;
1013        }
1014        new SendOrSaveMessageTask(send).execute();
1015   }
1016
1017    private void saveIfNeeded() {
1018        if (!mDraftNeedsSaving) {
1019            return;
1020        }
1021        setDraftNeedsSaving(false);
1022        sendOrSaveMessage(false);
1023    }
1024
1025    /**
1026     * Checks whether all the email addresses listed in TO, CC, BCC are valid.
1027     */
1028    /* package */ boolean isAddressAllValid() {
1029        for (TextView view : new TextView[]{mToView, mCcView, mBccView}) {
1030            String addresses = view.getText().toString().trim();
1031            if (!Address.isAllValid(addresses)) {
1032                view.setError(getString(R.string.message_compose_error_invalid_email));
1033                return false;
1034            }
1035        }
1036        return true;
1037    }
1038
1039    private void onSend() {
1040        if (!isAddressAllValid()) {
1041            Toast.makeText(this, getString(R.string.message_compose_error_invalid_email),
1042                           Toast.LENGTH_LONG).show();
1043        } else if (getAddresses(mToView).length == 0 &&
1044                getAddresses(mCcView).length == 0 &&
1045                getAddresses(mBccView).length == 0) {
1046            mToView.setError(getString(R.string.message_compose_error_no_recipients));
1047            Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
1048                    Toast.LENGTH_LONG).show();
1049        } else {
1050            sendOrSaveMessage(true);
1051            setDraftNeedsSaving(false);
1052            finish();
1053        }
1054    }
1055
1056    private void onDiscard() {
1057        DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog");
1058    }
1059
1060    /**
1061     * Called when ok on the "discard draft" dialog is pressed.  Actually delete the draft.
1062     */
1063    @Override
1064    public void onDeleteMessageConfirmationDialogOkPressed() {
1065        if (mDraft.mId > 0) {
1066            // By the way, we can't pass the message ID from onDiscard() to here (using a
1067            // dialog argument or whatever), because you can rotate the screen when the dialog is
1068            // shown, and during rotation we save & restore the draft.  If it's the
1069            // first save, we give it an ID at this point for the first time (and last time).
1070            // Which means it's possible for a draft to not have an ID in onDiscard(),
1071            // but here.
1072            mController.deleteMessage(mDraft.mId, mDraft.mAccountKey);
1073        }
1074        Utility.showToast(MessageCompose.this, R.string.message_discarded_toast);
1075        setDraftNeedsSaving(false);
1076        finish();
1077    }
1078
1079    private void onSave() {
1080        saveIfNeeded();
1081        finish();
1082    }
1083
1084    private void showCcBccFieldsIfFilled() {
1085        if ((mCcView.length() > 0) || (mBccView.length() > 0)) {
1086            showCcBccFields();
1087        }
1088    }
1089
1090    private void showCcBccFields() {
1091        mCcBccContainer.setVisibility(View.VISIBLE);
1092        findViewById(R.id.add_cc_bcc).setVisibility(View.INVISIBLE);
1093    }
1094
1095    /**
1096     * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
1097     */
1098    private void onAddAttachment() {
1099        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
1100        i.addCategory(Intent.CATEGORY_OPENABLE);
1101        i.setType(Email.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]);
1102        startActivityForResult(
1103                Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)),
1104                ACTIVITY_REQUEST_PICK_ATTACHMENT);
1105    }
1106
1107    private Attachment loadAttachmentInfo(Uri uri) {
1108        long size = -1;
1109        ContentResolver contentResolver = getContentResolver();
1110
1111        // Load name & size independently, because not all providers support both
1112        final String name = Utility.getContentFileName(this, uri);
1113
1114        Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION,
1115                null, null, null);
1116        if (metadataCursor != null) {
1117            try {
1118                if (metadataCursor.moveToFirst()) {
1119                    size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE);
1120                }
1121            } finally {
1122                metadataCursor.close();
1123            }
1124        }
1125
1126        // When the size is not provided, we need to determine it locally.
1127        if (size < 0) {
1128            // if the URI is a file: URI, ask file system for its size
1129            if ("file".equalsIgnoreCase(uri.getScheme())) {
1130                String path = uri.getPath();
1131                if (path != null) {
1132                    File file = new File(path);
1133                    size = file.length();  // Returns 0 for file not found
1134                }
1135            }
1136
1137            if (size <= 0) {
1138                // The size was not measurable;  This attachment is not safe to use.
1139                // Quick hack to force a relevant error into the UI
1140                // TODO: A proper announcement of the problem
1141                size = Email.MAX_ATTACHMENT_UPLOAD_SIZE + 1;
1142            }
1143        }
1144
1145        String contentType = contentResolver.getType(uri);
1146        if (contentType == null) {
1147            contentType = "";
1148        }
1149
1150        Attachment attachment = new Attachment();
1151        attachment.mFileName = name;
1152        attachment.mContentUri = uri.toString();
1153        attachment.mSize = size;
1154        attachment.mMimeType = contentType;
1155        return attachment;
1156    }
1157
1158    private void addAttachment(Attachment attachment, boolean allowDelete) {
1159        // Before attaching the attachment, make sure it meets any other pre-attach criteria
1160        if (attachment.mSize > Email.MAX_ATTACHMENT_UPLOAD_SIZE) {
1161            Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG)
1162                    .show();
1163            return;
1164        }
1165
1166        View view = getLayoutInflater().inflate(R.layout.message_compose_attachment,
1167                mAttachments, false);
1168        TextView nameView = (TextView)view.findViewById(R.id.attachment_name);
1169        ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete);
1170        TextView sizeView = (TextView)view.findViewById(R.id.attachment_size);
1171
1172        nameView.setText(attachment.mFileName);
1173        sizeView.setText(Utility.formatSize(this, attachment.mSize));
1174        if (allowDelete) {
1175            delete.setOnClickListener(this);
1176            delete.setTag(view);
1177        } else {
1178            delete.setVisibility(View.INVISIBLE);
1179        }
1180        view.setTag(attachment);
1181        mAttachments.addView(view);
1182        updateAttachmentContainer();
1183    }
1184
1185    private void updateAttachmentContainer() {
1186        mAttachmentContainer.setVisibility(mAttachments.getChildCount() == 0
1187                ? View.GONE : View.VISIBLE);
1188    }
1189
1190    private void addAttachment(Uri uri) {
1191        addAttachment(loadAttachmentInfo(uri), true);
1192    }
1193
1194    @Override
1195    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
1196        if (data == null) {
1197            return;
1198        }
1199        addAttachment(data.getData());
1200        setDraftNeedsSaving(true);
1201    }
1202
1203    private boolean includeQuotedText() {
1204        return mIncludeQuotedTextCheckBox.isChecked();
1205    }
1206
1207    public void onClick(View view) {
1208        if (handleCommand(view.getId())) {
1209            return;
1210        }
1211        switch (view.getId()) {
1212            case R.id.attachment_delete:
1213                onDeleteAttachment(view); // Needs a view; can't be a menu item
1214                break;
1215        }
1216    }
1217
1218    private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) {
1219        mIncludeQuotedTextCheckBox.setChecked(include);
1220        mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked()
1221                ? View.VISIBLE : View.GONE);
1222        if (updateNeedsSaving) {
1223            setDraftNeedsSaving(true);
1224        }
1225    }
1226
1227    private void onDeleteAttachment(View delButtonView) {
1228        /*
1229         * The view is the delete button, and we have previously set the tag of
1230         * the delete button to the view that owns it. We don't use parent because the
1231         * view is very complex and could change in the future.
1232         */
1233        View attachmentView = (View) delButtonView.getTag();
1234        Attachment attachment = (Attachment) attachmentView.getTag();
1235        mAttachments.removeView(attachmentView);
1236        updateAttachmentContainer();
1237        if (attachment.isSaved()) {
1238            // The following async task for deleting attachments:
1239            // - can be started multiple times in parallel (to delete multiple attachments).
1240            // - need not be interrupted on activity exit, instead should run to completion.
1241            new AsyncTask<Long, Void, Void>() {
1242                @Override
1243                protected Void doInBackground(Long... attachmentIds) {
1244                    mController.deleteAttachment(attachmentIds[0]);
1245                    return null;
1246                }
1247            }.execute(attachment.mId);
1248        }
1249        setDraftNeedsSaving(true);
1250    }
1251
1252    @Override
1253    public boolean onOptionsItemSelected(MenuItem item) {
1254        if (handleCommand(item.getItemId())) {
1255            return true;
1256        }
1257        return super.onOptionsItemSelected(item);
1258    }
1259
1260    private boolean handleCommand(int viewId) {
1261        switch (viewId) {
1262        case android.R.id.home:
1263            onActionBarHomePressed();
1264            return true;
1265        case R.id.send:
1266            onSend();
1267            return true;
1268        case R.id.save:
1269            onSave();
1270            return true;
1271        case R.id.discard:
1272            onDiscard();
1273            return true;
1274        case R.id.include_quoted_text:
1275            // The checkbox is already toggled at this point.
1276            setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true);
1277            return true;
1278        case R.id.add_cc_bcc:
1279            showCcBccFields();
1280            return true;
1281        case R.id.add_attachment:
1282            onAddAttachment();
1283            return true;
1284        }
1285        return false;
1286    }
1287
1288    private void onActionBarHomePressed() {
1289        finish();
1290        if (isOpenedFromWithinApp()) {
1291            // If opend from within the app, we just close it.
1292        } else {
1293            // Otherwise, need to open the main screen.  Let Welcome do that.
1294            Welcome.actionStart(this);
1295        }
1296    }
1297
1298    @Override
1299    public boolean onCreateOptionsMenu(Menu menu) {
1300        super.onCreateOptionsMenu(menu);
1301        getMenuInflater().inflate(R.menu.message_compose_option, menu);
1302        return true;
1303    }
1304
1305    @Override
1306    public boolean onPrepareOptionsMenu(Menu menu) {
1307        menu.findItem(R.id.save).setEnabled(mSaveEnabled);
1308        return true;
1309    }
1310
1311    /**
1312     * Set a message body and a signature when the Activity is launched.
1313     *
1314     * @param text the message body
1315     */
1316    /* package */ void setInitialComposeText(CharSequence text, String signature) {
1317        int textLength = 0;
1318        if (text != null) {
1319            mMessageContentView.append(text);
1320            textLength = text.length();
1321        }
1322        if (!TextUtils.isEmpty(signature)) {
1323            if (textLength == 0 || text.charAt(textLength - 1) != '\n') {
1324                mMessageContentView.append("\n");
1325            }
1326            mMessageContentView.append(signature);
1327        }
1328    }
1329
1330    /**
1331     * Fill all the widgets with the content found in the Intent Extra, if any.
1332     *
1333     * Note that we don't actually check the intent action  (typically VIEW, SENDTO, or SEND).
1334     * There is enough overlap in the definitions that it makes more sense to simply check for
1335     * all available data and use as much of it as possible.
1336     *
1337     * With one exception:  EXTRA_STREAM is defined as only valid for ACTION_SEND.
1338     *
1339     * @param intent the launch intent
1340     */
1341    /* package */ void initFromIntent(Intent intent) {
1342
1343        // First, add values stored in top-level extras
1344        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
1345        if (extraStrings != null) {
1346            addAddresses(mToView, extraStrings);
1347        }
1348        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
1349        if (extraStrings != null) {
1350            addAddresses(mCcView, extraStrings);
1351        }
1352        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1353        if (extraStrings != null) {
1354            addAddresses(mBccView, extraStrings);
1355        }
1356        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1357        if (extraString != null) {
1358            mSubjectView.setText(extraString);
1359        }
1360
1361        // Next, if we were invoked with a URI, try to interpret it
1362        // We'll take two courses here.  If it's mailto:, there is a specific set of rules
1363        // that define various optional fields.  However, for any other scheme, we'll simply
1364        // take the entire scheme-specific part and interpret it as a possible list of addresses.
1365        final Uri dataUri = intent.getData();
1366        if (dataUri != null) {
1367            if ("mailto".equals(dataUri.getScheme())) {
1368                initializeFromMailTo(dataUri.toString());
1369            } else {
1370                String toText = dataUri.getSchemeSpecificPart();
1371                if (toText != null) {
1372                    addAddresses(mToView, toText.split(","));
1373                }
1374            }
1375        }
1376
1377        // Next, fill in the plaintext (note, this will override mailto:?body=)
1378        CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
1379        if (text != null) {
1380            setInitialComposeText(text, null);
1381        }
1382
1383        // Next, convert EXTRA_STREAM into an attachment
1384        if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) {
1385            String type = intent.getType();
1386            Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
1387            if (stream != null && type != null) {
1388                if (MimeUtility.mimeTypeMatches(type,
1389                        Email.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) {
1390                    addAttachment(stream);
1391                }
1392            }
1393        }
1394
1395        if (Intent.ACTION_SEND_MULTIPLE.equals(mAction)
1396                && intent.hasExtra(Intent.EXTRA_STREAM)) {
1397            ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
1398            if (list != null) {
1399                for (Parcelable parcelable : list) {
1400                    Uri uri = (Uri) parcelable;
1401                    if (uri != null) {
1402                        Attachment attachment = loadAttachmentInfo(uri);
1403                        if (MimeUtility.mimeTypeMatches(attachment.mMimeType,
1404                                Email.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) {
1405                            addAttachment(attachment, true);
1406                        }
1407                    }
1408                }
1409            }
1410        }
1411
1412        // Finally - expose fields that were filled in but are normally hidden, and set focus
1413        showCcBccFieldsIfFilled();
1414        setNewMessageFocus();
1415        setDraftNeedsSaving(false);
1416    }
1417
1418    /**
1419     * When we are launched with an intent that includes a mailto: URI, we can actually
1420     * gather quite a few of our message fields from it.
1421     *
1422     * @mailToString the href (which must start with "mailto:").
1423     */
1424    private void initializeFromMailTo(String mailToString) {
1425
1426        // Chop up everything between mailto: and ? to find recipients
1427        int index = mailToString.indexOf("?");
1428        int length = "mailto".length() + 1;
1429        String to;
1430        try {
1431            // Extract the recipient after mailto:
1432            if (index == -1) {
1433                to = decode(mailToString.substring(length));
1434            } else {
1435                to = decode(mailToString.substring(length, index));
1436            }
1437            addAddresses(mToView, to.split(" ,"));
1438        } catch (UnsupportedEncodingException e) {
1439            Log.e(Email.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'");
1440        }
1441
1442        // Extract the other parameters
1443
1444        // We need to disguise this string as a URI in order to parse it
1445        Uri uri = Uri.parse("foo://" + mailToString);
1446
1447        List<String> cc = uri.getQueryParameters("cc");
1448        addAddresses(mCcView, cc.toArray(new String[cc.size()]));
1449
1450        List<String> otherTo = uri.getQueryParameters("to");
1451        addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()]));
1452
1453        List<String> bcc = uri.getQueryParameters("bcc");
1454        addAddresses(mBccView, bcc.toArray(new String[bcc.size()]));
1455
1456        List<String> subject = uri.getQueryParameters("subject");
1457        if (subject.size() > 0) {
1458            mSubjectView.setText(subject.get(0));
1459        }
1460
1461        List<String> body = uri.getQueryParameters("body");
1462        if (body.size() > 0) {
1463            setInitialComposeText(body.get(0), (mAccount != null) ? mAccount.mSignature : null);
1464        }
1465    }
1466
1467    private String decode(String s) throws UnsupportedEncodingException {
1468        return URLDecoder.decode(s, "UTF-8");
1469    }
1470
1471    // used by processSourceMessage()
1472    private void displayQuotedText(String textBody, String htmlBody) {
1473        /* Use plain-text body if available, otherwise use HTML body.
1474         * This matches the desired behavior for IMAP/POP where we only send plain-text,
1475         * and for EAS which sends HTML and has no plain-text body.
1476         */
1477        boolean plainTextFlag = textBody != null;
1478        String text = plainTextFlag ? textBody : htmlBody;
1479        if (text != null) {
1480            text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text;
1481            // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML
1482            //    EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount,
1483            //                                     text, message, 0);
1484            mQuotedTextBar.setVisibility(View.VISIBLE);
1485            if (mQuotedText != null) {
1486                mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null);
1487            }
1488        }
1489    }
1490
1491    /**
1492     * Given a packed address String, the address of our sending account, a view, and a list of
1493     * addressees already added to other addressing views, adds unique addressees that don't
1494     * match our address to the passed in view
1495     */
1496    private boolean safeAddAddresses(String addrs, String ourAddress,
1497            MultiAutoCompleteTextView view, ArrayList<Address> addrList) {
1498        boolean added = false;
1499        for (Address address : Address.unpack(addrs)) {
1500            // Don't send to ourselves or already-included addresses
1501            if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) {
1502                addrList.add(address);
1503                addAddress(view, address.toString());
1504                added = true;
1505            }
1506        }
1507        return added;
1508    }
1509
1510    /**
1511     * Set up the to and cc views properly for the "reply" and "replyAll" cases.  What's important
1512     * is that we not 1) send to ourselves, and 2) duplicate addressees.
1513     * @param message the message we're replying to
1514     * @param account the account we're sending from
1515     * @param toView the "To" view
1516     * @param ccView the "Cc" view
1517     * @param replyAll whether this is a replyAll (vs a reply)
1518     */
1519    /*package*/ void setupAddressViews(Message message, Account account,
1520            MultiAutoCompleteTextView toView, MultiAutoCompleteTextView ccView, boolean replyAll) {
1521        /*
1522         * If a reply-to was included with the message use that, otherwise use the from
1523         * or sender address.
1524         */
1525        Address[] replyToAddresses = Address.unpack(message.mReplyTo);
1526        if (replyToAddresses.length == 0) {
1527            replyToAddresses = Address.unpack(message.mFrom);
1528        }
1529        addAddresses(mToView, replyToAddresses);
1530
1531        if (replyAll) {
1532            // Keep a running list of addresses we're sending to
1533            ArrayList<Address> allAddresses = new ArrayList<Address>();
1534            String ourAddress = account.mEmailAddress;
1535
1536            for (Address address: replyToAddresses) {
1537                allAddresses.add(address);
1538            }
1539
1540            safeAddAddresses(message.mTo, ourAddress, mToView, allAddresses);
1541            safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses);
1542        }
1543        showCcBccFieldsIfFilled();
1544    }
1545
1546    void processSourceMessageGuarded(Message message, Account account) {
1547        // Make sure we only do this once (otherwise we'll duplicate addresses!)
1548        if (!mSourceMessageProcessed) {
1549            processSourceMessage(message, account);
1550            mSourceMessageProcessed = true;
1551        }
1552
1553        /* The quoted text is displayed in a WebView whose content is not automatically
1554         * saved/restored by onRestoreInstanceState(), so we need to *always* restore it here,
1555         * regardless of the value of mSourceMessageProcessed.
1556         * This only concerns EDIT_DRAFT because after a configuration change we're always
1557         * in EDIT_DRAFT.
1558         */
1559        if (ACTION_EDIT_DRAFT.equals(mAction)) {
1560            displayQuotedText(message.mTextReply, message.mHtmlReply);
1561            setIncludeQuotedText((mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0,
1562                    false);
1563        }
1564    }
1565
1566    /**
1567     * Pull out the parts of the now loaded source message and apply them to the new message
1568     * depending on the type of message being composed.
1569     * @param message
1570     */
1571    /* package */
1572    void processSourceMessage(Message message, Account account) {
1573        setDraftNeedsSaving(true);
1574        final String subject = message.mSubject;
1575        if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) {
1576            setupAddressViews(message, account, mToView, mCcView,
1577                ACTION_REPLY_ALL.equals(mAction));
1578            if (subject != null && !subject.toLowerCase().startsWith("re:")) {
1579                mSubjectView.setText("Re: " + subject);
1580            } else {
1581                mSubjectView.setText(subject);
1582            }
1583            displayQuotedText(message.mText, message.mHtml);
1584            setIncludeQuotedText(true, false);
1585            setInitialComposeText(null, (account != null) ? account.mSignature : null);
1586        } else if (ACTION_FORWARD.equals(mAction)) {
1587            mSubjectView.setText(subject != null && !subject.toLowerCase().startsWith("fwd:") ?
1588                    "Fwd: " + subject : subject);
1589            displayQuotedText(message.mText, message.mHtml);
1590            setIncludeQuotedText(true, false);
1591            setInitialComposeText(null, (account != null) ? account.mSignature : null);
1592        } else if (ACTION_EDIT_DRAFT.equals(mAction)) {
1593            mSubjectView.setText(subject);
1594            addAddresses(mToView, Address.unpack(message.mTo));
1595            Address[] cc = Address.unpack(message.mCc);
1596            if (cc.length > 0) {
1597                addAddresses(mCcView, cc);
1598            }
1599            Address[] bcc = Address.unpack(message.mBcc);
1600            if (bcc.length > 0) {
1601                addAddresses(mBccView, bcc);
1602            }
1603
1604            mMessageContentView.setText(message.mText);
1605            // TODO: re-enable loadAttachments
1606            // loadAttachments(message, 0);
1607            setDraftNeedsSaving(false);
1608        }
1609        showCcBccFieldsIfFilled();
1610        setNewMessageFocus();
1611    }
1612
1613    /**
1614     * Set a cursor to the end of a body except a signature
1615     */
1616    /* package */ void setMessageContentSelection(String signature) {
1617        // when selecting the message content, explicitly move IP to the end of the message,
1618        // so you can quickly resume typing into a draft
1619        int selection = mMessageContentView.length();
1620        if (!TextUtils.isEmpty(signature)) {
1621            int signatureLength = signature.length();
1622            int estimatedSelection = selection - signatureLength;
1623            if (estimatedSelection >= 0) {
1624                CharSequence text = mMessageContentView.getText();
1625                int i = 0;
1626                while (i < signatureLength
1627                       && text.charAt(estimatedSelection + i) == signature.charAt(i)) {
1628                    ++i;
1629                }
1630                if (i == signatureLength) {
1631                    selection = estimatedSelection;
1632                    while (selection > 0 && text.charAt(selection - 1) == '\n') {
1633                        --selection;
1634                    }
1635                }
1636            }
1637        }
1638        mMessageContentView.setSelection(selection, selection);
1639    }
1640
1641    /**
1642     * In order to accelerate typing, position the cursor in the first empty field,
1643     * or at the end of the body composition field if none are empty.  Typically, this will
1644     * play out as follows:
1645     *   Reply / Reply All - put cursor in the empty message body
1646     *   Forward - put cursor in the empty To field
1647     *   Edit Draft - put cursor in whatever field still needs entry
1648     */
1649    private void setNewMessageFocus() {
1650        if (mToView.length() == 0) {
1651            mToView.requestFocus();
1652        } else if (mSubjectView.length() == 0) {
1653            mSubjectView.requestFocus();
1654        } else {
1655            mMessageContentView.requestFocus();
1656            setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null);
1657        }
1658    }
1659}
1660