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