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