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