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