MessageCompose.java revision 9d0bb2e3a07144517e6449aa9fcdc6c79ea99be5
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 android.app.ActionBar;
20import android.app.ActionBar.Tab;
21import android.app.ActionBar.TabListener;
22import android.app.Activity;
23import android.app.ActivityManager;
24import android.app.FragmentTransaction;
25import android.content.ActivityNotFoundException;
26import android.content.ContentResolver;
27import android.content.ContentUris;
28import android.content.ContentValues;
29import android.content.Context;
30import android.content.Intent;
31import android.content.pm.ActivityInfo;
32import android.database.Cursor;
33import android.net.Uri;
34import android.os.Bundle;
35import android.os.Parcelable;
36import android.provider.OpenableColumns;
37import android.text.InputFilter;
38import android.text.SpannableStringBuilder;
39import android.text.Spanned;
40import android.text.TextUtils;
41import android.text.TextWatcher;
42import android.text.util.Rfc822Tokenizer;
43import android.util.Log;
44import android.view.Menu;
45import android.view.MenuItem;
46import android.view.View;
47import android.view.View.OnClickListener;
48import android.view.View.OnFocusChangeListener;
49import android.view.ViewGroup;
50import android.webkit.WebView;
51import android.widget.AdapterView;
52import android.widget.AdapterView.OnItemSelectedListener;
53import android.widget.ArrayAdapter;
54import android.widget.CheckBox;
55import android.widget.EditText;
56import android.widget.ImageButton;
57import android.widget.MultiAutoCompleteTextView;
58import android.widget.Spinner;
59import android.widget.TextView;
60import android.widget.Toast;
61
62import com.android.common.contacts.DataUsageStatUpdater;
63import com.android.email.Controller;
64import com.android.email.Email;
65import com.android.email.EmailAddressAdapter;
66import com.android.email.EmailAddressValidator;
67import com.android.email.R;
68import com.android.email.RecipientAdapter;
69import com.android.email.mail.internet.EmailHtmlUtil;
70import com.android.emailcommon.Logging;
71import com.android.emailcommon.internet.MimeUtility;
72import com.android.emailcommon.mail.Address;
73import com.android.emailcommon.provider.Account;
74import com.android.emailcommon.provider.EmailContent.Attachment;
75import com.android.emailcommon.provider.EmailContent.Body;
76import com.android.emailcommon.provider.EmailContent.BodyColumns;
77import com.android.emailcommon.provider.EmailContent.Message;
78import com.android.emailcommon.provider.EmailContent.MessageColumns;
79import com.android.emailcommon.provider.Mailbox;
80import com.android.emailcommon.utility.AttachmentUtilities;
81import com.android.emailcommon.utility.EmailAsyncTask;
82import com.android.emailcommon.utility.Utility;
83import com.android.ex.chips.AccountSpecifier;
84import com.android.ex.chips.ChipsUtil;
85import com.android.ex.chips.RecipientEditTextView;
86import com.google.common.annotations.VisibleForTesting;
87import com.google.common.base.Objects;
88import com.google.common.collect.Lists;
89
90import java.io.File;
91import java.io.UnsupportedEncodingException;
92import java.net.URLDecoder;
93import java.util.ArrayList;
94import java.util.HashMap;
95import java.util.HashSet;
96import java.util.List;
97import java.util.concurrent.ConcurrentHashMap;
98import java.util.concurrent.ExecutionException;
99
100
101/**
102 * Activity to compose a message.
103 *
104 * TODO Revive shortcuts command for removed menu options.
105 * C: add cc/bcc
106 * N: add attachment
107 */
108public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener,
109        DeleteMessageConfirmationDialog.Callback, InsertQuickResponseDialog.Callback {
110
111    private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
112    private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
113    private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
114    private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
115
116    private static final String EXTRA_ACCOUNT_ID = "account_id";
117    private static final String EXTRA_MESSAGE_ID = "message_id";
118    /** If the intent is sent from the email app itself, it should have this boolean extra. */
119    private static final String EXTRA_FROM_WITHIN_APP = "from_within_app";
120
121    private static final String STATE_KEY_CC_SHOWN =
122        "com.android.email.activity.MessageCompose.ccShown";
123    private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
124        "com.android.email.activity.MessageCompose.quotedTextShown";
125    private static final String STATE_KEY_DRAFT_ID =
126        "com.android.email.activity.MessageCompose.draftId";
127    private static final String STATE_KEY_LAST_SAVE_TASK_ID =
128        "com.android.email.activity.MessageCompose.requestId";
129    private static final String STATE_KEY_ACTION =
130        "com.android.email.activity.MessageCompose.action";
131
132    private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
133
134    private static final String[] ATTACHMENT_META_SIZE_PROJECTION = {
135        OpenableColumns.SIZE
136    };
137    private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0;
138
139    /**
140     * A registry of the active tasks used to save messages.
141     */
142    private static final ConcurrentHashMap<Long, SendOrSaveMessageTask> sActiveSaveTasks =
143            new ConcurrentHashMap<Long, SendOrSaveMessageTask>();
144
145    private static long sNextSaveTaskId = 1;
146
147    /**
148     * The ID of the latest save or send task requested by this Activity.
149     */
150    private long mLastSaveTaskId = -1;
151
152    private Account mAccount;
153
154    /**
155     * The contents of the current message being edited. This is not always in sync with what's
156     * on the UI. {@link #updateMessage(Message, Account, boolean, boolean)} must be called to sync
157     * the UI values into this object.
158     */
159    private Message mDraft = new Message();
160
161    /**
162     * A collection of attachments the user is currently wanting to attach to this message.
163     */
164    private final ArrayList<Attachment> mAttachments = new ArrayList<Attachment>();
165
166    /**
167     * The source message for a reply, reply all, or forward. This is asynchronously loaded.
168     */
169    private Message mSource;
170
171    /**
172     * The attachments associated with the source attachments. Usually included in a forward.
173     */
174    private ArrayList<Attachment> mSourceAttachments = new ArrayList<Attachment>();
175
176    /**
177     * The action being handled by this activity. This is initially populated from the
178     * {@link Intent}, but can switch between reply/reply all/forward where appropriate.
179     * This value is nullable (a null value indicating a regular "compose").
180     */
181    private String mAction;
182
183    private TextView mFromView;
184    private MultiAutoCompleteTextView mToView;
185    private MultiAutoCompleteTextView mCcView;
186    private MultiAutoCompleteTextView mBccView;
187    private View mCcBccContainer;
188    private EditText mSubjectView;
189    private EditText mMessageContentView;
190    private View mAttachmentContainer;
191    private ViewGroup mAttachmentContentView;
192    private View mQuotedTextBar;
193    private CheckBox mIncludeQuotedTextCheckBox;
194    private WebView mQuotedText;
195    private Spinner mActionSpinner;
196    private ActionSpinnerAdapter mActionSpinnerAdapter;
197
198    private Controller mController;
199    private boolean mDraftNeedsSaving;
200    private boolean mMessageLoaded;
201    private boolean mInitiallyEmpty;
202    private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker();
203
204    private AccountSpecifier mAddressAdapterTo;
205    private AccountSpecifier mAddressAdapterCc;
206    private AccountSpecifier mAddressAdapterBcc;
207
208    /**
209     * Watches the to, cc, bcc, subject, and message body fields.
210     */
211    private final TextWatcher mWatcher = new TextWatcher() {
212        @Override
213        public void beforeTextChanged(CharSequence s, int start,
214                                      int before, int after) { }
215
216        @Override
217        public void onTextChanged(CharSequence s, int start,
218                                      int before, int count) {
219            setMessageChanged(true);
220        }
221
222        @Override
223        public void afterTextChanged(android.text.Editable s) { }
224    };
225
226    private static Intent getBaseIntent(Context context) {
227        Intent i = new Intent(context, MessageCompose.class);
228        i.putExtra(EXTRA_FROM_WITHIN_APP, true);
229        return i;
230    }
231
232    /**
233     * Create an {@link Intent} that can start the message compose activity. If accountId -1,
234     * the default account will be used; otherwise, the specified account is used.
235     */
236    public static Intent getMessageComposeIntent(Context context, long accountId) {
237        Intent i = getBaseIntent(context);
238        i.putExtra(EXTRA_ACCOUNT_ID, accountId);
239        return i;
240    }
241
242    /**
243     * Compose a new message using the given account. If account is -1 the default account
244     * will be used.
245     * @param context
246     * @param accountId
247     */
248    public static void actionCompose(Context context, long accountId) {
249       try {
250           Intent i = getMessageComposeIntent(context, accountId);
251           context.startActivity(i);
252       } catch (ActivityNotFoundException anfe) {
253           // Swallow it - this is usually a race condition, especially under automated test.
254           // (The message composer might have been disabled)
255           Email.log(anfe.toString());
256       }
257    }
258
259    /**
260     * Compose a new message using a uri (mailto:) and a given account.  If account is -1 the
261     * default account will be used.
262     * @param context
263     * @param uriString
264     * @param accountId
265     * @return true if startActivity() succeeded
266     */
267    public static boolean actionCompose(Context context, String uriString, long accountId) {
268        try {
269            Intent i = getMessageComposeIntent(context, accountId);
270            i.setAction(Intent.ACTION_SEND);
271            i.setData(Uri.parse(uriString));
272            context.startActivity(i);
273            return true;
274        } catch (ActivityNotFoundException anfe) {
275            // Swallow it - this is usually a race condition, especially under automated test.
276            // (The message composer might have been disabled)
277            Email.log(anfe.toString());
278            return false;
279        }
280    }
281
282    /**
283     * Compose a new message as a reply to the given message. If replyAll is true the function
284     * is reply all instead of simply reply.
285     * @param context
286     * @param messageId
287     * @param replyAll
288     */
289    public static void actionReply(Context context, long messageId, boolean replyAll) {
290        startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId);
291    }
292
293    /**
294     * Compose a new message as a forward of the given message.
295     * @param context
296     * @param messageId
297     */
298    public static void actionForward(Context context, long messageId) {
299        startActivityWithMessage(context, ACTION_FORWARD, messageId);
300    }
301
302    /**
303     * Continue composition of the given message. This action modifies the way this Activity
304     * handles certain actions.
305     * Save will attempt to replace the message in the given folder with the updated version.
306     * Discard will delete the message from the given folder.
307     * @param context
308     * @param messageId the message id.
309     */
310    public static void actionEditDraft(Context context, long messageId) {
311        startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId);
312    }
313
314    /**
315     * Starts a compose activity with a message as a reference message (e.g. for reply or forward).
316     */
317    private static void startActivityWithMessage(Context context, String action, long messageId) {
318        Intent i = getBaseIntent(context);
319        i.putExtra(EXTRA_MESSAGE_ID, messageId);
320        i.setAction(action);
321        context.startActivity(i);
322    }
323
324    private void setAccount(Intent intent) {
325        long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1);
326        if (accountId == Account.NO_ACCOUNT) {
327            accountId = Account.getDefaultAccountId(this);
328        }
329        if (accountId == Account.NO_ACCOUNT) {
330            // There are no accounts set up. This should not have happened. Prompt the
331            // user to set up an account as an acceptable bailout.
332            Welcome.actionStart(this);
333            finish();
334        } else {
335            setAccount(Account.restoreAccountWithId(this, accountId));
336        }
337    }
338
339    private void setAccount(Account account) {
340        if (account == null) {
341            throw new IllegalArgumentException();
342        }
343        mAccount = account;
344        mAddressAdapterTo
345                .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
346        mAddressAdapterCc
347                .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
348        mAddressAdapterBcc
349                .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
350
351        if (mFromView != null) {
352            // Some configurations don't show the from field.
353            mFromView.setText(account.mEmailAddress);
354        }
355    }
356
357    @Override
358    public void onCreate(Bundle savedInstanceState) {
359        super.onCreate(savedInstanceState);
360        ActivityHelper.debugSetWindowFlags(this);
361        setContentView(R.layout.message_compose);
362
363        mController = Controller.getInstance(getApplication());
364        initViews();
365
366        // Show the back arrow on the action bar.
367        getActionBar().setDisplayOptions(
368                ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
369
370        if (savedInstanceState != null) {
371            long draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, Message.NOT_SAVED);
372            long existingSaveTaskId = savedInstanceState.getLong(STATE_KEY_LAST_SAVE_TASK_ID, -1);
373            setAction(savedInstanceState.getString(STATE_KEY_ACTION));
374            SendOrSaveMessageTask existingSaveTask = sActiveSaveTasks.get(existingSaveTaskId);
375
376            if ((draftId != Message.NOT_SAVED) || (existingSaveTask != null)) {
377                // Restoring state and there was an existing message saved or in the process of
378                // being saved.
379                resumeDraft(draftId, existingSaveTask, false /* don't restore views */);
380            } else {
381                // Restoring state but there was nothing saved - probably means the user rotated
382                // the device immediately - just use the Intent.
383                resolveIntent(getIntent());
384            }
385        } else {
386            Intent intent = getIntent();
387            setAction(intent.getAction());
388            resolveIntent(intent);
389        }
390    }
391
392    private void resolveIntent(Intent intent) {
393        if (Intent.ACTION_VIEW.equals(mAction)
394                || Intent.ACTION_SENDTO.equals(mAction)
395                || Intent.ACTION_SEND.equals(mAction)
396                || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) {
397            initFromIntent(intent);
398            setMessageChanged(true);
399            setMessageLoaded(true);
400        } else if (ACTION_REPLY.equals(mAction)
401                || ACTION_REPLY_ALL.equals(mAction)
402                || ACTION_FORWARD.equals(mAction)) {
403            long sourceMessageId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED);
404            loadSourceMessage(sourceMessageId, true);
405
406        } else if (ACTION_EDIT_DRAFT.equals(mAction)) {
407            // Assert getIntent.hasExtra(EXTRA_MESSAGE_ID)
408            long draftId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED);
409            resumeDraft(draftId, null, true /* restore views */);
410
411        } else {
412            // Normal compose flow for a new message.
413            setAccount(intent);
414            setInitialComposeText(null, getAccountSignature(mAccount));
415            setMessageLoaded(true);
416        }
417    }
418
419    @Override
420    protected void onRestoreInstanceState(Bundle savedInstanceState) {
421        // Temporarily disable onTextChanged listeners while restoring the fields
422        removeListeners();
423        super.onRestoreInstanceState(savedInstanceState);
424        if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) {
425            showCcBccFields();
426        }
427        mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
428                ? View.VISIBLE : View.GONE);
429        mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
430                ? View.VISIBLE : View.GONE);
431        addListeners();
432    }
433
434    // needed for unit tests
435    @Override
436    public void setIntent(Intent intent) {
437        super.setIntent(intent);
438        setAction(intent.getAction());
439    }
440
441    @Override
442    public void onResume() {
443        super.onResume();
444
445        // Exit immediately if the accounts list has changed (e.g. externally deleted)
446        if (Email.getNotifyUiAccountsChanged()) {
447            Welcome.actionStart(this);
448            finish();
449            return;
450        }
451    }
452
453    @Override
454    public void onPause() {
455        super.onPause();
456        saveIfNeeded();
457    }
458
459    /**
460     * We override onDestroy to make sure that the WebView gets explicitly destroyed.
461     * Otherwise it can leak native references.
462     */
463    @Override
464    public void onDestroy() {
465        super.onDestroy();
466        mQuotedText.destroy();
467        mQuotedText = null;
468
469        mTaskTracker.cancellAllInterrupt();
470
471        if (mAddressAdapterTo != null && mAddressAdapterTo instanceof EmailAddressAdapter) {
472            ((EmailAddressAdapter) mAddressAdapterTo).close();
473        }
474        if (mAddressAdapterCc != null && mAddressAdapterCc instanceof EmailAddressAdapter) {
475            ((EmailAddressAdapter) mAddressAdapterCc).close();
476        }
477        if (mAddressAdapterBcc != null && mAddressAdapterBcc instanceof EmailAddressAdapter) {
478            ((EmailAddressAdapter) mAddressAdapterBcc).close();
479        }
480    }
481
482    /**
483     * The framework handles most of the fields, but we need to handle stuff that we
484     * dynamically show and hide:
485     * Cc field,
486     * Bcc field,
487     * Quoted text,
488     */
489    @Override
490    protected void onSaveInstanceState(Bundle outState) {
491        super.onSaveInstanceState(outState);
492
493        long draftId = mDraft.mId;
494        if (draftId != Message.NOT_SAVED) {
495            outState.putLong(STATE_KEY_DRAFT_ID, draftId);
496        }
497        outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE);
498        outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
499                mQuotedTextBar.getVisibility() == View.VISIBLE);
500        outState.putString(STATE_KEY_ACTION, mAction);
501
502        // If there are any outstanding save requests, ensure that it's noted in case it hasn't
503        // finished by the time the activity is restored.
504        outState.putLong(STATE_KEY_LAST_SAVE_TASK_ID, mLastSaveTaskId);
505    }
506
507    /**
508     * Whether or not the current message being edited has a source message (i.e. is a reply,
509     * or forward) that is loaded.
510     */
511    private boolean hasSourceMessage() {
512        return mSource != null;
513    }
514
515    /**
516     * @return true if the activity was opened by the email app itself.
517     */
518    private boolean isOpenedFromWithinApp() {
519        Intent i = getIntent();
520        return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false));
521    }
522
523    /**
524     * Sets message as loaded and then initializes the TextWatchers.
525     * @param isLoaded - value to which to set mMessageLoaded
526     */
527    private void setMessageLoaded(boolean isLoaded) {
528        if (mMessageLoaded != isLoaded) {
529            mMessageLoaded = isLoaded;
530            addListeners();
531            mInitiallyEmpty = areViewsEmpty();
532        }
533    }
534
535    private void setMessageChanged(boolean messageChanged) {
536        boolean needsSaving = messageChanged && !(mInitiallyEmpty && areViewsEmpty());
537
538        if (mDraftNeedsSaving != needsSaving) {
539            mDraftNeedsSaving = needsSaving;
540            invalidateOptionsMenu();
541        }
542    }
543
544    /**
545     * @return whether or not all text fields are empty (i.e. the entire compose message is empty)
546     */
547    private boolean areViewsEmpty() {
548        return (mToView.length() == 0)
549                && (mCcView.length() == 0)
550                && (mBccView.length() == 0)
551                && (mSubjectView.length() == 0)
552                && isBodyEmpty()
553                && mAttachments.isEmpty();
554    }
555
556    private boolean isBodyEmpty() {
557        return (mMessageContentView.length() == 0)
558                || mMessageContentView.getText()
559                        .toString().equals("\n" + getAccountSignature(mAccount));
560    }
561
562    public void setFocusShifter(int fromViewId, final int targetViewId) {
563        View label = findViewById(fromViewId); // xlarge only
564        if (label != null) {
565            final View target = UiUtilities.getView(this, targetViewId);
566            label.setOnClickListener(new View.OnClickListener() {
567                @Override
568                public void onClick(View v) {
569                    target.requestFocus();
570                }
571            });
572        }
573    }
574
575    /**
576     * An {@link InputFilter} that implements special address cleanup rules.
577     * The first space key entry following an "@" symbol that is followed by any combination
578     * of letters and symbols, including one+ dots and zero commas, should insert an extra
579     * comma (followed by the space).
580     */
581    @VisibleForTesting
582    static final InputFilter RECIPIENT_FILTER = new InputFilter() {
583        @Override
584        public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
585                int dstart, int dend) {
586
587            // Quick check - did they enter a single space?
588            if (end-start != 1 || source.charAt(start) != ' ') {
589                return null;
590            }
591
592            // determine if the characters before the new space fit the pattern
593            // follow backwards and see if we find a comma, dot, or @
594            int scanBack = dstart;
595            boolean dotFound = false;
596            while (scanBack > 0) {
597                char c = dest.charAt(--scanBack);
598                switch (c) {
599                    case '.':
600                        dotFound = true;    // one or more dots are req'd
601                        break;
602                    case ',':
603                        return null;
604                    case '@':
605                        if (!dotFound) {
606                            return null;
607                        }
608
609                        // we have found a comma-insert case.  now just do it
610                        // in the least expensive way we can.
611                        if (source instanceof Spanned) {
612                            SpannableStringBuilder sb = new SpannableStringBuilder(",");
613                            sb.append(source);
614                            return sb;
615                        } else {
616                            return ", ";
617                        }
618                    default:
619                        // just keep going
620                }
621            }
622
623            // no termination cases were found, so don't edit the input
624            return null;
625        }
626    };
627
628    private void initViews() {
629        mToView = UiUtilities.getView(this, R.id.to);
630        mCcView = UiUtilities.getView(this, R.id.cc);
631        mBccView = UiUtilities.getView(this, R.id.bcc);
632        // add hints only when no labels exist
633        if (UiUtilities.getViewOrNull(this, R.id.to_label) == null) {
634            mToView.setHint(R.string.message_compose_to_hint);
635            mCcView.setHint(R.string.message_compose_cc_hint);
636            mBccView.setHint(R.string.message_compose_bcc_hint);
637        }
638
639        mCcBccContainer = UiUtilities.getView(this, R.id.cc_bcc_container);
640        mSubjectView = UiUtilities.getView(this, R.id.subject);
641        mMessageContentView = UiUtilities.getView(this, R.id.message_content);
642        mAttachmentContentView = UiUtilities.getView(this, R.id.attachments);
643        mAttachmentContainer = UiUtilities.getView(this, R.id.attachment_container);
644        mQuotedTextBar = UiUtilities.getView(this, R.id.quoted_text_bar);
645        mIncludeQuotedTextCheckBox = UiUtilities.getView(this, R.id.include_quoted_text);
646        mQuotedText = UiUtilities.getView(this, R.id.quoted_text);
647
648        InputFilter[] recipientFilters = new InputFilter[] { RECIPIENT_FILTER };
649
650        // NOTE: assumes no other filters are set
651        mToView.setFilters(recipientFilters);
652        mCcView.setFilters(recipientFilters);
653        mBccView.setFilters(recipientFilters);
654
655        /*
656         * We set this to invisible by default. Other methods will turn it back on if it's
657         * needed.
658         */
659        mQuotedTextBar.setVisibility(View.GONE);
660        setIncludeQuotedText(false, false);
661
662        mIncludeQuotedTextCheckBox.setOnClickListener(this);
663
664        EmailAddressValidator addressValidator = new EmailAddressValidator();
665
666        setupAddressAdapters();
667        mToView.setTokenizer(new Rfc822Tokenizer());
668        mToView.setValidator(addressValidator);
669
670        mCcView.setTokenizer(new Rfc822Tokenizer());
671        mCcView.setValidator(addressValidator);
672
673        mBccView.setTokenizer(new Rfc822Tokenizer());
674        mBccView.setValidator(addressValidator);
675
676        final View addCcBccView = UiUtilities.getViewOrNull(this, R.id.add_cc_bcc);
677        if (addCcBccView != null) {
678            // Tablet view.
679            addCcBccView.setOnClickListener(this);
680        }
681
682        final View addAttachmentView = UiUtilities.getViewOrNull(this, R.id.add_attachment);
683        if (addAttachmentView != null) {
684            // Tablet view.
685            addAttachmentView.setOnClickListener(this);
686        }
687
688        setFocusShifter(R.id.to_label, R.id.to);
689        setFocusShifter(R.id.cc_label, R.id.cc);
690        setFocusShifter(R.id.bcc_label, R.id.bcc);
691        setFocusShifter(R.id.subject_label, R.id.subject);
692        setFocusShifter(R.id.tap_trap, R.id.message_content);
693
694        mMessageContentView.setOnFocusChangeListener(this);
695
696        mFromView = UiUtilities.getViewOrNull(this, R.id.from);
697        mActionSpinner = UiUtilities.getViewOrNull(this, R.id.action_spinner);
698
699        updateAttachmentContainer();
700        mToView.requestFocus();
701    }
702
703    /**
704     * Initializes listeners. Should only be called once initializing of views is complete to
705     * avoid unnecessary draft saving.
706     */
707    private void addListeners() {
708        mToView.addTextChangedListener(mWatcher);
709        mCcView.addTextChangedListener(mWatcher);
710        mBccView.addTextChangedListener(mWatcher);
711        mSubjectView.addTextChangedListener(mWatcher);
712        mMessageContentView.addTextChangedListener(mWatcher);
713    }
714
715    /**
716     * Removes listeners from the user-editable fields. Can be used to temporarily disable them
717     * while resetting fields (such as when changing from reply to reply all) to avoid
718     * unnecessary saving.
719     */
720    private void removeListeners() {
721        mToView.removeTextChangedListener(mWatcher);
722        mCcView.removeTextChangedListener(mWatcher);
723        mBccView.removeTextChangedListener(mWatcher);
724        mSubjectView.removeTextChangedListener(mWatcher);
725        mMessageContentView.removeTextChangedListener(mWatcher);
726    }
727
728    /**
729     * Set up address auto-completion adapters.
730     */
731    private void setupAddressAdapters() {
732        boolean supportsChips = ChipsUtil.supportsChipsUi();
733
734        if (supportsChips && mToView instanceof RecipientEditTextView) {
735            mAddressAdapterTo = new RecipientAdapter(this, (RecipientEditTextView) mToView);
736            mToView.setAdapter((RecipientAdapter) mAddressAdapterTo);
737        } else {
738            mAddressAdapterTo = new EmailAddressAdapter(this);
739            mToView.setAdapter((EmailAddressAdapter) mAddressAdapterTo);
740        }
741        if (supportsChips && mCcView instanceof RecipientEditTextView) {
742            mAddressAdapterCc = new RecipientAdapter(this, (RecipientEditTextView) mCcView);
743            mCcView.setAdapter((RecipientAdapter) mAddressAdapterCc);
744        } else {
745            mAddressAdapterCc = new EmailAddressAdapter(this);
746            mCcView.setAdapter((EmailAddressAdapter) mAddressAdapterCc);
747        }
748        if (supportsChips && mBccView instanceof RecipientEditTextView) {
749            mAddressAdapterBcc = new RecipientAdapter(this, (RecipientEditTextView) mBccView);
750            mBccView.setAdapter((RecipientAdapter) mAddressAdapterBcc);
751        } else {
752            mAddressAdapterBcc = new EmailAddressAdapter(this);
753            mBccView.setAdapter((EmailAddressAdapter) mAddressAdapterBcc);
754        }
755    }
756
757    /**
758     * Asynchronously loads a draft message for editing.
759     * This may or may not restore the view contents, depending on whether or not callers want,
760     * since in the case of screen rotation, those are restored automatically.
761     */
762    private void resumeDraft(
763            long draftId,
764            SendOrSaveMessageTask existingSaveTask,
765            final boolean restoreViews) {
766        // Note - this can be Message.NOT_SAVED if there is an existing save task in progress
767        // for the draft we need to load.
768        mDraft.mId = draftId;
769
770        new LoadMessageTask(draftId, existingSaveTask, new OnMessageLoadHandler() {
771            @Override
772            public void onMessageLoaded(Message message, Body body) {
773                message.mHtml = body.mHtmlContent;
774                message.mText = body.mTextContent;
775                message.mHtmlReply = body.mHtmlReply;
776                message.mTextReply = body.mTextReply;
777                message.mIntroText = body.mIntroText;
778                message.mSourceKey = body.mSourceKey;
779
780                mDraft = message;
781                processDraftMessage(message, restoreViews);
782
783                // Load attachments related to the draft.
784                loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() {
785                    @Override
786                    public void onAttachmentLoaded(Attachment[] attachments) {
787                        for (Attachment attachment: attachments) {
788                            addAttachment(attachment);
789                        }
790                    }
791                });
792
793                // If we're resuming an edit of a reply, reply-all, or forward, re-load the
794                // source message if available so that we get more information.
795                if (message.mSourceKey != Message.NOT_SAVED) {
796                    loadSourceMessage(message.mSourceKey, false /* restore views */);
797                }
798            }
799
800            @Override
801            public void onLoadFailed() {
802                Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
803                finish();
804            }
805        }).executeParallel((Void[]) null);
806    }
807
808    @VisibleForTesting
809    void processDraftMessage(Message message, boolean restoreViews) {
810        if (restoreViews) {
811            mSubjectView.setText(message.mSubject);
812            addAddresses(mToView, Address.unpack(message.mTo));
813            Address[] cc = Address.unpack(message.mCc);
814            if (cc.length > 0) {
815                addAddresses(mCcView, cc);
816            }
817            Address[] bcc = Address.unpack(message.mBcc);
818            if (bcc.length > 0) {
819                addAddresses(mBccView, bcc);
820            }
821
822            mMessageContentView.setText(message.mText);
823
824            showCcBccFieldsIfFilled();
825            setNewMessageFocus();
826        }
827        setMessageChanged(false);
828
829        // The quoted text must always be restored.
830        displayQuotedText(message.mTextReply, message.mHtmlReply);
831        setIncludeQuotedText(
832                (mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0, false);
833    }
834
835    /**
836     * Asynchronously loads a source message (to be replied or forwarded in this current view),
837     * populating text fields and quoted text fields when the load finishes, if requested.
838     */
839    private void loadSourceMessage(long sourceMessageId, final boolean restoreViews) {
840        new LoadMessageTask(sourceMessageId, null, new OnMessageLoadHandler() {
841            @Override
842            public void onMessageLoaded(Message message, Body body) {
843                message.mHtml = body.mHtmlContent;
844                message.mText = body.mTextContent;
845                message.mHtmlReply = null;
846                message.mTextReply = null;
847                message.mIntroText = null;
848                mSource = message;
849                mSourceAttachments = new ArrayList<Attachment>();
850
851                if (restoreViews) {
852                    processSourceMessage(mSource, mAccount);
853                    setInitialComposeText(null, getAccountSignature(mAccount));
854                }
855
856                loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() {
857                    @Override
858                    public void onAttachmentLoaded(Attachment[] attachments) {
859                        final boolean supportsSmartForward =
860                            (mAccount.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0;
861
862                        // Process the attachments to have the appropriate smart forward flags.
863                        for (Attachment attachment : attachments) {
864                            if (supportsSmartForward) {
865                                attachment.mFlags |= Attachment.FLAG_SMART_FORWARD;
866                            }
867                            mSourceAttachments.add(attachment);
868                        }
869                        if (isForward() && restoreViews) {
870                            if (processSourceMessageAttachments(
871                                    mAttachments, mSourceAttachments, true)) {
872                                updateAttachmentUi();
873                                setMessageChanged(true);
874                            }
875                        }
876                    }
877                });
878
879                if (mAction.equals(ACTION_EDIT_DRAFT)) {
880                    // Resuming a draft may in fact be resuming a reply/reply all/forward.
881                    // Use a best guess and infer the action here.
882                    String inferredAction = inferAction();
883                    if (inferredAction != null) {
884                        setAction(inferredAction);
885                        // No need to update the action selector as switching actions should do it.
886                        return;
887                    }
888                }
889
890                updateActionSelector();
891            }
892
893            @Override
894            public void onLoadFailed() {
895                // The loading of the source message is only really required if it is needed
896                // immediately to restore the view contents. In the case of resuming draft, it
897                // is only needed to gather additional information.
898                if (restoreViews) {
899                    Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
900                    finish();
901                }
902            }
903        }).executeParallel((Void[]) null);
904    }
905
906    /**
907     * Infers whether or not the current state of the message best reflects either a reply,
908     * reply-all, or forward.
909     */
910    @VisibleForTesting
911    String inferAction() {
912        String subject = mSubjectView.getText().toString();
913        if (subject == null) {
914            return null;
915        }
916        if (subject.toLowerCase().startsWith("fwd:")) {
917            return ACTION_FORWARD;
918        } else if (subject.toLowerCase().startsWith("re:")) {
919            int numRecipients = getAddresses(mToView).length
920                    + getAddresses(mCcView).length
921                    + getAddresses(mBccView).length;
922            if (numRecipients > 1) {
923                return ACTION_REPLY_ALL;
924            } else {
925                return ACTION_REPLY;
926            }
927        } else {
928            // Unsure.
929            return null;
930        }
931    }
932
933    private interface OnMessageLoadHandler {
934        /**
935         * Handles a load to a message (e.g. a draft message or a source message).
936         */
937        void onMessageLoaded(Message message, Body body);
938
939        /**
940         * Handles a failure to load a message.
941         */
942        void onLoadFailed();
943    }
944
945    /**
946     * Asynchronously loads a message and the account information.
947     * This can be used to load a reference message (when replying) or when restoring a draft.
948     */
949    private class LoadMessageTask extends EmailAsyncTask<Void, Void, Object[]> {
950        /**
951         * The message ID to load, if available.
952         */
953        private long mMessageId;
954
955        /**
956         * A future-like reference to the save task which must complete prior to this load.
957         */
958        private final SendOrSaveMessageTask mSaveTask;
959
960        /**
961         * A callback to pass the results of the load to.
962         */
963        private final OnMessageLoadHandler mCallback;
964
965        public LoadMessageTask(
966                long messageId, SendOrSaveMessageTask saveTask, OnMessageLoadHandler callback) {
967            super(mTaskTracker);
968            mMessageId = messageId;
969            mSaveTask = saveTask;
970            mCallback = callback;
971        }
972
973        private long getIdToLoad() throws InterruptedException, ExecutionException {
974            if (mMessageId == -1) {
975                mMessageId = mSaveTask.get();
976            }
977            return mMessageId;
978        }
979
980        @Override
981        protected Object[] doInBackground(Void... params) {
982            long messageId;
983            try {
984                messageId = getIdToLoad();
985            } catch (InterruptedException e) {
986                // Don't have a good message ID to load - bail.
987                Log.e(Logging.LOG_TAG,
988                        "Unable to load draft message since existing save task failed: " + e);
989                return null;
990            } catch (ExecutionException e) {
991                // Don't have a good message ID to load - bail.
992                Log.e(Logging.LOG_TAG,
993                        "Unable to load draft message since existing save task failed: " + e);
994                return null;
995            }
996            Message message = Message.restoreMessageWithId(MessageCompose.this, messageId);
997            if (message == null) {
998                return null;
999            }
1000            long accountId = message.mAccountKey;
1001            Account account = Account.restoreAccountWithId(MessageCompose.this, accountId);
1002            Body body;
1003            try {
1004                body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId);
1005            } catch (RuntimeException e) {
1006                Log.d(Logging.LOG_TAG, "Exception while loading message body: " + e);
1007                return null;
1008            }
1009            return new Object[] {message, body, account};
1010        }
1011
1012        @Override
1013        protected void onSuccess(Object[] results) {
1014            if ((results == null) || (results.length != 3)) {
1015                mCallback.onLoadFailed();
1016                return;
1017            }
1018
1019            final Message message = (Message) results[0];
1020            final Body body = (Body) results[1];
1021            final Account account = (Account) results[2];
1022            if ((message == null) || (body == null) || (account == null)) {
1023                mCallback.onLoadFailed();
1024                return;
1025            }
1026
1027            setAccount(account);
1028            mCallback.onMessageLoaded(message, body);
1029            setMessageLoaded(true);
1030        }
1031    }
1032
1033    private interface AttachmentLoadedCallback {
1034        /**
1035         * Handles completion of the loading of a set of attachments.
1036         * Callback will always happen on the main thread.
1037         */
1038        void onAttachmentLoaded(Attachment[] attachment);
1039    }
1040
1041    private void loadAttachments(
1042            final long messageId,
1043            final Account account,
1044            final AttachmentLoadedCallback callback) {
1045        new EmailAsyncTask<Void, Void, Attachment[]>(mTaskTracker) {
1046            @Override
1047            protected Attachment[] doInBackground(Void... params) {
1048                return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this, messageId);
1049            }
1050
1051            @Override
1052            protected void onSuccess(Attachment[] attachments) {
1053                if (attachments == null) {
1054                    attachments = new Attachment[0];
1055                }
1056                callback.onAttachmentLoaded(attachments);
1057            }
1058        }.executeParallel((Void[]) null);
1059    }
1060
1061    @Override
1062    public void onFocusChange(View view, boolean focused) {
1063        if (focused) {
1064            switch (view.getId()) {
1065                case R.id.message_content:
1066                    // When focusing on the message content via tabbing to it, or other means of
1067                    // auto focusing, move the cursor to the end of the body (before the signature).
1068                    if (mMessageContentView.getSelectionStart() == 0
1069                            && mMessageContentView.getSelectionEnd() == 0) {
1070                        // There is no way to determine if the focus change was programmatic or due
1071                        // to keyboard event, or if it was due to a tap/restore. Use a best-guess
1072                        // by using the fact that auto-focus/keyboard tabs set the selection to 0.
1073                        setMessageContentSelection(getAccountSignature(mAccount));
1074                    }
1075            }
1076        }
1077    }
1078
1079    private static void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
1080        if (addresses == null) {
1081            return;
1082        }
1083        for (Address address : addresses) {
1084            addAddress(view, address.toString());
1085        }
1086    }
1087
1088    private static void addAddresses(MultiAutoCompleteTextView view, String[] addresses) {
1089        if (addresses == null) {
1090            return;
1091        }
1092        for (String oneAddress : addresses) {
1093            addAddress(view, oneAddress);
1094        }
1095    }
1096
1097    private static void addAddresses(MultiAutoCompleteTextView view, String addresses) {
1098        if (addresses == null) {
1099            return;
1100        }
1101        Address[] unpackedAddresses = Address.unpack(addresses);
1102        for (Address address : unpackedAddresses) {
1103            addAddress(view, address.toString());
1104        }
1105    }
1106
1107    private static void addAddress(MultiAutoCompleteTextView view, String address) {
1108        view.append(address + ", ");
1109    }
1110
1111    private static String getPackedAddresses(TextView view) {
1112        Address[] addresses = Address.parse(view.getText().toString().trim());
1113        return Address.pack(addresses);
1114    }
1115
1116    private static Address[] getAddresses(TextView view) {
1117        Address[] addresses = Address.parse(view.getText().toString().trim());
1118        return addresses;
1119    }
1120
1121    /*
1122     * Computes a short string indicating the destination of the message based on To, Cc, Bcc.
1123     * If only one address appears, returns the friendly form of that address.
1124     * Otherwise returns the friendly form of the first address appended with "and N others".
1125     */
1126    private String makeDisplayName(String packedTo, String packedCc, String packedBcc) {
1127        Address first = null;
1128        int nRecipients = 0;
1129        for (String packed: new String[] {packedTo, packedCc, packedBcc}) {
1130            Address[] addresses = Address.unpack(packed);
1131            nRecipients += addresses.length;
1132            if (first == null && addresses.length > 0) {
1133                first = addresses[0];
1134            }
1135        }
1136        if (nRecipients == 0) {
1137            return "";
1138        }
1139        String friendly = first.toFriendly();
1140        if (nRecipients == 1) {
1141            return friendly;
1142        }
1143        return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1);
1144    }
1145
1146    private ContentValues getUpdateContentValues(Message message) {
1147        ContentValues values = new ContentValues();
1148        values.put(MessageColumns.TIMESTAMP, message.mTimeStamp);
1149        values.put(MessageColumns.FROM_LIST, message.mFrom);
1150        values.put(MessageColumns.TO_LIST, message.mTo);
1151        values.put(MessageColumns.CC_LIST, message.mCc);
1152        values.put(MessageColumns.BCC_LIST, message.mBcc);
1153        values.put(MessageColumns.SUBJECT, message.mSubject);
1154        values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName);
1155        values.put(MessageColumns.FLAG_READ, message.mFlagRead);
1156        values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded);
1157        values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment);
1158        values.put(MessageColumns.FLAGS, message.mFlags);
1159        return values;
1160    }
1161
1162    /**
1163     * Updates the given message using values from the compose UI.
1164     *
1165     * @param message The message to be updated.
1166     * @param account the account (used to obtain From: address).
1167     * @param hasAttachments true if it has one or more attachment.
1168     * @param sending set true if the message is about to sent, in which case we perform final
1169     *        clean up;
1170     */
1171    private void updateMessage(Message message, Account account, boolean hasAttachments,
1172            boolean sending) {
1173        if (message.mMessageId == null || message.mMessageId.length() == 0) {
1174            message.mMessageId = Utility.generateMessageId();
1175        }
1176        message.mTimeStamp = System.currentTimeMillis();
1177        message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack();
1178        message.mTo = getPackedAddresses(mToView);
1179        message.mCc = getPackedAddresses(mCcView);
1180        message.mBcc = getPackedAddresses(mBccView);
1181        message.mSubject = mSubjectView.getText().toString();
1182        message.mText = mMessageContentView.getText().toString();
1183        message.mAccountKey = account.mId;
1184        message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc);
1185        message.mFlagRead = true;
1186        message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
1187        message.mFlagAttachment = hasAttachments;
1188        // Use the Intent to set flags saying this message is a reply or a forward and save the
1189        // unique id of the source message
1190        if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) {
1191            message.mSourceKey = mSource.mId;
1192            // If the quote bar is visible; this must either be a reply or forward
1193            // Get the body of the source message here
1194            message.mHtmlReply = mSource.mHtml;
1195            message.mTextReply = mSource.mText;
1196            String fromAsString = Address.unpackToString(mSource.mFrom);
1197            if (isForward()) {
1198                message.mFlags |= Message.FLAG_TYPE_FORWARD;
1199                String subject = mSource.mSubject;
1200                String to = Address.unpackToString(mSource.mTo);
1201                String cc = Address.unpackToString(mSource.mCc);
1202                message.mIntroText =
1203                    getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString,
1204                            to != null ? to : "", cc != null ? cc : "");
1205            } else {
1206                message.mFlags |= Message.FLAG_TYPE_REPLY;
1207                message.mIntroText =
1208                    getString(R.string.message_compose_reply_header_fmt, fromAsString);
1209            }
1210        }
1211
1212        if (includeQuotedText()) {
1213            message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
1214        } else {
1215            message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
1216            if (sending) {
1217                // If we are about to send a message, and not including the original message,
1218                // clear the related field.
1219                // We can't do this until the last minutes, so that the user can change their
1220                // mind later and want to include it again.
1221                mDraft.mIntroText = null;
1222                mDraft.mTextReply = null;
1223                mDraft.mHtmlReply = null;
1224
1225                // Note that mSourceKey is not cleared out as this is still considered a
1226                // reply/forward.
1227            }
1228        }
1229    }
1230
1231    private class SendOrSaveMessageTask extends EmailAsyncTask<Void, Void, Long> {
1232        private final boolean mSend;
1233        private final long mTaskId;
1234
1235        /** A context that will survive even past activity destruction. */
1236        private final Context mContext;
1237
1238        public SendOrSaveMessageTask(long taskId, boolean send) {
1239            super(null /* DO NOT cancel in onDestroy */);
1240            if (send && ActivityManager.isUserAMonkey()) {
1241                Log.d(Logging.LOG_TAG, "Inhibiting send while monkey is in charge.");
1242                send = false;
1243            }
1244            mTaskId = taskId;
1245            mSend = send;
1246            mContext = getApplicationContext();
1247
1248            sActiveSaveTasks.put(mTaskId, this);
1249        }
1250
1251        @Override
1252        protected Long doInBackground(Void... params) {
1253            synchronized (mDraft) {
1254                updateMessage(mDraft, mAccount, mAttachments.size() > 0, mSend);
1255                ContentResolver resolver = getContentResolver();
1256                if (mDraft.isSaved()) {
1257                    // Update the message
1258                    Uri draftUri =
1259                        ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId);
1260                    resolver.update(draftUri, getUpdateContentValues(mDraft), null, null);
1261                    // Update the body
1262                    ContentValues values = new ContentValues();
1263                    values.put(BodyColumns.TEXT_CONTENT, mDraft.mText);
1264                    values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply);
1265                    values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply);
1266                    values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText);
1267                    values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey);
1268                    Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values);
1269                } else {
1270                    // mDraft.mId is set upon return of saveToMailbox()
1271                    mController.saveToMailbox(mDraft, Mailbox.TYPE_DRAFTS);
1272                }
1273                // For any unloaded attachment, set the flag saying we need it loaded
1274                boolean hasUnloadedAttachments = false;
1275                for (Attachment attachment : mAttachments) {
1276                    if (attachment.mContentUri == null &&
1277                            ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0)) {
1278                        attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD;
1279                        hasUnloadedAttachments = true;
1280                        if (Email.DEBUG) {
1281                            Log.d(Logging.LOG_TAG,
1282                                    "Requesting download of attachment #" + attachment.mId);
1283                        }
1284                    }
1285                    // Make sure the UI version of the attachment has the now-correct id; we will
1286                    // use the id again when coming back from picking new attachments
1287                    if (!attachment.isSaved()) {
1288                        // this attachment is new so save it to DB.
1289                        attachment.mMessageKey = mDraft.mId;
1290                        attachment.save(MessageCompose.this);
1291                    } else if (attachment.mMessageKey != mDraft.mId) {
1292                        // We clone the attachment and save it again; otherwise, it will
1293                        // continue to point to the source message.  From this point forward,
1294                        // the attachments will be independent of the original message in the
1295                        // database; however, we still need the message on the server in order
1296                        // to retrieve unloaded attachments
1297                        attachment.mMessageKey = mDraft.mId;
1298                        ContentValues cv = attachment.toContentValues();
1299                        cv.put(Attachment.FLAGS, attachment.mFlags);
1300                        cv.put(Attachment.MESSAGE_KEY, mDraft.mId);
1301                        getContentResolver().insert(Attachment.CONTENT_URI, cv);
1302                    }
1303                }
1304
1305                if (mSend) {
1306                    // Let the user know if message sending might be delayed by background
1307                    // downlading of unloaded attachments
1308                    if (hasUnloadedAttachments) {
1309                        Utility.showToast(MessageCompose.this,
1310                                R.string.message_view_attachment_background_load);
1311                    }
1312                    mController.sendMessage(mDraft);
1313
1314                    ArrayList<CharSequence> addressTexts = new ArrayList<CharSequence>();
1315                    addressTexts.add(mToView.getText());
1316                    addressTexts.add(mCcView.getText());
1317                    addressTexts.add(mBccView.getText());
1318                    DataUsageStatUpdater updater = new DataUsageStatUpdater(mContext);
1319                    updater.updateWithRfc822Address(addressTexts);
1320                }
1321                return mDraft.mId;
1322            }
1323        }
1324
1325        @Override
1326        protected void onSuccess(Long draftId) {
1327            // Note that send or save tasks are always completed, even if the activity
1328            // finishes earlier.
1329            sActiveSaveTasks.remove(mTaskId);
1330            // Don't display the toast if the user is just changing the orientation
1331            if (!mSend && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
1332                Toast.makeText(mContext, R.string.message_saved_toast, Toast.LENGTH_LONG).show();
1333            }
1334        }
1335    }
1336
1337    /**
1338     * Send or save a message:
1339     * - out of the UI thread
1340     * - write to Drafts
1341     * - if send, invoke Controller.sendMessage()
1342     * - when operation is complete, display toast
1343     */
1344    private void sendOrSaveMessage(boolean send) {
1345        if (!mMessageLoaded) {
1346            Log.w(Logging.LOG_TAG,
1347                    "Attempted to save draft message prior to the state being fully loaded");
1348            return;
1349        }
1350        synchronized (sActiveSaveTasks) {
1351            mLastSaveTaskId = sNextSaveTaskId++;
1352
1353            SendOrSaveMessageTask task = new SendOrSaveMessageTask(mLastSaveTaskId, send);
1354
1355            // Ensure the tasks are executed serially so that rapid scheduling doesn't result
1356            // in inconsistent data.
1357            task.executeSerial();
1358        }
1359   }
1360
1361    private void saveIfNeeded() {
1362        if (!mDraftNeedsSaving) {
1363            return;
1364        }
1365        setMessageChanged(false);
1366        sendOrSaveMessage(false);
1367    }
1368
1369    /**
1370     * Checks whether all the email addresses listed in TO, CC, BCC are valid.
1371     */
1372    @VisibleForTesting
1373    boolean isAddressAllValid() {
1374        for (TextView view : new TextView[]{mToView, mCcView, mBccView}) {
1375            String addresses = view.getText().toString().trim();
1376            if (!Address.isAllValid(addresses)) {
1377                view.setError(getString(R.string.message_compose_error_invalid_email));
1378                return false;
1379            }
1380        }
1381        return true;
1382    }
1383
1384    private void onSend() {
1385        if (!isAddressAllValid()) {
1386            Toast.makeText(this, getString(R.string.message_compose_error_invalid_email),
1387                           Toast.LENGTH_LONG).show();
1388        } else if (getAddresses(mToView).length == 0 &&
1389                getAddresses(mCcView).length == 0 &&
1390                getAddresses(mBccView).length == 0) {
1391            mToView.setError(getString(R.string.message_compose_error_no_recipients));
1392            Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
1393                    Toast.LENGTH_LONG).show();
1394        } else {
1395            sendOrSaveMessage(true);
1396            setMessageChanged(false);
1397            finish();
1398        }
1399    }
1400
1401    private void showQuickResponseDialog() {
1402        InsertQuickResponseDialog.newInstance(null, mAccount)
1403                .show(getFragmentManager(), null);
1404    }
1405
1406    /**
1407     * Inserts the selected QuickResponse into the message body at the current cursor position.
1408     */
1409    @Override
1410    public void onQuickResponseSelected(CharSequence text) {
1411        int start = mMessageContentView.getSelectionStart();
1412        int end = mMessageContentView.getSelectionEnd();
1413        mMessageContentView.getEditableText().replace(start, end, text);
1414    }
1415
1416    private void onDiscard() {
1417        DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog");
1418    }
1419
1420    /**
1421     * Called when ok on the "discard draft" dialog is pressed.  Actually delete the draft.
1422     */
1423    @Override
1424    public void onDeleteMessageConfirmationDialogOkPressed() {
1425        if (mDraft.mId > 0) {
1426            // By the way, we can't pass the message ID from onDiscard() to here (using a
1427            // dialog argument or whatever), because you can rotate the screen when the dialog is
1428            // shown, and during rotation we save & restore the draft.  If it's the
1429            // first save, we give it an ID at this point for the first time (and last time).
1430            // Which means it's possible for a draft to not have an ID in onDiscard(),
1431            // but here.
1432            mController.deleteMessage(mDraft.mId);
1433        }
1434        Utility.showToast(MessageCompose.this, R.string.message_discarded_toast);
1435        setMessageChanged(false);
1436        finish();
1437    }
1438
1439    /**
1440     * Handles an explicit user-initiated action to save a draft.
1441     */
1442    private void onSave() {
1443        saveIfNeeded();
1444    }
1445
1446    private void showCcBccFieldsIfFilled() {
1447        if ((mCcView.length() > 0) || (mBccView.length() > 0)) {
1448            showCcBccFields();
1449        }
1450    }
1451
1452    private void showCcBccFields() {
1453        mCcBccContainer.setVisibility(View.VISIBLE);
1454        UiUtilities.setVisibilitySafe(this, R.id.add_cc_bcc, View.INVISIBLE);
1455    }
1456
1457    /**
1458     * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
1459     */
1460    private void onAddAttachment() {
1461        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
1462        i.addCategory(Intent.CATEGORY_OPENABLE);
1463        i.setType(AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]);
1464        startActivityForResult(
1465                Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)),
1466                ACTIVITY_REQUEST_PICK_ATTACHMENT);
1467    }
1468
1469    private Attachment loadAttachmentInfo(Uri uri) {
1470        long size = -1;
1471        ContentResolver contentResolver = getContentResolver();
1472
1473        // Load name & size independently, because not all providers support both
1474        final String name = Utility.getContentFileName(this, uri);
1475
1476        Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION,
1477                null, null, null);
1478        if (metadataCursor != null) {
1479            try {
1480                if (metadataCursor.moveToFirst()) {
1481                    size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE);
1482                }
1483            } finally {
1484                metadataCursor.close();
1485            }
1486        }
1487
1488        // When the size is not provided, we need to determine it locally.
1489        if (size < 0) {
1490            // if the URI is a file: URI, ask file system for its size
1491            if ("file".equalsIgnoreCase(uri.getScheme())) {
1492                String path = uri.getPath();
1493                if (path != null) {
1494                    File file = new File(path);
1495                    size = file.length();  // Returns 0 for file not found
1496                }
1497            }
1498
1499            if (size <= 0) {
1500                // The size was not measurable;  This attachment is not safe to use.
1501                // Quick hack to force a relevant error into the UI
1502                // TODO: A proper announcement of the problem
1503                size = AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE + 1;
1504            }
1505        }
1506
1507        Attachment attachment = new Attachment();
1508        attachment.mFileName = name;
1509        attachment.mContentUri = uri.toString();
1510        attachment.mSize = size;
1511        attachment.mMimeType = AttachmentUtilities.inferMimeTypeForUri(this, uri);
1512        return attachment;
1513    }
1514
1515    private void addAttachment(Attachment attachment) {
1516        // Before attaching the attachment, make sure it meets any other pre-attach criteria
1517        if (attachment.mSize > AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE) {
1518            Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG)
1519                    .show();
1520            return;
1521        }
1522
1523        mAttachments.add(attachment);
1524        updateAttachmentUi();
1525    }
1526
1527    private void updateAttachmentUi() {
1528        mAttachmentContentView.removeAllViews();
1529
1530        for (Attachment attachment : mAttachments) {
1531            // Note: allowDelete is set in two cases:
1532            // 1. First time a message (w/ attachments) is forwarded,
1533            //    where action == ACTION_FORWARD
1534            // 2. 1 -> Save -> Reopen
1535            //    but FLAG_SMART_FORWARD is already set at 1.
1536            // Even if the account supports smart-forward, attachments added
1537            // manually are still removable.
1538            final boolean allowDelete = (attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0;
1539
1540            View view = getLayoutInflater().inflate(R.layout.message_compose_attachment,
1541                    mAttachmentContentView, false);
1542            TextView nameView = UiUtilities.getView(view, R.id.attachment_name);
1543            ImageButton delete = UiUtilities.getView(view, R.id.attachment_delete);
1544            TextView sizeView = UiUtilities.getView(view, R.id.attachment_size);
1545
1546            nameView.setText(attachment.mFileName);
1547            if (attachment.mSize > 0) {
1548                sizeView.setText(UiUtilities.formatSize(this, attachment.mSize));
1549            } else {
1550                sizeView.setVisibility(View.GONE);
1551            }
1552            if (allowDelete) {
1553                delete.setOnClickListener(this);
1554                delete.setTag(view);
1555            } else {
1556                delete.setVisibility(View.INVISIBLE);
1557            }
1558            view.setTag(attachment);
1559            mAttachmentContentView.addView(view);
1560        }
1561        updateAttachmentContainer();
1562    }
1563
1564    private void updateAttachmentContainer() {
1565        mAttachmentContainer.setVisibility(mAttachmentContentView.getChildCount() == 0
1566                ? View.GONE : View.VISIBLE);
1567    }
1568
1569    private void addAttachmentFromUri(Uri uri) {
1570        addAttachment(loadAttachmentInfo(uri));
1571    }
1572
1573    /**
1574     * Same as {@link #addAttachmentFromUri}, but does the mime-type check against
1575     * {@link AttachmentUtilities#ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES}.
1576     */
1577    private void addAttachmentFromSendIntent(Uri uri) {
1578        final Attachment attachment = loadAttachmentInfo(uri);
1579        final String mimeType = attachment.mMimeType;
1580        if (!TextUtils.isEmpty(mimeType) && MimeUtility.mimeTypeMatches(mimeType,
1581                AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) {
1582            addAttachment(attachment);
1583        }
1584    }
1585
1586    @Override
1587    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
1588        if (data == null) {
1589            return;
1590        }
1591        addAttachmentFromUri(data.getData());
1592        setMessageChanged(true);
1593    }
1594
1595    private boolean includeQuotedText() {
1596        return mIncludeQuotedTextCheckBox.isChecked();
1597    }
1598
1599    public void onClick(View view) {
1600        if (handleCommand(view.getId())) {
1601            return;
1602        }
1603        switch (view.getId()) {
1604            case R.id.attachment_delete:
1605                onDeleteAttachmentIconClicked(view);
1606                break;
1607        }
1608    }
1609
1610    private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) {
1611        mIncludeQuotedTextCheckBox.setChecked(include);
1612        mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked()
1613                ? View.VISIBLE : View.GONE);
1614        if (updateNeedsSaving) {
1615            setMessageChanged(true);
1616        }
1617    }
1618
1619    private void onDeleteAttachmentIconClicked(View delButtonView) {
1620        View attachmentView = (View) delButtonView.getTag();
1621        Attachment attachment = (Attachment) attachmentView.getTag();
1622        deleteAttachment(mAttachments, attachment);
1623        updateAttachmentUi();
1624        setMessageChanged(true);
1625    }
1626
1627    /**
1628     * Removes an attachment from the current message.
1629     * If the attachment has previous been saved in the db (i.e. this is a draft message which
1630     * has previously been saved), then the draft is deleted from the db.
1631     *
1632     * This does not update the UI to remove the attachment view.
1633     * @param attachments the list of attachments to delete from. Injected for tests.
1634     * @param attachment the attachment to delete
1635     */
1636    private void deleteAttachment(List<Attachment> attachments, Attachment attachment) {
1637        attachments.remove(attachment);
1638        if ((attachment.mMessageKey == mDraft.mId) && attachment.isSaved()) {
1639            final long attachmentId = attachment.mId;
1640            EmailAsyncTask.runAsyncParallel(new Runnable() {
1641                @Override
1642                public void run() {
1643                    mController.deleteAttachment(attachmentId);
1644                }
1645            });
1646        }
1647    }
1648
1649    @Override
1650    public boolean onOptionsItemSelected(MenuItem item) {
1651        if (handleCommand(item.getItemId())) {
1652            return true;
1653        }
1654        return super.onOptionsItemSelected(item);
1655    }
1656
1657    private boolean handleCommand(int viewId) {
1658        switch (viewId) {
1659        case android.R.id.home:
1660            onActionBarHomePressed();
1661            return true;
1662        case R.id.send:
1663            onSend();
1664            return true;
1665        case R.id.save:
1666            onSave();
1667            return true;
1668        case R.id.show_quick_text_list_dialog:
1669            showQuickResponseDialog();
1670            return true;
1671        case R.id.discard:
1672            onDiscard();
1673            return true;
1674        case R.id.include_quoted_text:
1675            // The checkbox is already toggled at this point.
1676            setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true);
1677            return true;
1678        case R.id.add_cc_bcc:
1679            showCcBccFields();
1680            return true;
1681        case R.id.add_attachment:
1682            onAddAttachment();
1683            return true;
1684        }
1685        return false;
1686    }
1687
1688    private void onActionBarHomePressed() {
1689        finish();
1690        if (isOpenedFromWithinApp()) {
1691            // If opened from within the app, we just close it.
1692        } else {
1693            // Otherwise, need to open the main screen for the appropriate account.
1694            // Note that mAccount should always be set by the time the action bar is set up.
1695            startActivity(Welcome.createOpenAccountInboxIntent(this, mAccount.mId));
1696        }
1697    }
1698
1699    private void setAction(String action) {
1700        if (Objects.equal(action, mAction)) {
1701            return;
1702        }
1703
1704        mAction = action;
1705        onActionChanged();
1706    }
1707
1708    /**
1709     * Handles changing from reply/reply all/forward states. Note: this activity cannot transition
1710     * from a standard compose state to any of the other three states.
1711     */
1712    private void onActionChanged() {
1713        if (!hasSourceMessage()) {
1714            return;
1715        }
1716        // Temporarily remove listeners so that changing action does not invalidate and save message
1717        removeListeners();
1718
1719        processSourceMessage(mSource, mAccount);
1720
1721        // Note that the attachments might not be loaded yet, but this will safely noop
1722        // if that's the case, and the attachments will be processed when they load.
1723        if (processSourceMessageAttachments(mAttachments, mSourceAttachments, isForward())) {
1724            updateAttachmentUi();
1725            setMessageChanged(true);
1726        }
1727
1728        updateActionSelector();
1729        addListeners();
1730    }
1731
1732    /**
1733     * Updates UI components that allows the user to switch between reply/reply all/forward.
1734     */
1735    private void updateActionSelector() {
1736        // Update reply/reply all/forward switcher.
1737        if (shouldUseActionTabs()) {
1738            // Tab-based mode switching.
1739            ActionBar actionBar = getActionBar();
1740
1741            if (actionBar.getTabCount() > 0) {
1742                actionBar.removeAllTabs();
1743            }
1744            createAndAddTab(R.string.reply_action, ACTION_REPLY);
1745            createAndAddTab(R.string.reply_all_action, ACTION_REPLY_ALL);
1746            createAndAddTab(R.string.forward_action, ACTION_FORWARD);
1747
1748            actionBar.setDisplayShowTitleEnabled(false);
1749            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
1750        } else {
1751            // Spinner based mode switching.
1752            if (mActionSpinnerAdapter == null) {
1753                mActionSpinnerAdapter = new ActionSpinnerAdapter(this);
1754                mActionSpinner.setAdapter(mActionSpinnerAdapter);
1755                mActionSpinner.setOnItemSelectedListener(new OnItemSelectedListener() {
1756                    @Override
1757                    public void onItemSelected(
1758                            AdapterView<?> parent, View view, int position, long id) {
1759                        setAction(ActionSpinnerAdapter.getAction(position));
1760                    }
1761
1762                    @Override
1763                    public void onNothingSelected(AdapterView<?> parent) {
1764                        // Should not happen.
1765                    }
1766                });
1767            }
1768            int position = mActionSpinnerAdapter.getPosition(mAction);
1769            mActionSpinner.setSelection(position);
1770            mActionSpinner.setVisibility(View.VISIBLE);
1771        }
1772    }
1773
1774    private final TabListener ACTION_TAB_LISTENER = new TabListener() {
1775        @Override public void onTabReselected(Tab tab, FragmentTransaction ft) {}
1776        @Override public void onTabUnselected(Tab tab, FragmentTransaction ft) {}
1777
1778        @Override
1779        public void onTabSelected(Tab tab, FragmentTransaction ft) {
1780            String action = (String) tab.getTag();
1781            setAction(action);
1782        }
1783    };
1784
1785    private static class ActionSpinnerAdapter extends ArrayAdapter<String> {
1786        public ActionSpinnerAdapter(final Context context) {
1787            super(context,
1788                    android.R.layout.simple_spinner_dropdown_item,
1789                    android.R.id.text1,
1790                    Lists.newArrayList(ACTION_REPLY, ACTION_REPLY_ALL, ACTION_FORWARD));
1791        }
1792
1793        @Override
1794        public View getDropDownView(int position, View convertView, ViewGroup parent) {
1795            View result = super.getDropDownView(position, convertView, parent);
1796            ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position));
1797            return result;
1798        }
1799
1800        @Override
1801        public View getView(int position, View convertView, ViewGroup parent) {
1802            View result = super.getView(position, convertView, parent);
1803            ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position));
1804            return result;
1805        }
1806
1807        private String getDisplayValue(int position) {
1808            switch (position) {
1809                case 0:
1810                    return getContext().getString(R.string.reply_action);
1811                case 1:
1812                    return getContext().getString(R.string.reply_all_action);
1813                case 2:
1814                    return getContext().getString(R.string.forward_action);
1815                default:
1816                    throw new IllegalArgumentException("Invalid action type for spinner");
1817            }
1818        }
1819
1820        public static String getAction(int position) {
1821            switch (position) {
1822                case 0:
1823                    return ACTION_REPLY;
1824                case 1:
1825                    return ACTION_REPLY_ALL;
1826                case 2:
1827                    return ACTION_FORWARD;
1828                default:
1829                    throw new IllegalArgumentException("Invalid action type for spinner");
1830            }
1831        }
1832
1833    }
1834
1835    private Tab createAndAddTab(int labelResource, final String action) {
1836        ActionBar.Tab tab = getActionBar().newTab();
1837        boolean selected = mAction.equals(action);
1838        tab.setTag(action);
1839        tab.setText(getString(labelResource));
1840        tab.setTabListener(ACTION_TAB_LISTENER);
1841        getActionBar().addTab(tab, selected);
1842        return tab;
1843    }
1844
1845    private boolean shouldUseActionTabs() {
1846        return getResources().getBoolean(R.bool.message_compose_action_tabs);
1847    }
1848
1849    @Override
1850    public boolean onCreateOptionsMenu(Menu menu) {
1851        super.onCreateOptionsMenu(menu);
1852        getMenuInflater().inflate(R.menu.message_compose_option, menu);
1853        return true;
1854    }
1855
1856    @Override
1857    public boolean onPrepareOptionsMenu(Menu menu) {
1858        menu.findItem(R.id.save).setEnabled(mDraftNeedsSaving);
1859        return true;
1860    }
1861
1862    /**
1863     * Set a message body and a signature when the Activity is launched.
1864     *
1865     * @param text the message body
1866     */
1867    @VisibleForTesting
1868    void setInitialComposeText(CharSequence text, String signature) {
1869        mMessageContentView.setText("");
1870        int textLength = 0;
1871        if (text != null) {
1872            mMessageContentView.append(text);
1873            textLength = text.length();
1874        }
1875        if (!TextUtils.isEmpty(signature)) {
1876            if (textLength == 0 || text.charAt(textLength - 1) != '\n') {
1877                mMessageContentView.append("\n");
1878            }
1879            mMessageContentView.append(signature);
1880
1881            // Reset cursor to right before the signature.
1882            mMessageContentView.setSelection(textLength);
1883        }
1884    }
1885
1886    /**
1887     * Fill all the widgets with the content found in the Intent Extra, if any.
1888     *
1889     * Note that we don't actually check the intent action  (typically VIEW, SENDTO, or SEND).
1890     * There is enough overlap in the definitions that it makes more sense to simply check for
1891     * all available data and use as much of it as possible.
1892     *
1893     * With one exception:  EXTRA_STREAM is defined as only valid for ACTION_SEND.
1894     *
1895     * @param intent the launch intent
1896     */
1897    @VisibleForTesting
1898    void initFromIntent(Intent intent) {
1899
1900        setAccount(intent);
1901
1902        // First, add values stored in top-level extras
1903        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
1904        if (extraStrings != null) {
1905            addAddresses(mToView, extraStrings);
1906        }
1907        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
1908        if (extraStrings != null) {
1909            addAddresses(mCcView, extraStrings);
1910        }
1911        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1912        if (extraStrings != null) {
1913            addAddresses(mBccView, extraStrings);
1914        }
1915        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1916        if (extraString != null) {
1917            mSubjectView.setText(extraString);
1918        }
1919
1920        // Next, if we were invoked with a URI, try to interpret it
1921        // We'll take two courses here.  If it's mailto:, there is a specific set of rules
1922        // that define various optional fields.  However, for any other scheme, we'll simply
1923        // take the entire scheme-specific part and interpret it as a possible list of addresses.
1924        final Uri dataUri = intent.getData();
1925        if (dataUri != null) {
1926            if ("mailto".equals(dataUri.getScheme())) {
1927                initializeFromMailTo(dataUri.toString());
1928            } else {
1929                String toText = dataUri.getSchemeSpecificPart();
1930                if (toText != null) {
1931                    addAddresses(mToView, toText.split(","));
1932                }
1933            }
1934        }
1935
1936        // Next, fill in the plaintext (note, this will override mailto:?body=)
1937        CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
1938        setInitialComposeText(text, getAccountSignature(mAccount));
1939
1940        // Next, convert EXTRA_STREAM into an attachment
1941        if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) {
1942            Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
1943            if (uri != null) {
1944                addAttachmentFromSendIntent(uri);
1945            }
1946        }
1947
1948        if (Intent.ACTION_SEND_MULTIPLE.equals(mAction)
1949                && intent.hasExtra(Intent.EXTRA_STREAM)) {
1950            ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
1951            if (list != null) {
1952                for (Parcelable parcelable : list) {
1953                    Uri uri = (Uri) parcelable;
1954                    if (uri != null) {
1955                        addAttachmentFromSendIntent(uri);
1956                    }
1957                }
1958            }
1959        }
1960
1961        // Finally - expose fields that were filled in but are normally hidden, and set focus
1962        showCcBccFieldsIfFilled();
1963        setNewMessageFocus();
1964    }
1965
1966    /**
1967     * When we are launched with an intent that includes a mailto: URI, we can actually
1968     * gather quite a few of our message fields from it.
1969     *
1970     * @param mailToString the href (which must start with "mailto:").
1971     */
1972    private void initializeFromMailTo(String mailToString) {
1973
1974        // Chop up everything between mailto: and ? to find recipients
1975        int index = mailToString.indexOf("?");
1976        int length = "mailto".length() + 1;
1977        String to;
1978        try {
1979            // Extract the recipient after mailto:
1980            if (index == -1) {
1981                to = decode(mailToString.substring(length));
1982            } else {
1983                to = decode(mailToString.substring(length, index));
1984            }
1985            addAddresses(mToView, to.split(" ,"));
1986        } catch (UnsupportedEncodingException e) {
1987            Log.e(Logging.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'");
1988        }
1989
1990        // Extract the other parameters
1991
1992        // We need to disguise this string as a URI in order to parse it
1993        Uri uri = Uri.parse("foo://" + mailToString);
1994
1995        List<String> cc = uri.getQueryParameters("cc");
1996        addAddresses(mCcView, cc.toArray(new String[cc.size()]));
1997
1998        List<String> otherTo = uri.getQueryParameters("to");
1999        addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()]));
2000
2001        List<String> bcc = uri.getQueryParameters("bcc");
2002        addAddresses(mBccView, bcc.toArray(new String[bcc.size()]));
2003
2004        List<String> subject = uri.getQueryParameters("subject");
2005        if (subject.size() > 0) {
2006            mSubjectView.setText(subject.get(0));
2007        }
2008
2009        List<String> body = uri.getQueryParameters("body");
2010        if (body.size() > 0) {
2011            setInitialComposeText(body.get(0), getAccountSignature(mAccount));
2012        }
2013    }
2014
2015    private String decode(String s) throws UnsupportedEncodingException {
2016        return URLDecoder.decode(s, "UTF-8");
2017    }
2018
2019    /**
2020     * Displays quoted text from the original email
2021     */
2022    private void displayQuotedText(String textBody, String htmlBody) {
2023        // Only use plain text if there is no HTML body
2024        boolean plainTextFlag = TextUtils.isEmpty(htmlBody);
2025        String text = plainTextFlag ? textBody : htmlBody;
2026        if (text != null) {
2027            text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text;
2028            // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML
2029            //    EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount,
2030            //                                     text, message, 0);
2031            mQuotedTextBar.setVisibility(View.VISIBLE);
2032            if (mQuotedText != null) {
2033                mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null);
2034            }
2035        }
2036    }
2037
2038    /**
2039     * Given a packed address String, the address of our sending account, a view, and a list of
2040     * addressees already added to other addressing views, adds unique addressees that don't
2041     * match our address to the passed in view
2042     */
2043    private static boolean safeAddAddresses(String addrs, String ourAddress,
2044            MultiAutoCompleteTextView view, ArrayList<Address> addrList) {
2045        boolean added = false;
2046        for (Address address : Address.unpack(addrs)) {
2047            // Don't send to ourselves or already-included addresses
2048            if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) {
2049                addrList.add(address);
2050                addAddress(view, address.toString());
2051                added = true;
2052            }
2053        }
2054        return added;
2055    }
2056
2057    /**
2058     * Set up the to and cc views properly for the "reply" and "replyAll" cases.  What's important
2059     * is that we not 1) send to ourselves, and 2) duplicate addressees.
2060     * @param message the message we're replying to
2061     * @param account the account we're sending from
2062     * @param replyAll whether this is a replyAll (vs a reply)
2063     */
2064    @VisibleForTesting
2065    void setupAddressViews(Message message, Account account, boolean replyAll) {
2066        // Start clean.
2067        clearAddressViews();
2068
2069        // If Reply-to: addresses are included, use those; otherwise, use the From: address.
2070        Address[] replyToAddresses = Address.unpack(message.mReplyTo);
2071        if (replyToAddresses.length == 0) {
2072            replyToAddresses = Address.unpack(message.mFrom);
2073        }
2074
2075        // Check if ourAddress is one of the replyToAddresses to decide how to populate To: field
2076        String ourAddress = account.mEmailAddress;
2077        boolean containsOurAddress = false;
2078        for (Address address : replyToAddresses) {
2079            if (ourAddress.equalsIgnoreCase(address.getAddress())) {
2080                containsOurAddress = true;
2081                break;
2082            }
2083        }
2084
2085        if (containsOurAddress) {
2086            addAddresses(mToView, message.mTo);
2087        } else {
2088            addAddresses(mToView, replyToAddresses);
2089        }
2090
2091        if (replyAll) {
2092            // Keep a running list of addresses we're sending to
2093            ArrayList<Address> allAddresses = new ArrayList<Address>();
2094            for (Address address: replyToAddresses) {
2095                allAddresses.add(address);
2096            }
2097
2098            if (!containsOurAddress) {
2099                safeAddAddresses(message.mTo, ourAddress, mCcView, allAddresses);
2100            }
2101
2102            safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses);
2103        }
2104        showCcBccFieldsIfFilled();
2105    }
2106
2107    private void clearAddressViews() {
2108        mToView.setText("");
2109        mCcView.setText("");
2110        mBccView.setText("");
2111    }
2112
2113    /**
2114     * Pull out the parts of the now loaded source message and apply them to the new message
2115     * depending on the type of message being composed.
2116     */
2117    @VisibleForTesting
2118    void processSourceMessage(Message message, Account account) {
2119        String subject = message.mSubject;
2120        if (subject == null) {
2121            subject = "";
2122        }
2123        if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) {
2124            setupAddressViews(message, account, ACTION_REPLY_ALL.equals(mAction));
2125            if (!subject.toLowerCase().startsWith("re:")) {
2126                mSubjectView.setText("Re: " + subject);
2127            } else {
2128                mSubjectView.setText(subject);
2129            }
2130            displayQuotedText(message.mText, message.mHtml);
2131            setIncludeQuotedText(true, false);
2132        } else if (ACTION_FORWARD.equals(mAction)) {
2133            clearAddressViews();
2134            mSubjectView.setText(!subject.toLowerCase().startsWith("fwd:")
2135                    ? "Fwd: " + subject : subject);
2136            displayQuotedText(message.mText, message.mHtml);
2137            setIncludeQuotedText(true, false);
2138        } else {
2139            Log.w(Logging.LOG_TAG, "Unexpected action for a call to processSourceMessage "
2140                    + mAction);
2141        }
2142        showCcBccFieldsIfFilled();
2143        setNewMessageFocus();
2144    }
2145
2146    /**
2147     * Processes the source attachments and ensures they're either included or excluded from
2148     * a list of active attachments. This can be used to add attachments for a forwarded message, or
2149     * to remove them if going from a "Forward" to a "Reply"
2150     * Uniqueness is based on filename.
2151     *
2152     * @param current the list of active attachments on the current message. Injected for tests.
2153     * @param sourceAttachments the list of attachments related with the source message. Injected
2154     *     for tests.
2155     * @param include whether or not the sourceMessages should be included or excluded from the
2156     *     current list of active attachments
2157     * @return whether or not the current attachments were modified
2158     */
2159    @VisibleForTesting
2160    boolean processSourceMessageAttachments(
2161            List<Attachment> current, List<Attachment> sourceAttachments, boolean include) {
2162
2163        // Build a map of filename to the active attachments.
2164        HashMap<String, Attachment> currentNames = new HashMap<String, Attachment>();
2165        for (Attachment attachment : current) {
2166            currentNames.put(attachment.mFileName, attachment);
2167        }
2168
2169        boolean dirty = false;
2170        if (include) {
2171            // Needs to make sure it's in the list.
2172            for (Attachment attachment : sourceAttachments) {
2173                if (!currentNames.containsKey(attachment.mFileName)) {
2174                    current.add(attachment);
2175                    dirty = true;
2176                }
2177            }
2178        } else {
2179            // Need to remove the source attachments.
2180            HashSet<String> sourceNames = new HashSet<String>();
2181            for (Attachment attachment : sourceAttachments) {
2182                if (currentNames.containsKey(attachment.mFileName)) {
2183                    deleteAttachment(current, currentNames.get(attachment.mFileName));
2184                    dirty = true;
2185                }
2186            }
2187        }
2188
2189        return dirty;
2190    }
2191
2192    /**
2193     * Set a cursor to the end of a body except a signature.
2194     */
2195    @VisibleForTesting
2196    void setMessageContentSelection(String signature) {
2197        int selection = mMessageContentView.length();
2198        if (!TextUtils.isEmpty(signature)) {
2199            int signatureLength = signature.length();
2200            int estimatedSelection = selection - signatureLength;
2201            if (estimatedSelection >= 0) {
2202                CharSequence text = mMessageContentView.getText();
2203                int i = 0;
2204                while (i < signatureLength
2205                       && text.charAt(estimatedSelection + i) == signature.charAt(i)) {
2206                    ++i;
2207                }
2208                if (i == signatureLength) {
2209                    selection = estimatedSelection;
2210                    while (selection > 0 && text.charAt(selection - 1) == '\n') {
2211                        --selection;
2212                    }
2213                }
2214            }
2215        }
2216        mMessageContentView.setSelection(selection, selection);
2217    }
2218
2219    /**
2220     * In order to accelerate typing, position the cursor in the first empty field,
2221     * or at the end of the body composition field if none are empty.  Typically, this will
2222     * play out as follows:
2223     *   Reply / Reply All - put cursor in the empty message body
2224     *   Forward - put cursor in the empty To field
2225     *   Edit Draft - put cursor in whatever field still needs entry
2226     */
2227    private void setNewMessageFocus() {
2228        if (mToView.length() == 0) {
2229            mToView.requestFocus();
2230        } else if (mSubjectView.length() == 0) {
2231            mSubjectView.requestFocus();
2232        } else {
2233            mMessageContentView.requestFocus();
2234        }
2235    }
2236
2237    private boolean isForward() {
2238        return ACTION_FORWARD.equals(mAction);
2239    }
2240
2241    /**
2242     * @return the signature for the specified account, if non-null. If the account specified is
2243     *     null or has no signature, {@code null} is returned.
2244     */
2245    private static String getAccountSignature(Account account) {
2246        return (account == null) ? null : account.mSignature;
2247    }
2248}
2249