ComposeActivity.java revision fc6fd98fc1689130529e198e77b6a2d51e6cf0bd
1/**
2 * Copyright (c) 2011, Google Inc.
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.mail.compose;
18
19import android.annotation.SuppressLint;
20import android.annotation.TargetApi;
21import android.app.ActionBar;
22import android.app.ActionBar.OnNavigationListener;
23import android.app.Activity;
24import android.app.ActivityManager;
25import android.app.AlertDialog;
26import android.app.Dialog;
27import android.app.DialogFragment;
28import android.app.Fragment;
29import android.app.FragmentTransaction;
30import android.app.LoaderManager;
31import android.content.ClipData;
32import android.content.ContentResolver;
33import android.content.ContentValues;
34import android.content.Context;
35import android.content.CursorLoader;
36import android.content.DialogInterface;
37import android.content.Intent;
38import android.content.Loader;
39import android.content.pm.ActivityInfo;
40import android.content.res.Resources;
41import android.database.Cursor;
42import android.net.Uri;
43import android.os.AsyncTask;
44import android.os.Build;
45import android.os.Bundle;
46import android.os.Handler;
47import android.os.HandlerThread;
48import android.os.ParcelFileDescriptor;
49import android.provider.BaseColumns;
50import android.support.v4.app.RemoteInput;
51import android.text.Editable;
52import android.text.Html;
53import android.text.SpanWatcher;
54import android.text.SpannableString;
55import android.text.Spanned;
56import android.text.TextUtils;
57import android.text.TextWatcher;
58import android.text.util.Rfc822Token;
59import android.text.util.Rfc822Tokenizer;
60import android.view.Gravity;
61import android.view.KeyEvent;
62import android.view.LayoutInflater;
63import android.view.Menu;
64import android.view.MenuInflater;
65import android.view.MenuItem;
66import android.view.View;
67import android.view.View.OnClickListener;
68import android.view.ViewGroup;
69import android.view.inputmethod.BaseInputConnection;
70import android.view.inputmethod.EditorInfo;
71import android.widget.ArrayAdapter;
72import android.widget.Button;
73import android.widget.EditText;
74import android.widget.TextView;
75import android.widget.Toast;
76
77import com.android.common.Rfc822Validator;
78import com.android.common.contacts.DataUsageStatUpdater;
79import com.android.emailcommon.mail.Address;
80import com.android.ex.chips.BaseRecipientAdapter;
81import com.android.ex.chips.DropdownChipLayouter;
82import com.android.ex.chips.RecipientEditTextView;
83import com.android.mail.MailIntentService;
84import com.android.mail.R;
85import com.android.mail.analytics.Analytics;
86import com.android.mail.browse.MessageHeaderView;
87import com.android.mail.compose.AttachmentsView.AttachmentAddedOrDeletedListener;
88import com.android.mail.compose.AttachmentsView.AttachmentFailureException;
89import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener;
90import com.android.mail.compose.QuotedTextView.RespondInlineListener;
91import com.android.mail.providers.Account;
92import com.android.mail.providers.Attachment;
93import com.android.mail.providers.Folder;
94import com.android.mail.providers.MailAppProvider;
95import com.android.mail.providers.Message;
96import com.android.mail.providers.MessageModification;
97import com.android.mail.providers.ReplyFromAccount;
98import com.android.mail.providers.Settings;
99import com.android.mail.providers.UIProvider;
100import com.android.mail.providers.UIProvider.AccountCapabilities;
101import com.android.mail.providers.UIProvider.DraftType;
102import com.android.mail.ui.AttachmentTile.AttachmentPreview;
103import com.android.mail.ui.MailActivity;
104import com.android.mail.ui.WaitFragment;
105import com.android.mail.utils.AccountUtils;
106import com.android.mail.utils.AttachmentUtils;
107import com.android.mail.utils.ContentProviderTask;
108import com.android.mail.utils.HtmlUtils;
109import com.android.mail.utils.LogTag;
110import com.android.mail.utils.LogUtils;
111import com.android.mail.utils.NotificationActionUtils;
112import com.android.mail.utils.Utils;
113import com.google.android.mail.common.html.parser.HtmlTree;
114import com.google.common.annotations.VisibleForTesting;
115import com.google.common.collect.Lists;
116import com.google.common.collect.Sets;
117
118import java.io.FileNotFoundException;
119import java.io.IOException;
120import java.io.UnsupportedEncodingException;
121import java.net.URLDecoder;
122import java.util.ArrayList;
123import java.util.Arrays;
124import java.util.Collection;
125import java.util.HashMap;
126import java.util.HashSet;
127import java.util.List;
128import java.util.Map.Entry;
129import java.util.Set;
130import java.util.concurrent.ConcurrentHashMap;
131
132public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
133        RespondInlineListener, TextWatcher,
134        AttachmentAddedOrDeletedListener, OnAccountChangedListener,
135        LoaderManager.LoaderCallbacks<Cursor>, TextView.OnEditorActionListener,
136        RecipientEditTextView.RecipientEntryItemClickedListener {
137    /**
138     * An {@link Intent} action that launches {@link ComposeActivity}, but is handled as if the
139     * {@link Activity} were launched with no special action.
140     */
141    private static final String ACTION_LAUNCH_COMPOSE =
142            "com.android.mail.intent.action.LAUNCH_COMPOSE";
143
144    // Identifiers for which type of composition this is
145    public static final int COMPOSE = -1;
146    public static final int REPLY = 0;
147    public static final int REPLY_ALL = 1;
148    public static final int FORWARD = 2;
149    public static final int EDIT_DRAFT = 3;
150
151    // Integer extra holding one of the above compose action
152    protected static final String EXTRA_ACTION = "action";
153
154    private static final String EXTRA_SHOW_CC = "showCc";
155    private static final String EXTRA_SHOW_BCC = "showBcc";
156    private static final String EXTRA_RESPONDED_INLINE = "respondedInline";
157    private static final String EXTRA_SAVE_ENABLED = "saveEnabled";
158
159    private static final String UTF8_ENCODING_NAME = "UTF-8";
160
161    private static final String MAIL_TO = "mailto";
162
163    private static final String EXTRA_SUBJECT = "subject";
164
165    private static final String EXTRA_BODY = "body";
166
167    /**
168     * Expected to be html formatted text.
169     */
170    private static final String EXTRA_QUOTED_TEXT = "quotedText";
171
172    protected static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString";
173
174    private static final String EXTRA_ATTACHMENT_PREVIEWS = "attachmentPreviews";
175
176    // Extra that we can get passed from other activities
177    @VisibleForTesting
178    protected static final String EXTRA_TO = "to";
179    private static final String EXTRA_CC = "cc";
180    private static final String EXTRA_BCC = "bcc";
181
182    /**
183     * An optional extra containing a {@link ContentValues} of values to be added to
184     * {@link SendOrSaveMessage#mValues}.
185     */
186    public static final String EXTRA_VALUES = "extra-values";
187
188    // List of all the fields
189    static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC,
190            EXTRA_QUOTED_TEXT };
191
192    private static final String LEGACY_WEAR_EXTRA = "com.google.android.wearable.extras";
193
194    /**
195     * Constant value for the threshold to use for auto-complete suggestions
196     * for the to/cc/bcc fields.
197     */
198    private static final int COMPLETION_THRESHOLD = 1;
199
200    private static SendOrSaveCallback sTestSendOrSaveCallback = null;
201    // Map containing information about requests to create new messages, and the id of the
202    // messages that were the result of those requests.
203    //
204    // This map is used when the activity that initiated the save a of a new message, is killed
205    // before the save has completed (and when we know the id of the newly created message).  When
206    // a save is completed, the service that is running in the background, will update the map
207    //
208    // When a new ComposeActivity instance is created, it will attempt to use the information in
209    // the previously instantiated map.  If ComposeActivity.onCreate() is called, with a bundle
210    // (restoring data from a previous instance), and the map hasn't been created, we will attempt
211    // to populate the map with data stored in shared preferences.
212    // FIXME: values in this map are never read.
213    private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null;
214    /**
215     * Notifies the {@code Activity} that the caller is an Email
216     * {@code Activity}, so that the back behavior may be modified accordingly.
217     *
218     * @see #onAppUpPressed
219     */
220    public static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
221
222    public static final String EXTRA_ATTACHMENTS = "attachments";
223
224    /** If set, we will clear notifications for this folder. */
225    public static final String EXTRA_NOTIFICATION_FOLDER = "extra-notification-folder";
226    public static final String EXTRA_NOTIFICATION_CONVERSATION = "extra-notification-conversation";
227
228    //  If this is a reply/forward then this extra will hold the original message
229    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message";
230    // If this is a reply/forward then this extra will hold a uri we must query
231    // to get the original message.
232    protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri";
233    // If this is an action to edit an existing draft message, this extra will hold the
234    // draft message
235    private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message";
236    private static final String END_TOKEN = ", ";
237    private static final String LOG_TAG = LogTag.getLogTag();
238    // Request numbers for activities we start
239    private static final int RESULT_PICK_ATTACHMENT = 1;
240    private static final int RESULT_CREATE_ACCOUNT = 2;
241    // TODO(mindyp) set mime-type for auto send?
242    public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND";
243
244    private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount";
245    private static final String EXTRA_REQUEST_ID = "requestId";
246    private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart";
247    private static final String EXTRA_FOCUS_SELECTION_END = "focusSelectionEnd";
248    private static final String EXTRA_MESSAGE = "extraMessage";
249    private static final int REFERENCE_MESSAGE_LOADER = 0;
250    private static final int LOADER_ACCOUNT_CURSOR = 1;
251    private static final int INIT_DRAFT_USING_REFERENCE_MESSAGE = 2;
252    private static final String EXTRA_SELECTED_ACCOUNT = "selectedAccount";
253    private static final String TAG_WAIT = "wait-fragment";
254    private static final String MIME_TYPE_ALL = "*/*";
255    private static final String MIME_TYPE_PHOTO = "image/*";
256
257    private static final String KEY_INNER_SAVED_STATE = "compose_state";
258
259    /**
260     * A single thread for running tasks in the background.
261     */
262    private final static Handler SEND_SAVE_TASK_HANDLER;
263    static {
264        HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
265        handlerThread.start();
266
267        SEND_SAVE_TASK_HANDLER = new Handler(handlerThread.getLooper());
268    }
269
270    private RecipientEditTextView mTo;
271    private RecipientEditTextView mCc;
272    private RecipientEditTextView mBcc;
273    private Button mCcBccButton;
274    private CcBccView mCcBccView;
275    private AttachmentsView mAttachmentsView;
276    protected Account mAccount;
277    protected ReplyFromAccount mReplyFromAccount;
278    private Settings mCachedSettings;
279    private Rfc822Validator mValidator;
280    private TextView mSubject;
281
282    private ComposeModeAdapter mComposeModeAdapter;
283    protected int mComposeMode = -1;
284    private boolean mForward;
285    private QuotedTextView mQuotedTextView;
286    protected EditText mBodyView;
287    private View mFromStatic;
288    private TextView mFromStaticText;
289    private View mFromSpinnerWrapper;
290    @VisibleForTesting
291    protected FromAddressSpinner mFromSpinner;
292    protected boolean mAddingAttachment;
293    private boolean mAttachmentsChanged;
294    private boolean mTextChanged;
295    private boolean mReplyFromChanged;
296    private MenuItem mSave;
297    @VisibleForTesting
298    protected Message mRefMessage;
299    private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
300    private Message mDraft;
301    private ReplyFromAccount mDraftAccount;
302    private final Object mDraftLock = new Object();
303
304    /**
305     * Boolean indicating whether ComposeActivity was launched from a Gmail controlled view.
306     */
307    private boolean mLaunchedFromEmail = false;
308    private RecipientTextWatcher mToListener;
309    private RecipientTextWatcher mCcListener;
310    private RecipientTextWatcher mBccListener;
311    private Uri mRefMessageUri;
312    private boolean mShowQuotedText = false;
313    protected Bundle mInnerSavedState;
314    private ContentValues mExtraValues = null;
315
316    // Array of the outstanding send or save tasks.  Access is synchronized
317    // with the object itself
318    /* package for testing */
319    @VisibleForTesting
320    public final ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList();
321    // FIXME: this variable is never read. related to sRequestMessageIdMap.
322    private int mRequestId;
323    private String mSignature;
324    private Account[] mAccounts;
325    private boolean mRespondedInline;
326    private boolean mPerformedSendOrDiscard = false;
327
328    private final HtmlTree.ConverterFactory mSpanConverterFactory =
329            new HtmlTree.ConverterFactory() {
330            @Override
331                public HtmlTree.Converter<Spanned> createInstance() {
332                    return getSpanConverter();
333                }
334            };
335
336    /**
337     * Can be called from a non-UI thread.
338     */
339    public static void editDraft(Context launcher, Account account, Message message) {
340        launch(launcher, account, message, EDIT_DRAFT, null, null, null, null,
341                null /* extraValues */);
342    }
343
344    /**
345     * Can be called from a non-UI thread.
346     */
347    public static void compose(Context launcher, Account account) {
348        launch(launcher, account, null, COMPOSE, null, null, null, null, null /* extraValues */);
349    }
350
351    /**
352     * Can be called from a non-UI thread.
353     */
354    public static void composeToAddress(Context launcher, Account account, String toAddress) {
355        launch(launcher, account, null, COMPOSE, toAddress, null, null, null,
356                null /* extraValues */);
357    }
358
359    /**
360     * Can be called from a non-UI thread.
361     */
362    public static void composeWithExtraValues(Context launcher, Account account,
363            String subject, final ContentValues extraValues) {
364        launch(launcher, account, null, COMPOSE, null, null, null, subject, extraValues);
365    }
366
367    /**
368     * Can be called from a non-UI thread.
369     */
370    public static Intent createReplyIntent(final Context launcher, final Account account,
371            final Uri messageUri, final boolean isReplyAll) {
372        return createActionIntent(launcher, account, messageUri, isReplyAll ? REPLY_ALL : REPLY);
373    }
374
375    /**
376     * Can be called from a non-UI thread.
377     */
378    public static Intent createForwardIntent(final Context launcher, final Account account,
379            final Uri messageUri) {
380        return createActionIntent(launcher, account, messageUri, FORWARD);
381    }
382
383    private static Intent createActionIntent(final Context context, final Account account,
384            final Uri messageUri, final int action) {
385        final Intent intent = new Intent(ACTION_LAUNCH_COMPOSE);
386        intent.setPackage(context.getPackageName());
387
388        updateActionIntent(account, messageUri, action, intent);
389
390        return intent;
391    }
392
393    @VisibleForTesting
394    static Intent updateActionIntent(Account account, Uri messageUri, int action, Intent intent) {
395        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
396        intent.putExtra(EXTRA_ACTION, action);
397        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
398        intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, messageUri);
399
400        return intent;
401    }
402
403    /**
404     * Can be called from a non-UI thread.
405     */
406    public static void reply(Context launcher, Account account, Message message) {
407        launch(launcher, account, message, REPLY, null, null, null, null, null /* extraValues */);
408    }
409
410    /**
411     * Can be called from a non-UI thread.
412     */
413    public static void replyAll(Context launcher, Account account, Message message) {
414        launch(launcher, account, message, REPLY_ALL, null, null, null, null,
415                null /* extraValues */);
416    }
417
418    /**
419     * Can be called from a non-UI thread.
420     */
421    public static void forward(Context launcher, Account account, Message message) {
422        launch(launcher, account, message, FORWARD, null, null, null, null, null /* extraValues */);
423    }
424
425    public static void reportRenderingFeedback(Context launcher, Account account, Message message,
426            String body) {
427        launch(launcher, account, message, FORWARD,
428                "android-gmail-readability@google.com", body, null, null, null /* extraValues */);
429    }
430
431    private static void launch(Context context, Account account, Message message, int action,
432            String toAddress, String body, String quotedText, String subject,
433            final ContentValues extraValues) {
434        Intent intent = new Intent(ACTION_LAUNCH_COMPOSE);
435        intent.setPackage(context.getPackageName());
436        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
437        intent.putExtra(EXTRA_ACTION, action);
438        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
439        if (action == EDIT_DRAFT) {
440            intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message);
441        } else {
442            intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message);
443        }
444        if (toAddress != null) {
445            intent.putExtra(EXTRA_TO, toAddress);
446        }
447        if (body != null) {
448            intent.putExtra(EXTRA_BODY, body);
449        }
450        if (quotedText != null) {
451            intent.putExtra(EXTRA_QUOTED_TEXT, quotedText);
452        }
453        if (subject != null) {
454            intent.putExtra(EXTRA_SUBJECT, subject);
455        }
456        if (extraValues != null) {
457            LogUtils.d(LOG_TAG, "Launching with extraValues: %s", extraValues.toString());
458            intent.putExtra(EXTRA_VALUES, extraValues);
459        }
460        if (action == COMPOSE) {
461            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
462        } else if (message != null) {
463            intent.setData(Utils.normalizeUri(message.uri));
464        }
465        context.startActivity(intent);
466    }
467
468    public static void composeMailto(Context context, Account account, Uri mailto) {
469        final Intent intent = new Intent(Intent.ACTION_VIEW, mailto);
470        intent.setPackage(context.getPackageName());
471        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
472        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
473        if (mailto != null) {
474            intent.setData(Utils.normalizeUri(mailto));
475        }
476        context.startActivity(intent);
477    }
478
479    @Override
480    protected void onCreate(Bundle savedInstanceState) {
481        super.onCreate(savedInstanceState);
482        setContentView(R.layout.compose);
483        mInnerSavedState = (savedInstanceState != null) ?
484                savedInstanceState.getBundle(KEY_INNER_SAVED_STATE) : null;
485        checkValidAccounts();
486    }
487
488    private void finishCreate() {
489        final Bundle savedState = mInnerSavedState;
490        findViews();
491        final Intent intent = getIntent();
492        final Message message;
493        final ArrayList<AttachmentPreview> previews;
494        mShowQuotedText = false;
495        final CharSequence quotedText;
496        int action;
497        // Check for any of the possibly supplied accounts.;
498        final Account account;
499        if (hadSavedInstanceStateMessage(savedState)) {
500            action = savedState.getInt(EXTRA_ACTION, COMPOSE);
501            account = savedState.getParcelable(Utils.EXTRA_ACCOUNT);
502            message = savedState.getParcelable(EXTRA_MESSAGE);
503
504            previews = savedState.getParcelableArrayList(EXTRA_ATTACHMENT_PREVIEWS);
505            mRefMessage = savedState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE);
506            quotedText = savedState.getCharSequence(EXTRA_QUOTED_TEXT);
507
508            mExtraValues = savedState.getParcelable(EXTRA_VALUES);
509        } else {
510            account = obtainAccount(intent);
511            action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
512            // Initialize the message from the message in the intent
513            message = intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE);
514            previews = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_PREVIEWS);
515            mRefMessage = intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
516            mRefMessageUri = intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI);
517            quotedText = null;
518
519            if (Analytics.isLoggable()) {
520                if (intent.getBooleanExtra(Utils.EXTRA_FROM_NOTIFICATION, false)) {
521                    Analytics.getInstance().sendEvent(
522                            "notification_action", "compose", getActionString(action), 0);
523                }
524            }
525        }
526        mAttachmentsView.setAttachmentPreviews(previews);
527
528        setAccount(account);
529        if (mAccount == null) {
530            return;
531        }
532
533        initRecipients();
534
535        // Clear the notification and mark the conversation as seen, if necessary
536        final Folder notificationFolder =
537                intent.getParcelableExtra(EXTRA_NOTIFICATION_FOLDER);
538
539        if (notificationFolder != null) {
540            final Uri conversationUri = intent.getParcelableExtra(EXTRA_NOTIFICATION_CONVERSATION);
541            Intent actionIntent;
542            if (conversationUri != null) {
543                actionIntent = new Intent(MailIntentService.ACTION_RESEND_NOTIFICATIONS_WEAR);
544                actionIntent.putExtra(Utils.EXTRA_CONVERSATION, conversationUri);
545            } else {
546                actionIntent = new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
547                actionIntent.setData(Utils.appendVersionQueryParameter(this,
548                        notificationFolder.folderUri.fullUri));
549            }
550            actionIntent.setPackage(getPackageName());
551            actionIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
552            actionIntent.putExtra(Utils.EXTRA_FOLDER, notificationFolder);
553
554            startService(actionIntent);
555        }
556
557        if (intent.getBooleanExtra(EXTRA_FROM_EMAIL_TASK, false)) {
558            mLaunchedFromEmail = true;
559        } else if (Intent.ACTION_SEND.equals(intent.getAction())) {
560            final Uri dataUri = intent.getData();
561            if (dataUri != null) {
562                final String dataScheme = intent.getData().getScheme();
563                final String accountScheme = mAccount.composeIntentUri.getScheme();
564                mLaunchedFromEmail = TextUtils.equals(dataScheme, accountScheme);
565            }
566        }
567
568        if (mRefMessageUri != null) {
569            mShowQuotedText = true;
570            mComposeMode = action;
571
572            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
573                Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
574                String wearReply = null;
575                if (remoteInput != null) {
576                    LogUtils.d(LOG_TAG, "Got remote input from new api");
577                    CharSequence input = remoteInput.getCharSequence(
578                            NotificationActionUtils.WEAR_REPLY_INPUT);
579                    if (input != null) {
580                        wearReply = input.toString();
581                    }
582                } else {
583                    // TODO: remove after legacy code has been removed.
584                    LogUtils.d(LOG_TAG,
585                            "No remote input from new api, falling back to compatibility mode");
586                    ClipData clipData = intent.getClipData();
587                    if (clipData != null
588                            && LEGACY_WEAR_EXTRA.equals(clipData.getDescription().getLabel())) {
589                        Bundle extras = clipData.getItemAt(0).getIntent().getExtras();
590                        if (extras != null) {
591                            wearReply = extras.getString(NotificationActionUtils.WEAR_REPLY_INPUT);
592                        }
593                    }
594                }
595
596                if (!TextUtils.isEmpty(wearReply)) {
597                    createWearReplyTask(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION,
598                            mComposeMode, wearReply).execute();
599                    finish();
600                    return;
601                } else {
602                    LogUtils.w(LOG_TAG, "remote input string is null");
603                }
604            }
605
606            getLoaderManager().initLoader(INIT_DRAFT_USING_REFERENCE_MESSAGE, null, this);
607            return;
608        } else if (message != null && action != EDIT_DRAFT) {
609            initFromDraftMessage(message);
610            initQuotedTextFromRefMessage(mRefMessage, action);
611            mShowQuotedText = message.appendRefMessageContent;
612            // if we should be showing quoted text but mRefMessage is null
613            // and we have some quotedText, display that
614            if (mShowQuotedText && mRefMessage == null) {
615                if (quotedText != null) {
616                    initQuotedText(quotedText, false /* shouldQuoteText */);
617                } else if (mExtraValues != null) {
618                    initExtraValues(mExtraValues);
619                    return;
620                }
621            }
622        } else if (action == EDIT_DRAFT) {
623            if (message == null) {
624                throw new IllegalStateException("Message must not be null to edit draft");
625            }
626            initFromDraftMessage(message);
627            // Update the action to the draft type of the previous draft
628            switch (message.draftType) {
629                case UIProvider.DraftType.REPLY:
630                    action = REPLY;
631                    break;
632                case UIProvider.DraftType.REPLY_ALL:
633                    action = REPLY_ALL;
634                    break;
635                case UIProvider.DraftType.FORWARD:
636                    action = FORWARD;
637                    break;
638                case UIProvider.DraftType.COMPOSE:
639                default:
640                    action = COMPOSE;
641                    break;
642            }
643            LogUtils.d(LOG_TAG, "Previous draft had action type: %d", action);
644
645            mShowQuotedText = message.appendRefMessageContent;
646            if (message.refMessageUri != null) {
647                // If we're editing an existing draft that was in reference to an existing message,
648                // still need to load that original message since we might need to refer to the
649                // original sender and recipients if user switches "reply <-> reply-all".
650                mRefMessageUri = message.refMessageUri;
651                mComposeMode = action;
652                getLoaderManager().initLoader(REFERENCE_MESSAGE_LOADER, null, this);
653                return;
654            }
655        } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
656            if (mRefMessage != null) {
657                initFromRefMessage(action);
658                mShowQuotedText = true;
659            }
660        } else {
661            if (initFromExtras(intent)) {
662                return;
663            }
664        }
665
666        mComposeMode = action;
667        finishSetup(action, intent, savedState);
668    }
669
670    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
671    private static AsyncTask<Void, Void, Message> createWearReplyTask(
672            final ComposeActivity composeActivity,
673            final Uri refMessageUri, final String[] projection, final int action,
674            final String wearReply) {
675        return new AsyncTask<Void, Void, Message>() {
676            private Intent mEmptyServiceIntent = new Intent(composeActivity, EmptyService.class);
677
678            @Override
679            protected void onPreExecute() {
680                // Start service so we won't be killed if this app is put in the background.
681                composeActivity.startService(mEmptyServiceIntent);
682            }
683
684            @Override
685            protected Message doInBackground(Void... params) {
686                Cursor cursor = composeActivity.getContentResolver()
687                        .query(refMessageUri, projection, null, null, null, null);
688                if (cursor != null) {
689                    try {
690                        cursor.moveToFirst();
691                        return new Message(cursor);
692                    } finally {
693                        cursor.close();
694                    }
695                }
696                return null;
697            }
698
699            @Override
700            protected void onPostExecute(Message message) {
701                composeActivity.stopService(mEmptyServiceIntent);
702
703                composeActivity.mRefMessage = message;
704                composeActivity.initFromRefMessage(action);
705                composeActivity.setBody(wearReply, false);
706                composeActivity.finishSetup(action, composeActivity.getIntent(), null);
707                composeActivity.sendOrSaveWithSanityChecks(false /* save */, true /* show  toast */,
708                        false /* orientationChanged */, true /* autoSend */);
709            }
710        };
711    }
712
713    private void checkValidAccounts() {
714        final Account[] allAccounts = AccountUtils.getAccounts(this);
715        if (allAccounts == null || allAccounts.length == 0) {
716            final Intent noAccountIntent = MailAppProvider.getNoAccountIntent(this);
717            if (noAccountIntent != null) {
718                mAccounts = null;
719                startActivityForResult(noAccountIntent, RESULT_CREATE_ACCOUNT);
720            }
721        } else {
722            // If none of the accounts are syncing, setup a watcher.
723            boolean anySyncing = false;
724            for (Account a : allAccounts) {
725                if (a.isAccountReady()) {
726                    anySyncing = true;
727                    break;
728                }
729            }
730            if (!anySyncing) {
731                // There are accounts, but none are sync'd, which is just like having no accounts.
732                mAccounts = null;
733                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
734                return;
735            }
736            mAccounts = AccountUtils.getSyncingAccounts(this);
737            finishCreate();
738        }
739    }
740
741    private Account obtainAccount(Intent intent) {
742        Account account = null;
743        Object accountExtra = null;
744        if (intent != null && intent.getExtras() != null) {
745            accountExtra = intent.getExtras().get(Utils.EXTRA_ACCOUNT);
746            if (accountExtra instanceof Account) {
747                return (Account) accountExtra;
748            } else if (accountExtra instanceof String) {
749                // This is the Account attached to the widget compose intent.
750                account = Account.newInstance((String) accountExtra);
751                if (account != null) {
752                    return account;
753                }
754            }
755            accountExtra = intent.hasExtra(Utils.EXTRA_ACCOUNT) ?
756                    intent.getStringExtra(Utils.EXTRA_ACCOUNT) :
757                        intent.getStringExtra(EXTRA_SELECTED_ACCOUNT);
758        }
759
760        MailAppProvider provider = MailAppProvider.getInstance();
761        String lastAccountUri = provider.getLastSentFromAccount();
762        if (TextUtils.isEmpty(lastAccountUri)) {
763            lastAccountUri = provider.getLastViewedAccount();
764        }
765        if (!TextUtils.isEmpty(lastAccountUri)) {
766            accountExtra = Uri.parse(lastAccountUri);
767        }
768
769        if (mAccounts != null && mAccounts.length > 0) {
770            if (accountExtra instanceof String && !TextUtils.isEmpty((String) accountExtra)) {
771                // For backwards compatibility, we need to check account
772                // names.
773                for (Account a : mAccounts) {
774                    if (a.getEmailAddress().equals(accountExtra)) {
775                        account = a;
776                    }
777                }
778            } else if (accountExtra instanceof Uri) {
779                // The uri of the last viewed account is what is stored in
780                // the current code base.
781                for (Account a : mAccounts) {
782                    if (a.uri.equals(accountExtra)) {
783                        account = a;
784                    }
785                }
786            }
787            if (account == null) {
788                account = mAccounts[0];
789            }
790        }
791        return account;
792    }
793
794    protected void finishSetup(int action, Intent intent, Bundle savedInstanceState) {
795        setFocus(action);
796        // Don't bother with the intent if we have procured a message from the
797        // intent already.
798        if (!hadSavedInstanceStateMessage(savedInstanceState)) {
799            initAttachmentsFromIntent(intent);
800        }
801        initActionBar();
802        initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(),
803                action);
804
805        // If this is a draft message, the draft account is whatever account was
806        // used to open the draft message in Compose.
807        if (mDraft != null) {
808            mDraftAccount = mReplyFromAccount;
809        }
810
811        initChangeListeners();
812
813        // These two should be identical since we check CC and BCC the same way
814        boolean showCc = !TextUtils.isEmpty(mCc.getText()) || (savedInstanceState != null &&
815                savedInstanceState.getBoolean(EXTRA_SHOW_CC));
816        boolean showBcc = !TextUtils.isEmpty(mBcc.getText()) || (savedInstanceState != null &&
817                savedInstanceState.getBoolean(EXTRA_SHOW_BCC));
818        mCcBccView.show(false /* animate */, showCc, showBcc);
819        updateHideOrShowCcBcc();
820        updateHideOrShowQuotedText(mShowQuotedText);
821
822        mRespondedInline = mInnerSavedState != null &&
823                mInnerSavedState.getBoolean(EXTRA_RESPONDED_INLINE);
824        if (mRespondedInline) {
825            mQuotedTextView.setVisibility(View.GONE);
826        }
827    }
828
829    private static boolean hadSavedInstanceStateMessage(final Bundle savedInstanceState) {
830        return savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE);
831    }
832
833    private void updateHideOrShowQuotedText(boolean showQuotedText) {
834        mQuotedTextView.updateCheckedState(showQuotedText);
835        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
836    }
837
838    private void setFocus(int action) {
839        if (action == EDIT_DRAFT) {
840            int type = mDraft.draftType;
841            switch (type) {
842                case UIProvider.DraftType.COMPOSE:
843                case UIProvider.DraftType.FORWARD:
844                    action = COMPOSE;
845                    break;
846                case UIProvider.DraftType.REPLY:
847                case UIProvider.DraftType.REPLY_ALL:
848                default:
849                    action = REPLY;
850                    break;
851            }
852        }
853        switch (action) {
854            case FORWARD:
855            case COMPOSE:
856                if (TextUtils.isEmpty(mTo.getText())) {
857                    mTo.requestFocus();
858                    break;
859                }
860                //$FALL-THROUGH$
861            case REPLY:
862            case REPLY_ALL:
863            default:
864                focusBody();
865                break;
866        }
867    }
868
869    /**
870     * Focus the body of the message.
871     */
872    private void focusBody() {
873        mBodyView.requestFocus();
874        resetBodySelection();
875    }
876
877    private void resetBodySelection() {
878        int length = mBodyView.getText().length();
879        int signatureStartPos = getSignatureStartPosition(
880                mSignature, mBodyView.getText().toString());
881        if (signatureStartPos > -1) {
882            // In case the user deleted the newlines...
883            mBodyView.setSelection(signatureStartPos);
884        } else if (length >= 0) {
885            // Move cursor to the end.
886            mBodyView.setSelection(length);
887        }
888    }
889
890    @Override
891    protected void onStart() {
892        super.onStart();
893
894        Analytics.getInstance().activityStart(this);
895    }
896
897    @Override
898    protected void onStop() {
899        super.onStop();
900
901        Analytics.getInstance().activityStop(this);
902    }
903
904    @Override
905    protected void onResume() {
906        super.onResume();
907        // Update the from spinner as other accounts
908        // may now be available.
909        if (mFromSpinner != null && mAccount != null) {
910            mFromSpinner.initialize(mComposeMode, mAccount, mAccounts, mRefMessage);
911        }
912    }
913
914    @Override
915    protected void onPause() {
916        super.onPause();
917
918        // When the user exits the compose view, see if this draft needs saving.
919        // Don't save unnecessary drafts if we are only changing the orientation.
920        if (!isChangingConfigurations()) {
921            saveIfNeeded();
922
923            if (isFinishing() && !mPerformedSendOrDiscard && !isBlank()) {
924                // log saving upon backing out of activity. (we avoid logging every sendOrSave()
925                // because that method can be invoked many times in a single compose session.)
926                logSendOrSave(true /* save */);
927            }
928        }
929    }
930
931    @Override
932    protected void onActivityResult(int request, int result, Intent data) {
933        if (request == RESULT_PICK_ATTACHMENT) {
934            mAddingAttachment = false;
935            if (result == RESULT_OK) {
936                addAttachmentAndUpdateView(data);
937            }
938        } else if (request == RESULT_CREATE_ACCOUNT) {
939            // We were waiting for the user to create an account
940            if (result != RESULT_OK) {
941                finish();
942            } else {
943                // Watch for accounts to show up!
944                // restart the loader to get the updated list of accounts
945                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
946                showWaitFragment(null);
947            }
948        }
949    }
950
951    @Override
952    protected final void onRestoreInstanceState(Bundle savedInstanceState) {
953        final boolean hasAccounts = mAccounts != null && mAccounts.length > 0;
954        if (hasAccounts) {
955            clearChangeListeners();
956        }
957        super.onRestoreInstanceState(savedInstanceState);
958        if (mInnerSavedState != null) {
959            if (mInnerSavedState.containsKey(EXTRA_FOCUS_SELECTION_START)) {
960                int selectionStart = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_START);
961                int selectionEnd = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_END);
962                // There should be a focus and it should be an EditText since we
963                // only save these extras if these conditions are true.
964                EditText focusEditText = (EditText) getCurrentFocus();
965                final int length = focusEditText.getText().length();
966                if (selectionStart < length && selectionEnd < length) {
967                    focusEditText.setSelection(selectionStart, selectionEnd);
968                }
969            }
970        }
971        if (hasAccounts) {
972            initChangeListeners();
973        }
974    }
975
976    @Override
977    protected void onSaveInstanceState(Bundle state) {
978        super.onSaveInstanceState(state);
979        final Bundle inner = new Bundle();
980        saveState(inner);
981        state.putBundle(KEY_INNER_SAVED_STATE, inner);
982    }
983
984    private void saveState(Bundle state) {
985        // We have no accounts so there is nothing to compose, and therefore, nothing to save.
986        if (mAccounts == null || mAccounts.length == 0) {
987            return;
988        }
989        // The framework is happy to save and restore the selection but only if it also saves and
990        // restores the contents of the edit text. That's a lot of text to put in a bundle so we do
991        // this manually.
992        View focus = getCurrentFocus();
993        if (focus != null && focus instanceof EditText) {
994            EditText focusEditText = (EditText) focus;
995            state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart());
996            state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd());
997        }
998
999        final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
1000        final int selectedPos = mFromSpinner.getSelectedItemPosition();
1001        final ReplyFromAccount selectedReplyFromAccount = (replyFromAccounts != null
1002                && replyFromAccounts.size() > 0 && replyFromAccounts.size() > selectedPos) ?
1003                        replyFromAccounts.get(selectedPos) : null;
1004        if (selectedReplyFromAccount != null) {
1005            state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize()
1006                    .toString());
1007            state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account);
1008        } else {
1009            state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount);
1010        }
1011
1012        if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId !=0) {
1013            // We don't have a draft id, and we have a request id,
1014            // save the request id.
1015            state.putInt(EXTRA_REQUEST_ID, mRequestId);
1016        }
1017
1018        // We want to restore the current mode after a pause
1019        // or rotation.
1020        int mode = getMode();
1021        state.putInt(EXTRA_ACTION, mode);
1022
1023        final Message message = createMessage(selectedReplyFromAccount, mRefMessage, mode,
1024                removeComposingSpans(mBodyView.getText()));
1025        if (mDraft != null) {
1026            message.id = mDraft.id;
1027            message.serverId = mDraft.serverId;
1028            message.uri = mDraft.uri;
1029        }
1030        state.putParcelable(EXTRA_MESSAGE, message);
1031
1032        if (mRefMessage != null) {
1033            state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage);
1034        } else if (message.appendRefMessageContent) {
1035            // If we have no ref message but should be appending
1036            // ref message content, we have orphaned quoted text. Save it.
1037            state.putCharSequence(EXTRA_QUOTED_TEXT, mQuotedTextView.getQuotedTextIfIncluded());
1038        }
1039        state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible());
1040        state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible());
1041        state.putBoolean(EXTRA_RESPONDED_INLINE, mRespondedInline);
1042        state.putBoolean(EXTRA_SAVE_ENABLED, mSave != null && mSave.isEnabled());
1043        state.putParcelableArrayList(
1044                EXTRA_ATTACHMENT_PREVIEWS, mAttachmentsView.getAttachmentPreviews());
1045
1046        state.putParcelable(EXTRA_VALUES, mExtraValues);
1047    }
1048
1049    private int getMode() {
1050        int mode = ComposeActivity.COMPOSE;
1051        ActionBar actionBar = getActionBar();
1052        if (actionBar != null
1053                && actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) {
1054            mode = actionBar.getSelectedNavigationIndex();
1055        }
1056        return mode;
1057    }
1058
1059    /**
1060     * This function might be called from a background thread, so be sure to move everything that
1061     * can potentially modify the UI to the main thread (e.g. removeComposingSpans for body).
1062     */
1063    private Message createMessage(ReplyFromAccount selectedReplyFromAccount, Message refMessage,
1064            int mode, Spanned body) {
1065        Message message = new Message();
1066        message.id = UIProvider.INVALID_MESSAGE_ID;
1067        message.serverId = null;
1068        message.uri = null;
1069        message.conversationUri = null;
1070        message.subject = mSubject.getText().toString();
1071        message.snippet = null;
1072        message.setTo(formatSenders(mTo.getText().toString()));
1073        message.setCc(formatSenders(mCc.getText().toString()));
1074        message.setBcc(formatSenders(mBcc.getText().toString()));
1075        message.setReplyTo(null);
1076        message.dateReceivedMs = 0;
1077        message.bodyHtml = spannedBodyToHtml(body, true);
1078        message.bodyText = mBodyView.getText().toString();
1079        message.embedsExternalResources = false;
1080        message.refMessageUri = mRefMessage != null ? mRefMessage.uri : null;
1081        message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null;
1082        ArrayList<Attachment> attachments = mAttachmentsView.getAttachments();
1083        message.hasAttachments = attachments != null && attachments.size() > 0;
1084        message.attachmentListUri = null;
1085        message.messageFlags = 0;
1086        message.alwaysShowImages = false;
1087        message.attachmentsJson = Attachment.toJSONArray(attachments);
1088        CharSequence quotedText = mQuotedTextView.getQuotedText();
1089        message.quotedTextOffset = -1; // Just a default value.
1090        if (refMessage != null && !TextUtils.isEmpty(quotedText)) {
1091            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
1092                // We want the index to point to just the quoted text and not the
1093                // "On December 25, 2014..." part of it.
1094                message.quotedTextOffset =
1095                        QuotedTextView.getQuotedTextOffset(quotedText.toString());
1096            } else if (!TextUtils.isEmpty(refMessage.bodyText)) {
1097                // We want to point to the entire quoted text.
1098                message.quotedTextOffset = QuotedTextView.findQuotedTextIndex(quotedText);
1099            }
1100        }
1101        message.accountUri = null;
1102        final String email = selectedReplyFromAccount != null ? selectedReplyFromAccount.address
1103                : mAccount != null ? mAccount.getEmailAddress() : null;
1104        final String senderName = selectedReplyFromAccount != null ? selectedReplyFromAccount.name
1105                : mAccount != null ? mAccount.getSenderName() : null;
1106        final Address address = new Address(email, senderName);
1107        message.setFrom(address.toHeader());
1108        message.draftType = getDraftType(mode);
1109        return message;
1110    }
1111
1112    private static String formatSenders(final String string) {
1113        if (!TextUtils.isEmpty(string) && string.charAt(string.length() - 1) == ',') {
1114            return string.substring(0, string.length() - 1);
1115        }
1116        return string;
1117    }
1118
1119    @VisibleForTesting
1120    protected void setAccount(Account account) {
1121        if (account == null) {
1122            return;
1123        }
1124        if (!account.equals(mAccount)) {
1125            mAccount = account;
1126            mCachedSettings = mAccount.settings;
1127            appendSignature();
1128        }
1129        if (mAccount != null) {
1130            MailActivity.setNfcMessage(mAccount.getEmailAddress());
1131        }
1132    }
1133
1134    private void initFromSpinner(Bundle bundle, int action) {
1135        if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) {
1136            action = COMPOSE;
1137        }
1138        mFromSpinner.initialize(action, mAccount, mAccounts, mRefMessage);
1139
1140        if (bundle != null) {
1141            if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) {
1142                mReplyFromAccount = ReplyFromAccount.deserialize(mAccount,
1143                        bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT));
1144            } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) {
1145                final String accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING);
1146                mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString);
1147            }
1148        }
1149        if (mReplyFromAccount == null) {
1150            if (mDraft != null) {
1151                mReplyFromAccount = getReplyFromAccountFromDraft(mAccount, mDraft);
1152            } else if (mRefMessage != null) {
1153                mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage);
1154            }
1155        }
1156        if (mReplyFromAccount == null) {
1157            mReplyFromAccount = getDefaultReplyFromAccount(mAccount);
1158        }
1159
1160        mFromSpinner.setCurrentAccount(mReplyFromAccount);
1161
1162        if (mFromSpinner.getCount() > 1) {
1163            // If there is only 1 account, just show that account.
1164            // Otherwise, give the user the ability to choose which account to
1165            // send mail from / save drafts to.
1166            mFromStatic.setVisibility(View.GONE);
1167            mFromStaticText.setText(mReplyFromAccount.address);
1168            mFromSpinnerWrapper.setVisibility(View.VISIBLE);
1169        } else {
1170            mFromStatic.setVisibility(View.VISIBLE);
1171            mFromStaticText.setText(mReplyFromAccount.address);
1172            mFromSpinnerWrapper.setVisibility(View.GONE);
1173        }
1174    }
1175
1176    private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) {
1177        if (refMessage.accountUri != null) {
1178            // This must be from combined inbox.
1179            List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
1180            for (ReplyFromAccount from : replyFromAccounts) {
1181                if (from.account.uri.equals(refMessage.accountUri)) {
1182                    return from;
1183                }
1184            }
1185            return null;
1186        } else {
1187            return getReplyFromAccount(account, refMessage);
1188        }
1189    }
1190
1191    /**
1192     * Given an account and the message we're replying to,
1193     * return who the message should be sent from.
1194     * @param account Account in which the message arrived.
1195     * @param refMessage Message to analyze for account selection
1196     * @return the address from which to reply.
1197     */
1198    public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) {
1199        // First see if we are supposed to use the default address or
1200        // the address it was sentTo.
1201        if (mCachedSettings.forceReplyFromDefault) {
1202            return getDefaultReplyFromAccount(account);
1203        } else {
1204            // If we aren't explicitly told which account to look for, look at
1205            // all the message recipients and find one that matches
1206            // a custom from or account.
1207            List<String> allRecipients = new ArrayList<String>();
1208            allRecipients.addAll(Arrays.asList(refMessage.getToAddressesUnescaped()));
1209            allRecipients.addAll(Arrays.asList(refMessage.getCcAddressesUnescaped()));
1210            return getMatchingRecipient(account, allRecipients);
1211        }
1212    }
1213
1214    /**
1215     * Compare all the recipients of an email to the current account and all
1216     * custom addresses associated with that account. Return the match if there
1217     * is one, or the default account if there isn't.
1218     */
1219    protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) {
1220        // Tokenize the list and place in a hashmap.
1221        ReplyFromAccount matchingReplyFrom = null;
1222        Rfc822Token[] tokens;
1223        HashSet<String> recipientsMap = new HashSet<String>();
1224        for (String address : sentTo) {
1225            tokens = Rfc822Tokenizer.tokenize(address);
1226            for (final Rfc822Token token : tokens) {
1227                recipientsMap.add(token.getAddress());
1228            }
1229        }
1230
1231        int matchingAddressCount = 0;
1232        List<ReplyFromAccount> customFroms;
1233        customFroms = account.getReplyFroms();
1234        if (customFroms != null) {
1235            for (ReplyFromAccount entry : customFroms) {
1236                if (recipientsMap.contains(entry.address)) {
1237                    matchingReplyFrom = entry;
1238                    matchingAddressCount++;
1239                }
1240            }
1241        }
1242        if (matchingAddressCount > 1) {
1243            matchingReplyFrom = getDefaultReplyFromAccount(account);
1244        }
1245        return matchingReplyFrom;
1246    }
1247
1248    private static ReplyFromAccount getDefaultReplyFromAccount(final Account account) {
1249        for (final ReplyFromAccount from : account.getReplyFroms()) {
1250            if (from.isDefault) {
1251                return from;
1252            }
1253        }
1254        return new ReplyFromAccount(account, account.uri, account.getEmailAddress(),
1255                account.getSenderName(), account.getEmailAddress(), true, false);
1256    }
1257
1258    private ReplyFromAccount getReplyFromAccountFromDraft(final Account account,
1259            final Message msg) {
1260        final Address[] draftFroms = Address.parse(msg.getFrom());
1261        final String sender = draftFroms.length > 0 ? draftFroms[0].getAddress() : "";
1262        ReplyFromAccount replyFromAccount = null;
1263        List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
1264        if (TextUtils.equals(account.getEmailAddress(), sender)) {
1265            replyFromAccount = getDefaultReplyFromAccount(account);
1266        } else {
1267            for (ReplyFromAccount fromAccount : replyFromAccounts) {
1268                if (TextUtils.equals(fromAccount.address, sender)) {
1269                    replyFromAccount = fromAccount;
1270                    break;
1271                }
1272            }
1273        }
1274        return replyFromAccount;
1275    }
1276
1277    private void findViews() {
1278        findViewById(R.id.compose).setVisibility(View.VISIBLE);
1279        mCcBccButton = (Button) findViewById(R.id.add_cc_bcc);
1280        if (mCcBccButton != null) {
1281            mCcBccButton.setOnClickListener(this);
1282        }
1283        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
1284        mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments);
1285        final View addAttachmentsButton = findViewById(R.id.add_attachment);
1286        if (addAttachmentsButton != null) {
1287            addAttachmentsButton.setOnClickListener(this);
1288        }
1289        mTo = (RecipientEditTextView) findViewById(R.id.to);
1290        initializeRecipientEditTextView(mTo);
1291        mCc = (RecipientEditTextView) findViewById(R.id.cc);
1292        initializeRecipientEditTextView(mCc);
1293        mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
1294        initializeRecipientEditTextView(mBcc);
1295        // TODO: add special chips text change watchers before adding
1296        // this as a text changed watcher to the to, cc, bcc fields.
1297        mSubject = (TextView) findViewById(R.id.subject);
1298        mSubject.setOnEditorActionListener(this);
1299        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
1300        mQuotedTextView.setRespondInlineListener(this);
1301        mBodyView = (EditText) findViewById(R.id.body);
1302        mFromStatic = findViewById(R.id.static_from_content);
1303        mFromStaticText = (TextView) findViewById(R.id.from_account_name);
1304        mFromSpinnerWrapper = findViewById(R.id.spinner_from_content);
1305        mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker);
1306    }
1307
1308    private void initializeRecipientEditTextView(RecipientEditTextView view) {
1309        view.setTokenizer(new Rfc822Tokenizer());
1310        view.setThreshold(COMPLETION_THRESHOLD);
1311    }
1312
1313    @Override
1314    public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
1315        if (action == EditorInfo.IME_ACTION_DONE) {
1316            focusBody();
1317            return true;
1318        }
1319        return false;
1320    }
1321
1322    /**
1323     * Convert the body text (in {@link Spanned} form) to ready-to-send HTML format as a plain
1324     * String.
1325     *
1326     * @param body the body text including fancy style spans
1327     * @param removedComposing whether the function already removed composingSpans. Necessary
1328     *   because we cannot call removeComposingSpans from a background thread.
1329     * @return HTML formatted body that's suitable for sending or saving
1330     */
1331    private String spannedBodyToHtml(Spanned body, boolean removedComposing) {
1332        if (!removedComposing) {
1333            body = removeComposingSpans(body);
1334        }
1335        final HtmlifyBeginResult r = onHtmlifyBegin(body);
1336        return onHtmlifyEnd(Html.toHtml(r.result), r.extras);
1337    }
1338
1339    /**
1340     * A hook for subclasses to convert custom spans in the body text prior to system HTML
1341     * conversion. That HTML conversion is lossy, so anything above and beyond its capability
1342     * has to be handled here.
1343     *
1344     * @param body
1345     * @return a copy of the body text with custom spans replaced with HTML
1346     */
1347    protected HtmlifyBeginResult onHtmlifyBegin(Spanned body) {
1348        return new HtmlifyBeginResult(body, null /* extras */);
1349    }
1350
1351    protected String onHtmlifyEnd(String html, Object extras) {
1352        return html;
1353    }
1354
1355    protected TextView getBody() {
1356        return mBodyView;
1357    }
1358
1359    @VisibleForTesting
1360    public String getBodyHtml() {
1361        return spannedBodyToHtml(mBodyView.getText(), false);
1362    }
1363
1364    @VisibleForTesting
1365    public Account getFromAccount() {
1366        return mReplyFromAccount != null && mReplyFromAccount.account != null ?
1367                mReplyFromAccount.account : mAccount;
1368    }
1369
1370    private void clearChangeListeners() {
1371        mSubject.removeTextChangedListener(this);
1372        mBodyView.removeTextChangedListener(this);
1373        mTo.removeTextChangedListener(mToListener);
1374        mCc.removeTextChangedListener(mCcListener);
1375        mBcc.removeTextChangedListener(mBccListener);
1376        mFromSpinner.setOnAccountChangedListener(null);
1377        mAttachmentsView.setAttachmentChangesListener(null);
1378    }
1379
1380    // Now that the message has been initialized from any existing draft or
1381    // ref message data, set up listeners for any changes that occur to the
1382    // message.
1383    private void initChangeListeners() {
1384        // Make sure we only add text changed listeners once!
1385        clearChangeListeners();
1386        mSubject.addTextChangedListener(this);
1387        mBodyView.addTextChangedListener(this);
1388        if (mToListener == null) {
1389            mToListener = new RecipientTextWatcher(mTo, this);
1390        }
1391        mTo.addTextChangedListener(mToListener);
1392        if (mCcListener == null) {
1393            mCcListener = new RecipientTextWatcher(mCc, this);
1394        }
1395        mCc.addTextChangedListener(mCcListener);
1396        if (mBccListener == null) {
1397            mBccListener = new RecipientTextWatcher(mBcc, this);
1398        }
1399        mBcc.addTextChangedListener(mBccListener);
1400        mFromSpinner.setOnAccountChangedListener(this);
1401        mAttachmentsView.setAttachmentChangesListener(this);
1402    }
1403
1404    private void initActionBar() {
1405        LogUtils.d(LOG_TAG, "initializing action bar in ComposeActivity");
1406        ActionBar actionBar = getActionBar();
1407        if (actionBar == null) {
1408            return;
1409        }
1410        if (mComposeMode == ComposeActivity.COMPOSE) {
1411            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
1412            actionBar.setTitle(R.string.compose);
1413        } else {
1414            actionBar.setTitle(null);
1415            if (mComposeModeAdapter == null) {
1416                mComposeModeAdapter = new ComposeModeAdapter(this);
1417            }
1418            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
1419            actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
1420            switch (mComposeMode) {
1421                case ComposeActivity.REPLY:
1422                    actionBar.setSelectedNavigationItem(0);
1423                    break;
1424                case ComposeActivity.REPLY_ALL:
1425                    actionBar.setSelectedNavigationItem(1);
1426                    break;
1427                case ComposeActivity.FORWARD:
1428                    actionBar.setSelectedNavigationItem(2);
1429                    break;
1430            }
1431        }
1432        actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME,
1433                ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME);
1434        actionBar.setHomeButtonEnabled(true);
1435    }
1436
1437    private void initFromRefMessage(int action) {
1438        setFieldsFromRefMessage(action);
1439
1440        // Check if To: address and email body needs to be prefilled based on extras.
1441        // This is used for reporting rendering feedback.
1442        if (MessageHeaderView.ENABLE_REPORT_RENDERING_PROBLEM) {
1443            Intent intent = getIntent();
1444            if (intent.getExtras() != null) {
1445                String toAddresses = intent.getStringExtra(EXTRA_TO);
1446                if (toAddresses != null) {
1447                    addToAddresses(Arrays.asList(TextUtils.split(toAddresses, ",")));
1448                }
1449                String body = intent.getStringExtra(EXTRA_BODY);
1450                if (body != null) {
1451                    setBody(body, false /* withSignature */);
1452                }
1453            }
1454        }
1455    }
1456
1457    private void setFieldsFromRefMessage(int action) {
1458        setSubject(mRefMessage, action);
1459        // Setup recipients
1460        if (action == FORWARD) {
1461            mForward = true;
1462        }
1463        initRecipientsFromRefMessage(mRefMessage, action);
1464        initQuotedTextFromRefMessage(mRefMessage, action);
1465        if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
1466            initAttachments(mRefMessage);
1467        }
1468    }
1469
1470    protected HtmlTree.Converter<Spanned> getSpanConverter() {
1471        return new HtmlUtils.SpannedConverter();
1472    }
1473
1474    private void initFromDraftMessage(Message message) {
1475        LogUtils.d(LOG_TAG, "Intializing draft from previous draft message: %s", message);
1476
1477        mDraft = message;
1478        mDraftId = message.id;
1479        mSubject.setText(message.subject);
1480        mForward = message.draftType == UIProvider.DraftType.FORWARD;
1481        final List<String> toAddresses = Arrays.asList(message.getToAddressesUnescaped());
1482        addToAddresses(toAddresses);
1483        addCcAddresses(Arrays.asList(message.getCcAddressesUnescaped()), toAddresses);
1484        addBccAddresses(Arrays.asList(message.getBccAddressesUnescaped()));
1485        if (message.hasAttachments) {
1486            List<Attachment> attachments = message.getAttachments();
1487            for (Attachment a : attachments) {
1488                addAttachmentAndUpdateView(a);
1489            }
1490        }
1491        int quotedTextIndex = message.appendRefMessageContent ? message.quotedTextOffset : -1;
1492        // Set the body
1493        CharSequence quotedText = null;
1494        if (!TextUtils.isEmpty(message.bodyHtml)) {
1495            if (quotedTextIndex > -1) {
1496                // Find the offset in the html text of the actual quoted text and strip it out.
1497                // Note that the actual quotedTextOffset in the message has not changed as
1498                // this different offset is used only for display purposes. They point to different
1499                // parts of the original message.  Please see the comments in QuoteTextView
1500                // to see the differences.
1501                quotedTextIndex = QuotedTextView.findQuotedTextIndex(message.bodyHtml);
1502                if (quotedTextIndex > -1) {
1503                    new HtmlToSpannedTask().execute(message.bodyHtml.substring(0, quotedTextIndex));
1504                    quotedText = message.bodyHtml.subSequence(quotedTextIndex,
1505                            message.bodyHtml.length());
1506                }
1507            } else {
1508                new HtmlToSpannedTask().execute(message.bodyHtml);
1509            }
1510        } else {
1511            final String body = message.bodyText;
1512            final CharSequence bodyText;
1513            if (TextUtils.isEmpty(body)) {
1514                bodyText = "";
1515                quotedText = null;
1516            } else {
1517                if (quotedTextIndex > body.length()) {
1518                    // Sanity check to guarantee that we will not over index the String.
1519                    // If this happens there is a bigger problem. This should never happen hence
1520                    // the wtf logging.
1521                    quotedTextIndex = -1;
1522                    LogUtils.wtf(LOG_TAG, "quotedTextIndex (%d) > body.length() (%d)",
1523                            quotedTextIndex, body.length());
1524                }
1525                bodyText = quotedTextIndex > -1 ? body.substring(0, quotedTextIndex) : body;
1526                if (quotedTextIndex > -1) {
1527                    quotedText = body.substring(quotedTextIndex);
1528                }
1529            }
1530            mBodyView.setText(bodyText);
1531        }
1532        if (quotedTextIndex > -1 && quotedText != null) {
1533            mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward);
1534        }
1535    }
1536
1537    /**
1538     * Fill all the widgets with the content found in the Intent Extra, if any.
1539     * Also apply the same style to all widgets. Note: if initFromExtras is
1540     * called as a result of switching between reply, reply all, and forward per
1541     * the latest revision of Gmail, and the user has already made changes to
1542     * attachments on a previous incarnation of the message (as a reply, reply
1543     * all, or forward), the original attachments from the message will not be
1544     * re-instantiated. The user's changes will be respected. This follows the
1545     * web gmail interaction.
1546     * @return {@code true} if the activity should not call {@link #finishSetup}.
1547     */
1548    public boolean initFromExtras(Intent intent) {
1549        // If we were invoked with a SENDTO intent, the value
1550        // should take precedence
1551        final Uri dataUri = intent.getData();
1552        if (dataUri != null) {
1553            if (MAIL_TO.equals(dataUri.getScheme())) {
1554                initFromMailTo(dataUri.toString());
1555            } else {
1556                if (!mAccount.composeIntentUri.equals(dataUri)) {
1557                    String toText = dataUri.getSchemeSpecificPart();
1558                    if (toText != null) {
1559                        mTo.setText("");
1560                        addToAddresses(Arrays.asList(TextUtils.split(toText, ",")));
1561                    }
1562                }
1563            }
1564        }
1565
1566        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
1567        if (extraStrings != null) {
1568            addToAddresses(Arrays.asList(extraStrings));
1569        }
1570        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
1571        if (extraStrings != null) {
1572            addCcAddresses(Arrays.asList(extraStrings), null);
1573        }
1574        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1575        if (extraStrings != null) {
1576            addBccAddresses(Arrays.asList(extraStrings));
1577        }
1578
1579        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1580        if (extraString != null) {
1581            mSubject.setText(extraString);
1582        }
1583
1584        for (String extra : ALL_EXTRAS) {
1585            if (intent.hasExtra(extra)) {
1586                String value = intent.getStringExtra(extra);
1587                if (EXTRA_TO.equals(extra)) {
1588                    addToAddresses(Arrays.asList(TextUtils.split(value, ",")));
1589                } else if (EXTRA_CC.equals(extra)) {
1590                    addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null);
1591                } else if (EXTRA_BCC.equals(extra)) {
1592                    addBccAddresses(Arrays.asList(TextUtils.split(value, ",")));
1593                } else if (EXTRA_SUBJECT.equals(extra)) {
1594                    mSubject.setText(value);
1595                } else if (EXTRA_BODY.equals(extra)) {
1596                    setBody(value, true /* with signature */);
1597                } else if (EXTRA_QUOTED_TEXT.equals(extra)) {
1598                    initQuotedText(value, true /* shouldQuoteText */);
1599                }
1600            }
1601        }
1602
1603        Bundle extras = intent.getExtras();
1604        if (extras != null) {
1605            CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT);
1606            if (text != null) {
1607                setBody(text, true /* with signature */);
1608            }
1609
1610            // TODO - support EXTRA_HTML_TEXT
1611        }
1612
1613        mExtraValues = intent.getParcelableExtra(EXTRA_VALUES);
1614        if (mExtraValues != null) {
1615            LogUtils.d(LOG_TAG, "Launched with extra values: %s", mExtraValues.toString());
1616            initExtraValues(mExtraValues);
1617            return true;
1618        }
1619
1620        return false;
1621    }
1622
1623    protected void initExtraValues(ContentValues extraValues) {
1624        // DO NOTHING - Gmail will override
1625    }
1626
1627
1628    @VisibleForTesting
1629    protected String decodeEmailInUri(String s) throws UnsupportedEncodingException {
1630        // TODO: handle the case where there are spaces in the display name as
1631        // well as the email such as "Guy with spaces <guy+with+spaces@gmail.com>"
1632        // as they could be encoded ambiguously.
1633        // Since URLDecode.decode changes + into ' ', and + is a valid
1634        // email character, we need to find/ replace these ourselves before
1635        // decoding.
1636        try {
1637            return URLDecoder.decode(replacePlus(s), UTF8_ENCODING_NAME);
1638        } catch (IllegalArgumentException e) {
1639            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1640                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s);
1641            } else {
1642                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1643            }
1644            return null;
1645        }
1646    }
1647
1648    /**
1649     * Replaces all occurrences of '+' with "%2B", to prevent URLDecode.decode from
1650     * changing '+' into ' '
1651     *
1652     * @param toReplace Input string
1653     * @return The string with all "+" characters replaced with "%2B"
1654     */
1655    private static String replacePlus(String toReplace) {
1656        return toReplace.replace("+", "%2B");
1657    }
1658
1659    /**
1660     * Replaces all occurrences of '%' with "%25", to prevent URLDecode.decode from
1661     * crashing on decoded '%' symbols
1662     *
1663     * @param toReplace Input string
1664     * @return The string with all "%" characters replaced with "%25"
1665     */
1666    private static String replacePercent(String toReplace) {
1667        return toReplace.replace("%", "%25");
1668    }
1669
1670    /**
1671     * Helper function to encapsulate encoding/decoding string from Uri.getQueryParameters
1672     * @param content Input string
1673     * @return The string that's properly escaped to be shown in mail subject/content
1674     */
1675    private static String decodeContentFromQueryParam(String content) {
1676        try {
1677            return URLDecoder.decode(replacePlus(replacePercent(content)), UTF8_ENCODING_NAME);
1678        } catch (UnsupportedEncodingException e) {
1679            LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), content);
1680            return "";  // Default to empty string so setText/setBody has same behavior as before.
1681        }
1682    }
1683
1684    /**
1685     * Initialize the compose view from a String representing a mailTo uri.
1686     * @param mailToString The uri as a string.
1687     */
1688    public void initFromMailTo(String mailToString) {
1689        // We need to disguise this string as a URI in order to parse it
1690        // TODO:  Remove this hack when http://b/issue?id=1445295 gets fixed
1691        Uri uri = Uri.parse("foo://" + mailToString);
1692        int index = mailToString.indexOf("?");
1693        int length = "mailto".length() + 1;
1694        String to;
1695        try {
1696            // Extract the recipient after mailto:
1697            if (index == -1) {
1698                to = decodeEmailInUri(mailToString.substring(length));
1699            } else {
1700                to = decodeEmailInUri(mailToString.substring(length, index));
1701            }
1702            if (!TextUtils.isEmpty(to)) {
1703                addToAddresses(Arrays.asList(TextUtils.split(to, ",")));
1704            }
1705        } catch (UnsupportedEncodingException e) {
1706            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1707                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString);
1708            } else {
1709                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1710            }
1711        }
1712
1713        List<String> cc = uri.getQueryParameters("cc");
1714        addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null);
1715
1716        List<String> otherTo = uri.getQueryParameters("to");
1717        addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()])));
1718
1719        List<String> bcc = uri.getQueryParameters("bcc");
1720        addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()])));
1721
1722        // NOTE: Uri.getQueryParameters already decodes % encoded characters
1723        List<String> subject = uri.getQueryParameters("subject");
1724        if (subject.size() > 0) {
1725            mSubject.setText(decodeContentFromQueryParam(subject.get(0)));
1726        }
1727
1728        List<String> body = uri.getQueryParameters("body");
1729        if (body.size() > 0) {
1730            setBody(decodeContentFromQueryParam(body.get(0)), true /* with signature */);
1731        }
1732    }
1733
1734    @VisibleForTesting
1735    protected void initAttachments(Message refMessage) {
1736        addAttachments(refMessage.getAttachments());
1737    }
1738
1739    public long addAttachments(List<Attachment> attachments) {
1740        long size = 0;
1741        AttachmentFailureException error = null;
1742        for (Attachment a : attachments) {
1743            try {
1744                size += mAttachmentsView.addAttachment(mAccount, a);
1745            } catch (AttachmentFailureException e) {
1746                error = e;
1747            }
1748        }
1749        if (error != null) {
1750            LogUtils.e(LOG_TAG, error, "Error adding attachment");
1751            if (attachments.size() > 1) {
1752                showAttachmentTooBigToast(R.string.too_large_to_attach_multiple);
1753            } else {
1754                showAttachmentTooBigToast(error.getErrorRes());
1755            }
1756        }
1757        return size;
1758    }
1759
1760    /**
1761     * When an attachment is too large to be added to a message, show a toast.
1762     * This method also updates the position of the toast so that it is shown
1763     * clearly above they keyboard if it happens to be open.
1764     */
1765    private void showAttachmentTooBigToast(int errorRes) {
1766        String maxSize = AttachmentUtils.convertToHumanReadableSize(
1767                getApplicationContext(), mAccount.settings.getMaxAttachmentSize());
1768        showErrorToast(getString(errorRes, maxSize));
1769    }
1770
1771    private void showErrorToast(String message) {
1772        Toast t = Toast.makeText(this, message, Toast.LENGTH_LONG);
1773        t.setText(message);
1774        t.setGravity(Gravity.CENTER_HORIZONTAL, 0,
1775                getResources().getDimensionPixelSize(R.dimen.attachment_toast_yoffset));
1776        t.show();
1777    }
1778
1779    private void initAttachmentsFromIntent(Intent intent) {
1780        Bundle extras = intent.getExtras();
1781        if (extras == null) {
1782            extras = Bundle.EMPTY;
1783        }
1784        final String action = intent.getAction();
1785        if (!mAttachmentsChanged) {
1786            long totalSize = 0;
1787            if (extras.containsKey(EXTRA_ATTACHMENTS)) {
1788                String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS);
1789                for (String uriString : uris) {
1790                    final Uri uri = Uri.parse(uriString);
1791                    long size = 0;
1792                    try {
1793                        if (handleSpecialAttachmentUri(uri)) {
1794                            continue;
1795                        }
1796
1797                        final Attachment a = mAttachmentsView.generateLocalAttachment(uri);
1798                        size = mAttachmentsView.addAttachment(mAccount, a);
1799
1800                        Analytics.getInstance().sendEvent("send_intent_attachment",
1801                                Utils.normalizeMimeType(a.getContentType()), null, size);
1802
1803                    } catch (AttachmentFailureException e) {
1804                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1805                        showAttachmentTooBigToast(e.getErrorRes());
1806                    }
1807                    totalSize += size;
1808                }
1809            }
1810            if (extras.containsKey(Intent.EXTRA_STREAM)) {
1811                if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
1812                    final ArrayList<Uri> uris = extras
1813                            .getParcelableArrayList(Intent.EXTRA_STREAM);
1814                    ArrayList<Attachment> attachments = new ArrayList<Attachment>();
1815                    for (Uri uri : uris) {
1816                        if (uri == null) {
1817                            continue;
1818                        }
1819                        try {
1820                            if (handleSpecialAttachmentUri(uri)) {
1821                                continue;
1822                            }
1823
1824                            final Attachment a = mAttachmentsView.generateLocalAttachment(uri);
1825                            attachments.add(a);
1826
1827                            Analytics.getInstance().sendEvent("send_intent_attachment",
1828                                    Utils.normalizeMimeType(a.getContentType()), null, a.size);
1829
1830                        } catch (AttachmentFailureException e) {
1831                            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1832                            String maxSize = AttachmentUtils.convertToHumanReadableSize(
1833                                    getApplicationContext(),
1834                                    mAccount.settings.getMaxAttachmentSize());
1835                            showErrorToast(getString
1836                                    (R.string.generic_attachment_problem, maxSize));
1837                        }
1838                    }
1839                    totalSize += addAttachments(attachments);
1840                } else {
1841                    final Uri uri = extras.getParcelable(Intent.EXTRA_STREAM);
1842                    if (uri != null) {
1843                        long size = 0;
1844                        try {
1845                            if (!handleSpecialAttachmentUri(uri)) {
1846                                final Attachment a = mAttachmentsView.generateLocalAttachment(uri);
1847                                size = mAttachmentsView.addAttachment(mAccount, a);
1848
1849                                Analytics.getInstance().sendEvent("send_intent_attachment",
1850                                        Utils.normalizeMimeType(a.getContentType()), null, size);
1851                            }
1852
1853                        } catch (AttachmentFailureException e) {
1854                            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1855                            showAttachmentTooBigToast(e.getErrorRes());
1856                        }
1857                        totalSize += size;
1858                    }
1859                }
1860            }
1861
1862            if (totalSize > 0) {
1863                mAttachmentsChanged = true;
1864                updateSaveUi();
1865
1866                Analytics.getInstance().sendEvent("send_intent_with_attachments",
1867                        Integer.toString(getAttachments().size()), null, totalSize);
1868            }
1869        }
1870    }
1871
1872    protected void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) {
1873        mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText);
1874        mShowQuotedText = true;
1875    }
1876
1877    private void initQuotedTextFromRefMessage(Message refMessage, int action) {
1878        if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) {
1879            mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD);
1880        }
1881    }
1882
1883    private void updateHideOrShowCcBcc() {
1884        // Its possible there is a menu item OR a button.
1885        boolean ccVisible = mCcBccView.isCcVisible();
1886        boolean bccVisible = mCcBccView.isBccVisible();
1887        if (mCcBccButton != null) {
1888            if (!ccVisible || !bccVisible) {
1889                mCcBccButton.setVisibility(View.VISIBLE);
1890                mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label
1891                        : R.string.add_bcc_label));
1892            } else {
1893                mCcBccButton.setVisibility(View.INVISIBLE);
1894            }
1895        }
1896    }
1897
1898    /**
1899     * Add attachment and update the compose area appropriately.
1900     */
1901    private void addAttachmentAndUpdateView(Intent data) {
1902        if (data == null) {
1903            return;
1904        }
1905
1906        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
1907            final ClipData clipData = data.getClipData();
1908            if (clipData != null) {
1909                for (int i = 0, size = clipData.getItemCount(); i < size; i++) {
1910                    addAttachmentAndUpdateView(clipData.getItemAt(i).getUri());
1911                }
1912                return;
1913            }
1914        }
1915
1916        addAttachmentAndUpdateView(data.getData());
1917    }
1918
1919    private void addAttachmentAndUpdateView(Uri contentUri) {
1920        if (contentUri == null) {
1921            return;
1922        }
1923        try {
1924
1925            if (handleSpecialAttachmentUri(contentUri)) {
1926                return;
1927            }
1928
1929            addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri));
1930        } catch (AttachmentFailureException e) {
1931            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1932            showErrorToast(getResources().getString(
1933                    e.getErrorRes(),
1934                    AttachmentUtils.convertToHumanReadableSize(
1935                            getApplicationContext(), mAccount.settings.getMaxAttachmentSize())));
1936        }
1937    }
1938
1939    /**
1940     * Allow subclasses to implement custom handling of attachments.
1941     *
1942     * @param contentUri a passed-in URI from a pick intent
1943     * @return true iff handled
1944     */
1945    protected boolean handleSpecialAttachmentUri(final Uri contentUri) {
1946        return false;
1947    }
1948
1949    private void addAttachmentAndUpdateView(Attachment attachment) {
1950        try {
1951            long size = mAttachmentsView.addAttachment(mAccount, attachment);
1952            if (size > 0) {
1953                mAttachmentsChanged = true;
1954                updateSaveUi();
1955            }
1956        } catch (AttachmentFailureException e) {
1957            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1958            showAttachmentTooBigToast(e.getErrorRes());
1959        }
1960    }
1961
1962    void initRecipientsFromRefMessage(Message refMessage, int action) {
1963        // Don't populate the address if this is a forward.
1964        if (action == ComposeActivity.FORWARD) {
1965            return;
1966        }
1967        initReplyRecipients(refMessage, action);
1968    }
1969
1970    // TODO: This should be private.  This method shouldn't be used by ComposeActivityTests, as
1971    // it doesn't setup the state of the activity correctly
1972    @VisibleForTesting
1973    void initReplyRecipients(final Message refMessage, final int action) {
1974        String[] sentToAddresses = refMessage.getToAddressesUnescaped();
1975        final Collection<String> toAddresses;
1976        final String[] fromAddresses = refMessage.getFromAddressesUnescaped();
1977        final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null;
1978        final String[] replyToAddresses = getReplyToAddresses(
1979                refMessage.getReplyToAddressesUnescaped(), fromAddress);
1980
1981        // If this is a reply, the Cc list is empty. If this is a reply-all, the
1982        // Cc list is the union of the To and Cc recipients of the original
1983        // message, excluding the current user's email address and any addresses
1984        // already on the To list.
1985        if (action == ComposeActivity.REPLY) {
1986            toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses);
1987            addToAddresses(toAddresses);
1988        } else if (action == ComposeActivity.REPLY_ALL) {
1989            final Set<String> ccAddresses = Sets.newHashSet();
1990            toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses);
1991            addToAddresses(toAddresses);
1992            addRecipients(ccAddresses, sentToAddresses);
1993            addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped());
1994            addCcAddresses(ccAddresses, toAddresses);
1995        }
1996    }
1997
1998    // If there is no reply to address, the reply to address is the sender.
1999    private static String[] getReplyToAddresses(String[] replyTo, String from) {
2000        boolean hasReplyTo = false;
2001        for (final String replyToAddress : replyTo) {
2002            if (!TextUtils.isEmpty(replyToAddress)) {
2003                hasReplyTo = true;
2004            }
2005        }
2006        return hasReplyTo ? replyTo : new String[] {from};
2007    }
2008
2009    private void addToAddresses(Collection<String> addresses) {
2010        addAddressesToList(addresses, mTo);
2011    }
2012
2013    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
2014        addCcAddressesToList(tokenizeAddressList(addresses),
2015                toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc);
2016    }
2017
2018    private void addBccAddresses(Collection<String> addresses) {
2019        addAddressesToList(addresses, mBcc);
2020    }
2021
2022    @VisibleForTesting
2023    protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
2024            List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
2025        String address;
2026
2027        if (compareToList == null) {
2028            for (final Rfc822Token[] tokens : addresses) {
2029                for (final Rfc822Token token : tokens) {
2030                    address = token.toString();
2031                    list.append(address + END_TOKEN);
2032                }
2033            }
2034        } else {
2035            HashSet<String> compareTo = convertToHashSet(compareToList);
2036            for (final Rfc822Token[] tokens : addresses) {
2037                for (final Rfc822Token token : tokens) {
2038                    address = token.toString();
2039                    // Check if this is a duplicate:
2040                    if (!compareTo.contains(token.getAddress())) {
2041                        // Get the address here
2042                        list.append(address + END_TOKEN);
2043                    }
2044                }
2045            }
2046        }
2047    }
2048
2049    private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) {
2050        final HashSet<String> hash = new HashSet<String>();
2051        for (final Rfc822Token[] tokens : list) {
2052            for (final Rfc822Token token : tokens) {
2053                hash.add(token.getAddress());
2054            }
2055        }
2056        return hash;
2057    }
2058
2059    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
2060        @VisibleForTesting
2061        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
2062
2063        for (String address: addresses) {
2064            tokenized.add(Rfc822Tokenizer.tokenize(address));
2065        }
2066        return tokenized;
2067    }
2068
2069    @VisibleForTesting
2070    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
2071        for (String address : addresses) {
2072            addAddressToList(address, list);
2073        }
2074    }
2075
2076    private static void addAddressToList(final String address, final RecipientEditTextView list) {
2077        if (address == null || list == null)
2078            return;
2079
2080        final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
2081
2082        for (final Rfc822Token token : tokens) {
2083            list.append(token + END_TOKEN);
2084        }
2085    }
2086
2087    @VisibleForTesting
2088    protected Collection<String> initToRecipients(final String fullSenderAddress,
2089            final String[] replyToAddresses, final String[] inToAddresses) {
2090        // The To recipient is the reply-to address specified in the original
2091        // message, unless it is:
2092        // the current user OR a custom from of the current user, in which case
2093        // it's the To recipient list of the original message.
2094        // OR missing, in which case use the sender of the original message
2095        Set<String> toAddresses = Sets.newHashSet();
2096        for (final String replyToAddress : replyToAddresses) {
2097            if (!TextUtils.isEmpty(replyToAddress)
2098                    && !recipientMatchesThisAccount(replyToAddress)) {
2099                toAddresses.add(replyToAddress);
2100            }
2101        }
2102        if (toAddresses.size() == 0) {
2103            // In this case, the user is replying to a message in which their
2104            // current account or some of their custom from addresses are the only
2105            // recipients and they sent the original message.
2106            if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress)
2107                    && recipientMatchesThisAccount(inToAddresses[0])) {
2108                toAddresses.add(inToAddresses[0]);
2109                return toAddresses;
2110            }
2111            // This happens if the user replies to a message they originally
2112            // wrote. In this case, "reply" really means "re-send," so we
2113            // target the original recipients. This works as expected even
2114            // if the user sent the original message to themselves.
2115            for (String address : inToAddresses) {
2116                if (!recipientMatchesThisAccount(address)) {
2117                    toAddresses.add(address);
2118                }
2119            }
2120        }
2121        return toAddresses;
2122    }
2123
2124    private void addRecipients(final Set<String> recipients, final String[] addresses) {
2125        for (final String email : addresses) {
2126            // Do not add this account, or any of its custom from addresses, to
2127            // the list of recipients.
2128            final String recipientAddress = Address.getEmailAddress(email).getAddress();
2129            if (!recipientMatchesThisAccount(recipientAddress)) {
2130                recipients.add(email.replace("\"\"", ""));
2131            }
2132        }
2133    }
2134
2135    /**
2136     * A recipient matches this account if it has the same address as the
2137     * currently selected account OR one of the custom from addresses associated
2138     * with the currently selected account.
2139     * @param recipientAddress address we are comparing with the currently selected account
2140     */
2141    protected boolean recipientMatchesThisAccount(String recipientAddress) {
2142        return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress,
2143                        mAccount.getReplyFroms());
2144    }
2145
2146    /**
2147     * Returns a formatted subject string with the appropriate prefix for the action type.
2148     * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}.
2149     */
2150    public static String buildFormattedSubject(Resources res, String subject, int action) {
2151        String prefix;
2152        String correctedSubject = null;
2153        if (action == ComposeActivity.COMPOSE) {
2154            prefix = "";
2155        } else if (action == ComposeActivity.FORWARD) {
2156            prefix = res.getString(R.string.forward_subject_label);
2157        } else {
2158            prefix = res.getString(R.string.reply_subject_label);
2159        }
2160
2161        // Don't duplicate the prefix
2162        if (!TextUtils.isEmpty(subject)
2163                && subject.toLowerCase().startsWith(prefix.toLowerCase())) {
2164            correctedSubject = subject;
2165        } else {
2166            correctedSubject = String.format(
2167                    res.getString(R.string.formatted_subject), prefix, subject);
2168        }
2169
2170        return correctedSubject;
2171    }
2172
2173    private void setSubject(Message refMessage, int action) {
2174        mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action));
2175    }
2176
2177    private void initRecipients() {
2178        setupRecipients(mTo);
2179        setupRecipients(mCc);
2180        setupRecipients(mBcc);
2181    }
2182
2183    private void setupRecipients(RecipientEditTextView view) {
2184        final DropdownChipLayouter layouter = getDropdownChipLayouter();
2185        if (layouter != null) {
2186            view.setDropdownChipLayouter(layouter);
2187        }
2188        view.setAdapter(getRecipientAdapter());
2189        view.setRecipientEntryItemClickedListener(this);
2190        if (mValidator == null) {
2191            final String accountName = mAccount.getEmailAddress();
2192            int offset = accountName.indexOf("@") + 1;
2193            String account = accountName;
2194            if (offset > 0) {
2195                account = account.substring(offset);
2196            }
2197            mValidator = new Rfc822Validator(account);
2198        }
2199        view.setValidator(mValidator);
2200    }
2201
2202    /**
2203     * Derived classes should override if they wish to provide their own autocomplete behavior.
2204     */
2205    public BaseRecipientAdapter getRecipientAdapter() {
2206        return new RecipientAdapter(this, mAccount);
2207    }
2208
2209    /**
2210     * Derived classes should override this to provide their own dropdown behavior.
2211     * If the result is null, the default {@link com.android.ex.chips.DropdownChipLayouter}
2212     * is used.
2213     */
2214    public DropdownChipLayouter getDropdownChipLayouter() {
2215        return null;
2216    }
2217
2218    @Override
2219    public void onClick(View v) {
2220        final int id = v.getId();
2221        if (id == R.id.add_cc_bcc) {
2222            // Verify that cc/ bcc aren't showing.
2223            // Animate in cc/bcc.
2224            showCcBccViews();
2225        } else if (id == R.id.add_attachment) {
2226            doAttach(Utils.isRunningKitkatOrLater() ? MIME_TYPE_ALL : MIME_TYPE_PHOTO);
2227        }
2228    }
2229
2230    @Override
2231    public boolean onCreateOptionsMenu(Menu menu) {
2232        final boolean superCreated = super.onCreateOptionsMenu(menu);
2233        // Don't render any menu items when there are no accounts.
2234        if (mAccounts == null || mAccounts.length == 0) {
2235            return superCreated;
2236        }
2237        MenuInflater inflater = getMenuInflater();
2238        inflater.inflate(R.menu.compose_menu, menu);
2239
2240        /*
2241         * Start save in the correct enabled state.
2242         * 1) If a user launches compose from within gmail, save is disabled
2243         * until they add something, at which point, save is enabled, auto save
2244         * on exit; if the user empties everything, save is disabled, exiting does not
2245         * auto-save
2246         * 2) if a user replies/ reply all/ forwards from within gmail, save is
2247         * disabled until they change something, at which point, save is
2248         * enabled, auto save on exit; if the user empties everything, save is
2249         * disabled, exiting does not auto-save.
2250         * 3) If a user launches compose from another application and something
2251         * gets populated (attachments, recipients, body, subject, etc), save is
2252         * enabled, auto save on exit; if the user empties everything, save is
2253         * disabled, exiting does not auto-save
2254         */
2255        mSave = menu.findItem(R.id.save);
2256        String action = getIntent() != null ? getIntent().getAction() : null;
2257        enableSave(mInnerSavedState != null ?
2258                mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED)
2259                    : (Intent.ACTION_SEND.equals(action)
2260                            || Intent.ACTION_SEND_MULTIPLE.equals(action)
2261                            || Intent.ACTION_SENDTO.equals(action)
2262                            || shouldSave()));
2263
2264        MenuItem helpItem = menu.findItem(R.id.help_info_menu_item);
2265        MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item);
2266        if (helpItem != null) {
2267            helpItem.setVisible(mAccount != null
2268                    && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT));
2269        }
2270        if (sendFeedbackItem != null) {
2271            sendFeedbackItem.setVisible(mAccount != null
2272                    && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK));
2273        }
2274
2275        // Show attach picture on pre-K devices.
2276        menu.findItem(R.id.add_photo_attachment).setVisible(!Utils.isRunningKitkatOrLater());
2277
2278        return true;
2279    }
2280
2281    @Override
2282    public boolean onPrepareOptionsMenu(Menu menu) {
2283        MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
2284        if (ccBcc != null && mCc != null) {
2285            // Its possible there is a menu item OR a button.
2286            boolean ccFieldVisible = mCc.isShown();
2287            boolean bccFieldVisible = mBcc.isShown();
2288            if (!ccFieldVisible || !bccFieldVisible) {
2289                ccBcc.setVisible(true);
2290                ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
2291                        : R.string.add_bcc_label));
2292            } else {
2293                ccBcc.setVisible(false);
2294            }
2295        }
2296        return true;
2297    }
2298
2299    @Override
2300    public boolean onOptionsItemSelected(MenuItem item) {
2301        final int id = item.getItemId();
2302
2303        Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id,
2304                "compose", 0);
2305
2306        boolean handled = true;
2307        if (id == R.id.add_file_attachment) {
2308            doAttach(MIME_TYPE_ALL);
2309        } else if (id == R.id.add_photo_attachment) {
2310            doAttach(MIME_TYPE_PHOTO);
2311        } else if (id == R.id.add_cc_bcc) {
2312            showCcBccViews();
2313        } else if (id == R.id.save) {
2314            doSave(true);
2315        } else if (id == R.id.send) {
2316            doSend();
2317        } else if (id == R.id.discard) {
2318            doDiscard();
2319        } else if (id == R.id.settings) {
2320            Utils.showSettings(this, mAccount);
2321        } else if (id == android.R.id.home) {
2322            onAppUpPressed();
2323        } else if (id == R.id.help_info_menu_item) {
2324            Utils.showHelp(this, mAccount, getString(R.string.compose_help_context));
2325        } else if (id == R.id.feedback_menu_item) {
2326            Utils.sendFeedback(this, mAccount, false);
2327        } else {
2328            handled = false;
2329        }
2330        return handled || super.onOptionsItemSelected(item);
2331    }
2332
2333    @Override
2334    public void onBackPressed() {
2335        // If we are showing the wait fragment, just exit.
2336        if (getWaitFragment() != null) {
2337            finish();
2338        } else {
2339            super.onBackPressed();
2340        }
2341    }
2342
2343    /**
2344     * Carries out the "up" action in the action bar.
2345     */
2346    private void onAppUpPressed() {
2347        if (mLaunchedFromEmail) {
2348            // If this was started from Gmail, simply treat app up as the system back button, so
2349            // that the last view is restored.
2350            onBackPressed();
2351            return;
2352        }
2353
2354        // Fire the main activity to ensure it launches the "top" screen of mail.
2355        // Since the main Activity is singleTask, it should revive that task if it was already
2356        // started.
2357        final Intent mailIntent = Utils.createViewInboxIntent(mAccount);
2358        mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
2359                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
2360        startActivity(mailIntent);
2361        finish();
2362    }
2363
2364    private void doSend() {
2365        sendOrSaveWithSanityChecks(false, true, false, false);
2366        logSendOrSave(false /* save */);
2367        mPerformedSendOrDiscard = true;
2368    }
2369
2370    private void doSave(boolean showToast) {
2371        sendOrSaveWithSanityChecks(true, showToast, false, false);
2372    }
2373
2374    @Override
2375    public void onRecipientEntryItemClicked(int charactersTyped, int position) {
2376        // Send analytics of characters typed and position in dropdown selected.
2377        Analytics.getInstance().sendEvent(
2378                "suggest_click", Integer.toString(charactersTyped), Integer.toString(position), 0);
2379    }
2380
2381    @VisibleForTesting
2382    public interface SendOrSaveCallback {
2383        void initializeSendOrSave(SendOrSaveTask sendOrSaveTask);
2384        void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
2385        Message getMessage();
2386        void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success);
2387        void incrementRecipientsTimesContacted(List<String> recipients);
2388    }
2389
2390    @VisibleForTesting
2391    public static class SendOrSaveTask implements Runnable {
2392        private final Context mContext;
2393        @VisibleForTesting
2394        public final SendOrSaveCallback mSendOrSaveCallback;
2395        @VisibleForTesting
2396        public final SendOrSaveMessage mSendOrSaveMessage;
2397        private ReplyFromAccount mExistingDraftAccount;
2398
2399        public SendOrSaveTask(Context context, SendOrSaveMessage message,
2400                SendOrSaveCallback callback, ReplyFromAccount draftAccount) {
2401            mContext = context;
2402            mSendOrSaveCallback = callback;
2403            mSendOrSaveMessage = message;
2404            mExistingDraftAccount = draftAccount;
2405        }
2406
2407        @Override
2408        public void run() {
2409            final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage;
2410
2411            final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount;
2412            Message message = mSendOrSaveCallback.getMessage();
2413            long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID;
2414            // If a previous draft has been saved, in an account that is different
2415            // than what the user wants to send from, remove the old draft, and treat this
2416            // as a new message
2417            if (mExistingDraftAccount != null
2418                    && !selectedAccount.account.uri.equals(mExistingDraftAccount.account.uri)) {
2419                if (messageId != UIProvider.INVALID_MESSAGE_ID) {
2420                    ContentResolver resolver = mContext.getContentResolver();
2421                    ContentValues values = new ContentValues();
2422                    values.put(BaseColumns._ID, messageId);
2423                    if (mExistingDraftAccount.account.expungeMessageUri != null) {
2424                        new ContentProviderTask.UpdateTask()
2425                                .run(resolver, mExistingDraftAccount.account.expungeMessageUri,
2426                                        values, null, null);
2427                    } else {
2428                        // TODO(mindyp) delete the conversation.
2429                    }
2430                    // reset messageId to 0, so a new message will be created
2431                    messageId = UIProvider.INVALID_MESSAGE_ID;
2432                }
2433            }
2434
2435            final long messageIdToSave = messageId;
2436            sendOrSaveMessage(messageIdToSave, sendOrSaveMessage, selectedAccount);
2437
2438            if (!sendOrSaveMessage.mSave) {
2439                incrementRecipientsTimesContacted(
2440                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO));
2441                incrementRecipientsTimesContacted(
2442                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC));
2443                incrementRecipientsTimesContacted(
2444                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
2445            }
2446            mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
2447        }
2448
2449        private void incrementRecipientsTimesContacted(final String addressString) {
2450            if (TextUtils.isEmpty(addressString)) {
2451                return;
2452            }
2453            final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString);
2454            final ArrayList<String> recipients = new ArrayList<String>(tokens.length);
2455            for (final Rfc822Token token : tokens) {
2456                recipients.add(token.getAddress());
2457            }
2458            mSendOrSaveCallback.incrementRecipientsTimesContacted(recipients);
2459        }
2460
2461        /**
2462         * Send or Save a message.
2463         */
2464        private void sendOrSaveMessage(final long messageIdToSave,
2465                final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) {
2466            final ContentResolver resolver = mContext.getContentResolver();
2467            final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID;
2468
2469            final String accountMethod = sendOrSaveMessage.mSave ?
2470                    UIProvider.AccountCallMethods.SAVE_MESSAGE :
2471                    UIProvider.AccountCallMethods.SEND_MESSAGE;
2472
2473            try {
2474                if (updateExistingMessage) {
2475                    sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
2476
2477                    callAccountSendSaveMethod(resolver,
2478                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2479                } else {
2480                    Uri messageUri = null;
2481                    final Bundle result = callAccountSendSaveMethod(resolver,
2482                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2483                    if (result != null) {
2484                        // If a non-null value was returned, then the provider handled the call
2485                        // method
2486                        messageUri = result.getParcelable(UIProvider.MessageColumns.URI);
2487                    }
2488                    if (sendOrSaveMessage.mSave && messageUri != null) {
2489                        final Cursor messageCursor = resolver.query(messageUri,
2490                                UIProvider.MESSAGE_PROJECTION, null, null, null);
2491                        if (messageCursor != null) {
2492                            try {
2493                                if (messageCursor.moveToFirst()) {
2494                                    // Broadcast notification that a new message has
2495                                    // been allocated
2496                                    mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
2497                                            new Message(messageCursor));
2498                                }
2499                            } finally {
2500                                messageCursor.close();
2501                            }
2502                        }
2503                    }
2504                }
2505            } finally {
2506                // Close any opened file descriptors
2507                closeOpenedAttachmentFds(sendOrSaveMessage);
2508            }
2509        }
2510
2511        private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) {
2512            final Bundle openedFds = sendOrSaveMessage.attachmentFds();
2513            if (openedFds != null) {
2514                final Set<String> keys = openedFds.keySet();
2515                for (final String key : keys) {
2516                    final ParcelFileDescriptor fd = openedFds.getParcelable(key);
2517                    if (fd != null) {
2518                        try {
2519                            fd.close();
2520                        } catch (IOException e) {
2521                            // Do nothing
2522                        }
2523                    }
2524                }
2525            }
2526        }
2527
2528        /**
2529         * Use the {@link ContentResolver#call} method to send or save the message.
2530         *
2531         * If this was successful, this method will return an non-null Bundle instance
2532         */
2533        private static Bundle callAccountSendSaveMethod(final ContentResolver resolver,
2534                final Account account, final String method,
2535                final SendOrSaveMessage sendOrSaveMessage) {
2536            // Copy all of the values from the content values to the bundle
2537            final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size());
2538            final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet();
2539
2540            for (Entry<String, Object> entry : valueSet) {
2541                final Object entryValue = entry.getValue();
2542                final String key = entry.getKey();
2543                if (entryValue instanceof String) {
2544                    methodExtras.putString(key, (String)entryValue);
2545                } else if (entryValue instanceof Boolean) {
2546                    methodExtras.putBoolean(key, (Boolean)entryValue);
2547                } else if (entryValue instanceof Integer) {
2548                    methodExtras.putInt(key, (Integer)entryValue);
2549                } else if (entryValue instanceof Long) {
2550                    methodExtras.putLong(key, (Long)entryValue);
2551                } else {
2552                    LogUtils.wtf(LOG_TAG, "Unexpected object type: %s",
2553                            entryValue.getClass().getName());
2554                }
2555            }
2556
2557            // If the SendOrSaveMessage has some opened fds, add them to the bundle
2558            final Bundle fdMap = sendOrSaveMessage.attachmentFds();
2559            if (fdMap != null) {
2560                methodExtras.putParcelable(
2561                        UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap);
2562            }
2563
2564            return resolver.call(account.uri, method, account.uri.toString(), methodExtras);
2565        }
2566    }
2567
2568    /**
2569     * Reports recipients that have been contacted in order to improve auto-complete
2570     * suggestions. Default behavior updates usage statistics in ContactsProvider.
2571     * @param recipients addresses
2572     */
2573    protected void incrementRecipientsTimesContacted(List<String> recipients) {
2574        final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(this);
2575        statsUpdater.updateWithAddress(recipients);
2576    }
2577
2578    @VisibleForTesting
2579    public static class SendOrSaveMessage {
2580        final ReplyFromAccount mAccount;
2581        final ContentValues mValues;
2582        final String mRefMessageId;
2583        @VisibleForTesting
2584        public final boolean mSave;
2585        final int mRequestId;
2586        private final Bundle mAttachmentFds;
2587
2588        public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values,
2589                String refMessageId, List<Attachment> attachments, boolean save) {
2590            mAccount = account;
2591            mValues = values;
2592            mRefMessageId = refMessageId;
2593            mSave = save;
2594            mRequestId = mValues.hashCode() ^ hashCode();
2595
2596            mAttachmentFds = initializeAttachmentFds(context, attachments);
2597        }
2598
2599        int requestId() {
2600            return mRequestId;
2601        }
2602
2603        Bundle attachmentFds() {
2604            return mAttachmentFds;
2605        }
2606
2607        /**
2608         * Opens {@link ParcelFileDescriptor} for each of the attachments.  This method must be
2609         * called before the ComposeActivity finishes.
2610         * Note: The caller is responsible for closing these file descriptors.
2611         */
2612        private static Bundle initializeAttachmentFds(final Context context,
2613                final List<Attachment> attachments) {
2614            if (attachments == null || attachments.size() == 0) {
2615                return null;
2616            }
2617
2618            final Bundle result = new Bundle(attachments.size());
2619            final ContentResolver resolver = context.getContentResolver();
2620
2621            for (Attachment attachment : attachments) {
2622                if (attachment == null || Utils.isEmpty(attachment.contentUri)) {
2623                    continue;
2624                }
2625
2626                ParcelFileDescriptor fileDescriptor;
2627                try {
2628                    fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r");
2629                } catch (FileNotFoundException e) {
2630                    LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment");
2631                    fileDescriptor = null;
2632                } catch (SecurityException e) {
2633                    // We have encountered a security exception when attempting to open the file
2634                    // specified by the content uri.  If the attachment has been cached, this
2635                    // isn't a problem, as even through the original permission may have been
2636                    // revoked, we have cached the file.  This will happen when saving/sending
2637                    // a previously saved draft.
2638                    // TODO(markwei): Expose whether the attachment has been cached through the
2639                    // attachment object.  This would allow us to limit when the log is made, as
2640                    // if the attachment has been cached, this really isn't an error
2641                    LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment");
2642                    // Just set the file descriptor to null, as the underlying provider needs
2643                    // to handle the file descriptor not being set.
2644                    fileDescriptor = null;
2645                }
2646
2647                if (fileDescriptor != null) {
2648                    result.putParcelable(attachment.contentUri.toString(), fileDescriptor);
2649                }
2650            }
2651
2652            return result;
2653        }
2654    }
2655
2656    /**
2657     * Get the to recipients.
2658     */
2659    public String[] getToAddresses() {
2660        return getAddressesFromList(mTo);
2661    }
2662
2663    /**
2664     * Get the cc recipients.
2665     */
2666    public String[] getCcAddresses() {
2667        return getAddressesFromList(mCc);
2668    }
2669
2670    /**
2671     * Get the bcc recipients.
2672     */
2673    public String[] getBccAddresses() {
2674        return getAddressesFromList(mBcc);
2675    }
2676
2677    public String[] getAddressesFromList(RecipientEditTextView list) {
2678        if (list == null) {
2679            return new String[0];
2680        }
2681        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
2682        int count = tokens.length;
2683        String[] result = new String[count];
2684        for (int i = 0; i < count; i++) {
2685            result[i] = tokens[i].toString();
2686        }
2687        return result;
2688    }
2689
2690    /**
2691     * Check for invalid email addresses.
2692     * @param to String array of email addresses to check.
2693     * @param wrongEmailsOut Emails addresses that were invalid.
2694     */
2695    public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) {
2696        if (mValidator == null) {
2697            return;
2698        }
2699        for (final String email : to) {
2700            if (!mValidator.isValid(email)) {
2701                wrongEmailsOut.add(email);
2702            }
2703        }
2704    }
2705
2706    public static class RecipientErrorDialogFragment extends DialogFragment {
2707        // Public no-args constructor needed for fragment re-instantiation
2708        public RecipientErrorDialogFragment() {}
2709
2710        public static RecipientErrorDialogFragment newInstance(final String message) {
2711            final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment();
2712            final Bundle args = new Bundle(1);
2713            args.putString("message", message);
2714            frag.setArguments(args);
2715            return frag;
2716        }
2717
2718        @Override
2719        public Dialog onCreateDialog(Bundle savedInstanceState) {
2720            final String message = getArguments().getString("message");
2721            return new AlertDialog.Builder(getActivity())
2722                    .setMessage(message)
2723                    .setPositiveButton(
2724                            R.string.ok, new Dialog.OnClickListener() {
2725                        @Override
2726                        public void onClick(DialogInterface dialog, int which) {
2727                            ((ComposeActivity)getActivity()).finishRecipientErrorDialog();
2728                        }
2729                    }).create();
2730        }
2731    }
2732
2733    private void finishRecipientErrorDialog() {
2734        // after the user dismisses the recipient error
2735        // dialog we want to make sure to refocus the
2736        // recipient to field so they can fix the issue
2737        // easily
2738        if (mTo != null) {
2739            mTo.requestFocus();
2740        }
2741    }
2742
2743    /**
2744     * Show an error because the user has entered an invalid recipient.
2745     */
2746    private void showRecipientErrorDialog(final String message) {
2747        final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message);
2748        frag.show(getFragmentManager(), "recipient error");
2749    }
2750
2751    /**
2752     * Update the state of the UI based on whether or not the current draft
2753     * needs to be saved and the message is not empty.
2754     */
2755    public void updateSaveUi() {
2756        if (mSave != null) {
2757            mSave.setEnabled((shouldSave() && !isBlank()));
2758        }
2759    }
2760
2761    /**
2762     * Returns true if we need to save the current draft.
2763     */
2764    private boolean shouldSave() {
2765        synchronized (mDraftLock) {
2766            // The message should only be saved if:
2767            // It hasn't been sent AND
2768            // Some text has been added to the message OR
2769            // an attachment has been added or removed
2770            // AND there is actually something in the draft to save.
2771            return (mTextChanged || mAttachmentsChanged || mReplyFromChanged)
2772                    && !isBlank();
2773        }
2774    }
2775
2776    /**
2777     * Check if all fields are blank.
2778     * @return boolean
2779     */
2780    public boolean isBlank() {
2781        // Need to check for null since isBlank() can be called from onPause()
2782        // before findViews() is called
2783        if (mSubject == null || mBodyView == null || mTo == null || mCc == null ||
2784                mAttachmentsView == null) {
2785            LogUtils.w(LOG_TAG, "null views in isBlank check");
2786            return true;
2787        }
2788        return mSubject.getText().length() == 0
2789                && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
2790                        mBodyView.getText().toString()) == 0)
2791                && mTo.length() == 0
2792                && mCc.length() == 0 && mBcc.length() == 0
2793                && mAttachmentsView.getAttachments().size() == 0;
2794    }
2795
2796    @VisibleForTesting
2797    protected int getSignatureStartPosition(String signature, String bodyText) {
2798        int startPos = -1;
2799
2800        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
2801            return startPos;
2802        }
2803
2804        int bodyLength = bodyText.length();
2805        int signatureLength = signature.length();
2806        String printableVersion = convertToPrintableSignature(signature);
2807        int printableLength = printableVersion.length();
2808
2809        if (bodyLength >= printableLength
2810                && bodyText.substring(bodyLength - printableLength)
2811                .equals(printableVersion)) {
2812            startPos = bodyLength - printableLength;
2813        } else if (bodyLength >= signatureLength
2814                && bodyText.substring(bodyLength - signatureLength)
2815                .equals(signature)) {
2816            startPos = bodyLength - signatureLength;
2817        }
2818        return startPos;
2819    }
2820
2821    /**
2822     * Allows any changes made by the user to be ignored. Called when the user
2823     * decides to discard a draft.
2824     */
2825    private void discardChanges() {
2826        mTextChanged = false;
2827        mAttachmentsChanged = false;
2828        mReplyFromChanged = false;
2829    }
2830
2831    /**
2832     * @param save True to save, false to send
2833     * @param showToast True to show a toast once the message is sent/saved
2834     */
2835    protected void sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
2836            final boolean orientationChanged, final boolean autoSend) {
2837        if (mAccounts == null || mAccount == null) {
2838            Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show();
2839            if (autoSend) {
2840                finish();
2841            }
2842            return;
2843        }
2844
2845        final String[] to, cc, bcc;
2846        if (orientationChanged) {
2847            to = cc = bcc = new String[0];
2848        } else {
2849            to = getToAddresses();
2850            cc = getCcAddresses();
2851            bcc = getBccAddresses();
2852        }
2853
2854        final ArrayList<String> recipients = buildEmailAddressList(to);
2855        recipients.addAll(buildEmailAddressList(cc));
2856        recipients.addAll(buildEmailAddressList(bcc));
2857
2858        // Don't let the user send to nobody (but it's okay to save a message
2859        // with no recipients)
2860        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
2861            showRecipientErrorDialog(getString(R.string.recipient_needed));
2862            return;
2863        }
2864
2865        List<String> wrongEmails = new ArrayList<String>();
2866        if (!save) {
2867            checkInvalidEmails(to, wrongEmails);
2868            checkInvalidEmails(cc, wrongEmails);
2869            checkInvalidEmails(bcc, wrongEmails);
2870        }
2871
2872        // Don't let the user send an email with invalid recipients
2873        if (wrongEmails.size() > 0) {
2874            String errorText = String.format(getString(R.string.invalid_recipient),
2875                    wrongEmails.get(0));
2876            showRecipientErrorDialog(errorText);
2877            return;
2878        }
2879
2880        if (!save) {
2881            if (autoSend) {
2882                // Skip all further checks during autosend. This flow is used by Android Wear
2883                // and Google Now.
2884                sendOrSave(save, showToast);
2885                return;
2886            }
2887
2888            // Show a warning before sending only if there are no attachments, body, or subject.
2889            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
2890                boolean warnAboutEmptySubject = isSubjectEmpty();
2891                boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0;
2892
2893                // A warning about an empty body may not be warranted when
2894                // forwarding mails, since a common use case is to forward
2895                // quoted text and not append any more text.
2896                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
2897
2898                // When we bring up a dialog warning the user about a send,
2899                // assume that they accept sending the message. If they do not,
2900                // the dialog listener is required to enable sending again.
2901                if (warnAboutEmptySubject) {
2902                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject,
2903                            showToast, recipients);
2904                    return;
2905                }
2906
2907                if (warnAboutEmptyBody) {
2908                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body,
2909                            showToast, recipients);
2910                    return;
2911                }
2912            }
2913            // Ask for confirmation to send.
2914            if (showSendConfirmation()) {
2915                showSendConfirmDialog(R.string.confirm_send_message, showToast, recipients);
2916                return;
2917            }
2918        }
2919
2920        performAdditionalSendOrSaveSanityChecks(save, showToast, recipients);
2921    }
2922
2923    /**
2924     * Returns a boolean indicating whether warnings should be shown for empty
2925     * subject and body fields
2926     *
2927     * @return True if a warning should be shown for empty text fields
2928     */
2929    protected boolean showEmptyTextWarnings() {
2930        return mAttachmentsView.getAttachments().size() == 0;
2931    }
2932
2933    /**
2934     * Returns a boolean indicating whether the user should confirm each send
2935     *
2936     * @return True if a warning should be on each send
2937     */
2938    protected boolean showSendConfirmation() {
2939        return mCachedSettings != null && mCachedSettings.confirmSend;
2940    }
2941
2942    public static class SendConfirmDialogFragment extends DialogFragment
2943            implements DialogInterface.OnClickListener {
2944
2945        private static final String MESSAGE_ID = "messageId";
2946        private static final String SHOW_TOAST = "showToast";
2947        private static final String RECIPIENTS = "recipients";
2948
2949        private boolean mShowToast;
2950
2951        private ArrayList<String> mRecipients;
2952
2953        // Public no-args constructor needed for fragment re-instantiation
2954        public SendConfirmDialogFragment() {}
2955
2956        public static SendConfirmDialogFragment newInstance(final int messageId,
2957                final boolean showToast, final ArrayList<String> recipients) {
2958            final SendConfirmDialogFragment frag = new SendConfirmDialogFragment();
2959            final Bundle args = new Bundle(3);
2960            args.putInt(MESSAGE_ID, messageId);
2961            args.putBoolean(SHOW_TOAST, showToast);
2962            args.putStringArrayList(RECIPIENTS, recipients);
2963            frag.setArguments(args);
2964            return frag;
2965        }
2966
2967        @Override
2968        public Dialog onCreateDialog(Bundle savedInstanceState) {
2969            final int messageId = getArguments().getInt(MESSAGE_ID);
2970            mShowToast = getArguments().getBoolean(SHOW_TOAST);
2971            mRecipients = getArguments().getStringArrayList(RECIPIENTS);
2972
2973            final int confirmTextId = (messageId == R.string.confirm_send_message) ?
2974                    R.string.ok : R.string.send;
2975
2976            return new AlertDialog.Builder(getActivity())
2977                    .setMessage(messageId)
2978                    .setPositiveButton(confirmTextId, this)
2979                    .setNegativeButton(R.string.cancel, null)
2980                    .create();
2981        }
2982
2983        @Override
2984        public void onClick(DialogInterface dialog, int which) {
2985            if (which == DialogInterface.BUTTON_POSITIVE) {
2986                ((ComposeActivity) getActivity()).finishSendConfirmDialog(mShowToast, mRecipients);
2987            }
2988        }
2989    }
2990
2991    private void finishSendConfirmDialog(
2992            final boolean showToast, final ArrayList<String> recipients) {
2993        performAdditionalSendOrSaveSanityChecks(false /* save */, showToast, recipients);
2994    }
2995
2996    // The list of recipients are used by the additional sendOrSave checks.
2997    // However, the send confirm dialog may be shown before performing
2998    // the additional checks. As a result, we need to plumb the recipient
2999    // list through the send confirm dialog so that
3000    // performAdditionalSendOrSaveChecks can be performed properly.
3001    private void showSendConfirmDialog(final int messageId,
3002            final boolean showToast, final ArrayList<String> recipients) {
3003        final DialogFragment frag = SendConfirmDialogFragment.newInstance(
3004                messageId, showToast, recipients);
3005        frag.show(getFragmentManager(), "send confirm");
3006    }
3007
3008    /**
3009     * Returns whether the ComposeArea believes there is any text in the body of
3010     * the composition. TODO: When ComposeArea controls the Body as well, add
3011     * that here.
3012     */
3013    public boolean isBodyEmpty() {
3014        return !mQuotedTextView.isTextIncluded();
3015    }
3016
3017    /**
3018     * Test to see if the subject is empty.
3019     *
3020     * @return boolean.
3021     */
3022    // TODO: this will likely go away when composeArea.focus() is implemented
3023    // after all the widget control is moved over.
3024    public boolean isSubjectEmpty() {
3025        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
3026    }
3027
3028    @VisibleForTesting
3029    public String getSubject() {
3030        return mSubject.getText().toString();
3031    }
3032
3033    private int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount,
3034            Message message, final Message refMessage, final CharSequence quotedText,
3035            SendOrSaveCallback callback, Handler handler, boolean save, int composeMode,
3036            ReplyFromAccount draftAccount, final ContentValues extraValues) {
3037        final ContentValues values = new ContentValues();
3038
3039        final String refMessageId = refMessage != null ? refMessage.uri.toString() : "";
3040
3041        MessageModification.putToAddresses(values, message.getToAddresses());
3042        MessageModification.putCcAddresses(values, message.getCcAddresses());
3043        MessageModification.putBccAddresses(values, message.getBccAddresses());
3044        MessageModification.putCustomFromAddress(values, message.getFrom());
3045
3046        MessageModification.putSubject(values, message.subject);
3047
3048        // bodyHtml already have the composing spans removed.
3049        final String htmlBody = message.bodyHtml;
3050        final String textBody = Utils.convertHtmlToPlainText(htmlBody);
3051        // fullbody will contain the actual body plus the quoted text.
3052        final String fullBody;
3053        final String quotedString;
3054        final boolean hasQuotedText = !TextUtils.isEmpty(quotedText);
3055        if (hasQuotedText) {
3056            // The quoted text is HTML at this point.
3057            quotedString = quotedText.toString();
3058            fullBody = htmlBody + quotedString;
3059            MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
3060            MessageModification.putAppendRefMessageContent(values, true /* include quoted */);
3061        } else {
3062            fullBody = htmlBody;
3063            quotedString = null;
3064        }
3065        if (refMessage != null) {
3066            // The code below might need to be revisited. The quoted text position is different
3067            // between text/html and text/plain parts and they should be stored seperately and
3068            // the right version should be used in the UI. text/html should have preference
3069            // if both exist.  Issues like this made me file b/14256940 to make sure that we
3070            // properly handle the existing of both text/html and text/plain parts and to verify
3071            // that we are not making some assumptions that break if there is no text/html part.
3072            int quotedTextPos = -1;
3073            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
3074                MessageModification.putBodyHtml(values, fullBody.toString());
3075                if (hasQuotedText) {
3076                    quotedTextPos = htmlBody.length() +
3077                            QuotedTextView.getQuotedTextOffset(quotedString);
3078                }
3079            }
3080            if (!TextUtils.isEmpty(refMessage.bodyText)) {
3081                MessageModification.putBody(values,
3082                        Utils.convertHtmlToPlainText(fullBody.toString()));
3083                if (hasQuotedText && (quotedTextPos == -1)) {
3084                    quotedTextPos = textBody.length();
3085                }
3086            }
3087            if (quotedTextPos != -1) {
3088                // The quoted text pos is the text/html version first and the text/plan version
3089                // if there is no text/html part. The reason for this is because preference
3090                // is given to text/html in the compose window if it exists. In the future, we
3091                // should calculate the index for both since the user could choose to compose
3092                // explicitly in text/plain.
3093                MessageModification.putQuoteStartPos(values, quotedTextPos);
3094            }
3095        } else {
3096            MessageModification.putBodyHtml(values, fullBody.toString());
3097            MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString()));
3098        }
3099        int draftType = getDraftType(composeMode);
3100        MessageModification.putDraftType(values, draftType);
3101        MessageModification.putAttachments(values, message.getAttachments());
3102        if (!TextUtils.isEmpty(refMessageId)) {
3103            MessageModification.putRefMessageId(values, refMessageId);
3104        }
3105        if (extraValues != null) {
3106            values.putAll(extraValues);
3107        }
3108        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount,
3109                values, refMessageId, message.getAttachments(), save);
3110        SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback,
3111                draftAccount);
3112
3113        callback.initializeSendOrSave(sendOrSaveTask);
3114        // Do the send/save action on the specified handler to avoid possible
3115        // ANRs
3116        handler.post(sendOrSaveTask);
3117
3118        return sendOrSaveMessage.requestId();
3119    }
3120
3121    /**
3122     * Removes any composing spans from the specified string.  This will create a new
3123     * SpannableString instance, as to not modify the behavior of the EditText view.
3124     */
3125    private static SpannableString removeComposingSpans(Spanned body) {
3126        final SpannableString messageBody = new SpannableString(body);
3127        BaseInputConnection.removeComposingSpans(messageBody);
3128
3129        // Remove watcher spans while we're at it, so any off-UI thread manipulation of these
3130        // spans doesn't trigger unexpected side-effects. This copy is essentially 100% detached
3131        // from the EditText.
3132        //
3133        // (must remove SpanWatchers first to avoid triggering them as we remove other spans)
3134        removeSpansOfType(messageBody, SpanWatcher.class);
3135        removeSpansOfType(messageBody, TextWatcher.class);
3136
3137        return messageBody;
3138    }
3139
3140    private static void removeSpansOfType(SpannableString str, Class<?> cls) {
3141        for (Object span : str.getSpans(0, str.length(), cls)) {
3142            str.removeSpan(span);
3143        }
3144    }
3145
3146    private static int getDraftType(int mode) {
3147        int draftType = -1;
3148        switch (mode) {
3149            case ComposeActivity.COMPOSE:
3150                draftType = DraftType.COMPOSE;
3151                break;
3152            case ComposeActivity.REPLY:
3153                draftType = DraftType.REPLY;
3154                break;
3155            case ComposeActivity.REPLY_ALL:
3156                draftType = DraftType.REPLY_ALL;
3157                break;
3158            case ComposeActivity.FORWARD:
3159                draftType = DraftType.FORWARD;
3160                break;
3161        }
3162        return draftType;
3163    }
3164
3165    /**
3166     * Derived classes should override this step to perform additional checks before
3167     * send or save. The default implementation simply calls {@link #sendOrSave(boolean, boolean)}.
3168     */
3169    protected void performAdditionalSendOrSaveSanityChecks(
3170            final boolean save, final boolean showToast, ArrayList<String> recipients) {
3171        sendOrSave(save, showToast);
3172    }
3173
3174    protected void sendOrSave(final boolean save, final boolean showToast) {
3175        // Check if user is a monkey. Monkeys can compose and hit send
3176        // button but are not allowed to send anything off the device.
3177        if (ActivityManager.isUserAMonkey()) {
3178            return;
3179        }
3180
3181        final SendOrSaveCallback callback = new SendOrSaveCallback() {
3182            // FIXME: unused
3183            private int mRestoredRequestId;
3184
3185            @Override
3186            public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
3187                synchronized (mActiveTasks) {
3188                    int numTasks = mActiveTasks.size();
3189                    if (numTasks == 0) {
3190                        // Start service so we won't be killed if this app is
3191                        // put in the background.
3192                        startService(new Intent(ComposeActivity.this, EmptyService.class));
3193                    }
3194
3195                    mActiveTasks.add(sendOrSaveTask);
3196                }
3197                if (sTestSendOrSaveCallback != null) {
3198                    sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
3199                }
3200            }
3201
3202            @Override
3203            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
3204                    Message message) {
3205                synchronized (mDraftLock) {
3206                    mDraftAccount = sendOrSaveMessage.mAccount;
3207                    mDraftId = message.id;
3208                    mDraft = message;
3209                    if (sRequestMessageIdMap != null) {
3210                        sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
3211                    }
3212                    // Cache request message map, in case the process is killed
3213                    saveRequestMap();
3214                }
3215                if (sTestSendOrSaveCallback != null) {
3216                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
3217                }
3218            }
3219
3220            @Override
3221            public Message getMessage() {
3222                synchronized (mDraftLock) {
3223                    return mDraft;
3224                }
3225            }
3226
3227            @Override
3228            public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
3229                // Update the last sent from account.
3230                if (mAccount != null) {
3231                    MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString());
3232                }
3233                if (success) {
3234                    // Successfully sent or saved so reset change markers
3235                    discardChanges();
3236                } else {
3237                    // A failure happened with saving/sending the draft
3238                    // TODO(pwestbro): add a better string that should be used
3239                    // when failing to send or save
3240                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
3241                            .show();
3242                }
3243
3244                int numTasks;
3245                synchronized (mActiveTasks) {
3246                    // Remove the task from the list of active tasks
3247                    mActiveTasks.remove(task);
3248                    numTasks = mActiveTasks.size();
3249                }
3250
3251                if (numTasks == 0) {
3252                    // Stop service so we can be killed.
3253                    stopService(new Intent(ComposeActivity.this, EmptyService.class));
3254                }
3255                if (sTestSendOrSaveCallback != null) {
3256                    sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
3257                }
3258            }
3259
3260            @Override
3261            public void incrementRecipientsTimesContacted(final List<String> recipients) {
3262                ComposeActivity.this.incrementRecipientsTimesContacted(recipients);
3263            }
3264        };
3265        setAccount(mReplyFromAccount.account);
3266
3267        final Spanned body = removeComposingSpans(mBodyView.getText());
3268        SEND_SAVE_TASK_HANDLER.post(new Runnable() {
3269            @Override
3270            public void run() {
3271                final Message msg = createMessage(mReplyFromAccount, mRefMessage, getMode(), body);
3272                mRequestId = sendOrSaveInternal(ComposeActivity.this, mReplyFromAccount, msg,
3273                        mRefMessage, mQuotedTextView.getQuotedTextIfIncluded(), callback,
3274                        SEND_SAVE_TASK_HANDLER, save, mComposeMode, mDraftAccount, mExtraValues);
3275            }
3276        });
3277
3278        // Don't display the toast if the user is just changing the orientation,
3279        // but we still need to save the draft to the cursor because this is how we restore
3280        // the attachments when the configuration change completes.
3281        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
3282            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
3283                    Toast.LENGTH_LONG).show();
3284        }
3285
3286        // Need to update variables here because the send or save completes
3287        // asynchronously even though the toast shows right away.
3288        discardChanges();
3289        updateSaveUi();
3290
3291        // If we are sending, finish the activity
3292        if (!save) {
3293            finish();
3294        }
3295    }
3296
3297    /**
3298     * Save the state of the request messageid map. This allows for the Gmail
3299     * process to be killed, but and still allow for ComposeActivity instances
3300     * to be recreated correctly.
3301     */
3302    private void saveRequestMap() {
3303        // TODO: store the request map in user preferences.
3304    }
3305
3306    @SuppressLint("NewApi")
3307    private void doAttach(String type) {
3308        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
3309        i.addCategory(Intent.CATEGORY_OPENABLE);
3310        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
3311        i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
3312        i.setType(type);
3313        mAddingAttachment = true;
3314        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
3315                RESULT_PICK_ATTACHMENT);
3316    }
3317
3318    private void showCcBccViews() {
3319        mCcBccView.show(true, true, true);
3320        if (mCcBccButton != null) {
3321            mCcBccButton.setVisibility(View.INVISIBLE);
3322        }
3323    }
3324
3325    private static String getActionString(int action) {
3326        final String msgType;
3327        switch (action) {
3328            case COMPOSE:
3329                msgType = "new_message";
3330                break;
3331            case REPLY:
3332                msgType = "reply";
3333                break;
3334            case REPLY_ALL:
3335                msgType = "reply_all";
3336                break;
3337            case FORWARD:
3338                msgType = "forward";
3339                break;
3340            default:
3341                msgType = "unknown";
3342                break;
3343        }
3344        return msgType;
3345    }
3346
3347    private void logSendOrSave(boolean save) {
3348        if (!Analytics.isLoggable() || mAttachmentsView == null) {
3349            return;
3350        }
3351
3352        final String category = (save) ? "message_save" : "message_send";
3353        final int attachmentCount = getAttachments().size();
3354        final String msgType = getActionString(mComposeMode);
3355        final String label;
3356        final long value;
3357        if (mComposeMode == COMPOSE) {
3358            label = Integer.toString(attachmentCount);
3359            value = attachmentCount;
3360        } else {
3361            label = null;
3362            value = 0;
3363        }
3364        Analytics.getInstance().sendEvent(category, msgType, label, value);
3365    }
3366
3367    @Override
3368    public boolean onNavigationItemSelected(int position, long itemId) {
3369        int initialComposeMode = mComposeMode;
3370        if (position == ComposeActivity.REPLY) {
3371            mComposeMode = ComposeActivity.REPLY;
3372        } else if (position == ComposeActivity.REPLY_ALL) {
3373            mComposeMode = ComposeActivity.REPLY_ALL;
3374        } else if (position == ComposeActivity.FORWARD) {
3375            mComposeMode = ComposeActivity.FORWARD;
3376        }
3377        clearChangeListeners();
3378        if (initialComposeMode != mComposeMode) {
3379            resetMessageForModeChange();
3380            if (mRefMessage != null) {
3381                setFieldsFromRefMessage(mComposeMode);
3382            }
3383            boolean showCc = false;
3384            boolean showBcc = false;
3385            if (mDraft != null) {
3386                // Following desktop behavior, if the user has added a BCC
3387                // field to a draft, we show it regardless of compose mode.
3388                showBcc = !TextUtils.isEmpty(mDraft.getBcc());
3389                // Use the draft to determine what to populate.
3390                // If the Bcc field is showing, show the Cc field whether it is populated or not.
3391                showCc = showBcc
3392                        || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL);
3393            }
3394            if (mRefMessage != null) {
3395                showCc = !TextUtils.isEmpty(mCc.getText());
3396                showBcc = !TextUtils.isEmpty(mBcc.getText());
3397            }
3398            mCcBccView.show(false, showCc, showBcc);
3399        }
3400        updateHideOrShowCcBcc();
3401        initChangeListeners();
3402        return true;
3403    }
3404
3405    @VisibleForTesting
3406    protected void resetMessageForModeChange() {
3407        // When switching between reply, reply all, forward,
3408        // follow the behavior of webview.
3409        // The contents of the following fields are cleared
3410        // so that they can be populated directly from the
3411        // ref message:
3412        // 1) Any recipient fields
3413        // 2) The subject
3414        mTo.setText("");
3415        mCc.setText("");
3416        mBcc.setText("");
3417        // Any edits to the subject are replaced with the original subject.
3418        mSubject.setText("");
3419
3420        // Any changes to the contents of the following fields are kept:
3421        // 1) Body
3422        // 2) Attachments
3423        // If the user made changes to attachments, keep their changes.
3424        if (!mAttachmentsChanged) {
3425            mAttachmentsView.deleteAllAttachments();
3426        }
3427    }
3428
3429    private class ComposeModeAdapter extends ArrayAdapter<String> {
3430
3431        private LayoutInflater mInflater;
3432
3433        public ComposeModeAdapter(Context context) {
3434            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
3435                    .getStringArray(R.array.compose_modes));
3436        }
3437
3438        private LayoutInflater getInflater() {
3439            if (mInflater == null) {
3440                mInflater = LayoutInflater.from(getContext());
3441            }
3442            return mInflater;
3443        }
3444
3445        @Override
3446        public View getView(int position, View convertView, ViewGroup parent) {
3447            if (convertView == null) {
3448                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
3449            }
3450            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
3451            return super.getView(position, convertView, parent);
3452        }
3453    }
3454
3455    @Override
3456    public void onRespondInline(String text) {
3457        appendToBody(text, false);
3458        mQuotedTextView.setUpperDividerVisible(false);
3459        mRespondedInline = true;
3460        if (!mBodyView.hasFocus()) {
3461            mBodyView.requestFocus();
3462        }
3463    }
3464
3465    /**
3466     * Append text to the body of the message. If there is no existing body
3467     * text, just sets the body to text.
3468     *
3469     * @param text Text to append
3470     * @param withSignature True to append a signature.
3471     */
3472    public void appendToBody(CharSequence text, boolean withSignature) {
3473        Editable bodyText = mBodyView.getEditableText();
3474        if (bodyText != null && bodyText.length() > 0) {
3475            bodyText.append(text);
3476        } else {
3477            setBody(text, withSignature);
3478        }
3479    }
3480
3481    /**
3482     * Set the body of the message.
3483     *
3484     * @param text text to set
3485     * @param withSignature True to append a signature.
3486     */
3487    public void setBody(CharSequence text, boolean withSignature) {
3488        mBodyView.setText(text);
3489        if (withSignature) {
3490            appendSignature();
3491        }
3492    }
3493
3494    private void appendSignature() {
3495        final String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
3496        final int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString());
3497        if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) {
3498            mSignature = newSignature;
3499            if (!TextUtils.isEmpty(mSignature)) {
3500                // Appending a signature does not count as changing text.
3501                mBodyView.removeTextChangedListener(this);
3502                mBodyView.append(convertToPrintableSignature(mSignature));
3503                mBodyView.addTextChangedListener(this);
3504            }
3505            resetBodySelection();
3506        }
3507    }
3508
3509    private String convertToPrintableSignature(String signature) {
3510        String signatureResource = getResources().getString(R.string.signature);
3511        if (signature == null) {
3512            signature = "";
3513        }
3514        return String.format(signatureResource, signature);
3515    }
3516
3517    @Override
3518    public void onAccountChanged() {
3519        mReplyFromAccount = mFromSpinner.getCurrentAccount();
3520        if (!mAccount.equals(mReplyFromAccount.account)) {
3521            // Clear a signature, if there was one.
3522            mBodyView.removeTextChangedListener(this);
3523            String oldSignature = mSignature;
3524            String bodyText = getBody().getText().toString();
3525            if (!TextUtils.isEmpty(oldSignature)) {
3526                int pos = getSignatureStartPosition(oldSignature, bodyText);
3527                if (pos > -1) {
3528                    mBodyView.setText(bodyText.substring(0, pos));
3529                }
3530            }
3531            setAccount(mReplyFromAccount.account);
3532            mBodyView.addTextChangedListener(this);
3533            // TODO: handle discarding attachments when switching accounts.
3534            // Only enable save for this draft if there is any other content
3535            // in the message.
3536            if (!isBlank()) {
3537                enableSave(true);
3538            }
3539            mReplyFromChanged = true;
3540            initRecipients();
3541        }
3542    }
3543
3544    public void enableSave(boolean enabled) {
3545        if (mSave != null) {
3546            mSave.setEnabled(enabled);
3547        }
3548    }
3549
3550    public static class DiscardConfirmDialogFragment extends DialogFragment {
3551        // Public no-args constructor needed for fragment re-instantiation
3552        public DiscardConfirmDialogFragment() {}
3553
3554        @Override
3555        public Dialog onCreateDialog(Bundle savedInstanceState) {
3556            return new AlertDialog.Builder(getActivity())
3557                    .setMessage(R.string.confirm_discard_text)
3558                    .setPositiveButton(R.string.discard,
3559                            new DialogInterface.OnClickListener() {
3560                                @Override
3561                                public void onClick(DialogInterface dialog, int which) {
3562                                    ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation();
3563                                }
3564                            })
3565                    .setNegativeButton(R.string.cancel, null)
3566                    .create();
3567        }
3568    }
3569
3570    private void doDiscard() {
3571        final DialogFragment frag = new DiscardConfirmDialogFragment();
3572        frag.show(getFragmentManager(), "discard confirm");
3573    }
3574    /**
3575     * Effectively discard the current message.
3576     *
3577     * This method is either invoked from the menu or from the dialog
3578     * once the user has confirmed that they want to discard the message.
3579     */
3580    private void doDiscardWithoutConfirmation() {
3581        synchronized (mDraftLock) {
3582            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
3583                ContentValues values = new ContentValues();
3584                values.put(BaseColumns._ID, mDraftId);
3585                if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) {
3586                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
3587                } else {
3588                    getContentResolver().delete(mDraft.uri, null, null);
3589                }
3590                // This is not strictly necessary (since we should not try to
3591                // save the draft after calling this) but it ensures that if we
3592                // do save again for some reason we make a new draft rather than
3593                // trying to resave an expunged draft.
3594                mDraftId = UIProvider.INVALID_MESSAGE_ID;
3595            }
3596        }
3597
3598        // Display a toast to let the user know
3599        Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
3600
3601        // This prevents the draft from being saved in onPause().
3602        discardChanges();
3603        mPerformedSendOrDiscard = true;
3604        finish();
3605    }
3606
3607    private void saveIfNeeded() {
3608        if (mAccount == null) {
3609            // We have not chosen an account yet so there's no way that we can save. This is ok,
3610            // though, since we are saving our state before AccountsActivity is activated. Thus, the
3611            // user has not interacted with us yet and there is no real state to save.
3612            return;
3613        }
3614
3615        if (shouldSave()) {
3616            doSave(!mAddingAttachment /* show toast */);
3617        }
3618    }
3619
3620    @Override
3621    public void onAttachmentDeleted() {
3622        mAttachmentsChanged = true;
3623        // If we are showing any attachments, make sure we have an upper
3624        // divider.
3625        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3626        updateSaveUi();
3627    }
3628
3629    @Override
3630    public void onAttachmentAdded() {
3631        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3632        mAttachmentsView.focusLastAttachment();
3633    }
3634
3635    /**
3636     * This is called any time one of our text fields changes.
3637     */
3638    @Override
3639    public void afterTextChanged(Editable s) {
3640        mTextChanged = true;
3641        updateSaveUi();
3642    }
3643
3644    @Override
3645    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3646        // Do nothing.
3647    }
3648
3649    @Override
3650    public void onTextChanged(CharSequence s, int start, int before, int count) {
3651        // Do nothing.
3652    }
3653
3654
3655    // There is a big difference between the text associated with an address changing
3656    // to add the display name or to format properly and a recipient being added or deleted.
3657    // Make sure we only notify of changes when a recipient has been added or deleted.
3658    private class RecipientTextWatcher implements TextWatcher {
3659        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
3660
3661        private RecipientEditTextView mView;
3662
3663        private TextWatcher mListener;
3664
3665        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
3666            mView = view;
3667            mListener = listener;
3668        }
3669
3670        @Override
3671        public void afterTextChanged(Editable s) {
3672            if (hasChanged()) {
3673                mListener.afterTextChanged(s);
3674            }
3675        }
3676
3677        private boolean hasChanged() {
3678            final ArrayList<String> currRecips = buildEmailAddressList(getAddressesFromList(mView));
3679            int totalCount = currRecips.size();
3680            int totalPrevCount = 0;
3681            for (Entry<String, Integer> entry : mContent.entrySet()) {
3682                totalPrevCount += entry.getValue();
3683            }
3684            if (totalCount != totalPrevCount) {
3685                return true;
3686            }
3687
3688            for (String recip : currRecips) {
3689                if (!mContent.containsKey(recip)) {
3690                    return true;
3691                } else {
3692                    int count = mContent.get(recip) - 1;
3693                    if (count < 0) {
3694                        return true;
3695                    } else {
3696                        mContent.put(recip, count);
3697                    }
3698                }
3699            }
3700            return false;
3701        }
3702
3703        @Override
3704        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3705            final ArrayList<String> recips = buildEmailAddressList(getAddressesFromList(mView));
3706            for (String recip : recips) {
3707                if (!mContent.containsKey(recip)) {
3708                    mContent.put(recip, 1);
3709                } else {
3710                    mContent.put(recip, (mContent.get(recip)) + 1);
3711                }
3712            }
3713        }
3714
3715        @Override
3716        public void onTextChanged(CharSequence s, int start, int before, int count) {
3717            // Do nothing.
3718        }
3719    }
3720
3721    /**
3722     * Returns a list of email addresses from the recipients. List only contains
3723     * email addresses strips additional info like the recipient's name.
3724     */
3725    private static ArrayList<String> buildEmailAddressList(String[] recips) {
3726        // Tokenize them all and put them in the list.
3727        final ArrayList<String> recipAddresses = Lists.newArrayListWithCapacity(recips.length);
3728        for (int i = 0; i < recips.length; i++) {
3729            recipAddresses.add(Rfc822Tokenizer.tokenize(recips[i])[0].getAddress());
3730        }
3731        return recipAddresses;
3732    }
3733
3734    public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) {
3735        if (sTestSendOrSaveCallback != null && testCallback != null) {
3736            throw new IllegalStateException("Attempting to register more than one test callback");
3737        }
3738        sTestSendOrSaveCallback = testCallback;
3739    }
3740
3741    @VisibleForTesting
3742    protected ArrayList<Attachment> getAttachments() {
3743        return mAttachmentsView.getAttachments();
3744    }
3745
3746    @Override
3747    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
3748        switch (id) {
3749            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3750                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3751                        null, null);
3752            case REFERENCE_MESSAGE_LOADER:
3753                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3754                        null, null);
3755            case LOADER_ACCOUNT_CURSOR:
3756                return new CursorLoader(this, MailAppProvider.getAccountsUri(),
3757                        UIProvider.ACCOUNTS_PROJECTION, null, null, null);
3758        }
3759        return null;
3760    }
3761
3762    @Override
3763    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
3764        int id = loader.getId();
3765        switch (id) {
3766            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3767                if (data != null && data.moveToFirst()) {
3768                    mRefMessage = new Message(data);
3769                    Intent intent = getIntent();
3770                    initFromRefMessage(mComposeMode);
3771                    finishSetup(mComposeMode, intent, null);
3772                    if (mComposeMode != FORWARD) {
3773                        String to = intent.getStringExtra(EXTRA_TO);
3774                        if (!TextUtils.isEmpty(to)) {
3775                            mRefMessage.setTo(null);
3776                            mRefMessage.setFrom(null);
3777                            clearChangeListeners();
3778                            mTo.append(to);
3779                            initChangeListeners();
3780                        }
3781                    }
3782                } else {
3783                    finish();
3784                }
3785                break;
3786            case REFERENCE_MESSAGE_LOADER:
3787                // Only populate mRefMessage and leave other fields untouched.
3788                if (data != null && data.moveToFirst()) {
3789                    mRefMessage = new Message(data);
3790                }
3791                finishSetup(mComposeMode, getIntent(), mInnerSavedState);
3792                break;
3793            case LOADER_ACCOUNT_CURSOR:
3794                if (data != null && data.moveToFirst()) {
3795                    // there are accounts now!
3796                    Account account;
3797                    final ArrayList<Account> accounts = new ArrayList<Account>();
3798                    final ArrayList<Account> initializedAccounts = new ArrayList<Account>();
3799                    do {
3800                        account = new Account(data);
3801                        if (account.isAccountReady()) {
3802                            initializedAccounts.add(account);
3803                        }
3804                        accounts.add(account);
3805                    } while (data.moveToNext());
3806                    if (initializedAccounts.size() > 0) {
3807                        findViewById(R.id.wait).setVisibility(View.GONE);
3808                        getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR);
3809                        findViewById(R.id.compose).setVisibility(View.VISIBLE);
3810                        mAccounts = initializedAccounts.toArray(
3811                                new Account[initializedAccounts.size()]);
3812
3813                        finishCreate();
3814                        invalidateOptionsMenu();
3815                    } else {
3816                        // Show "waiting"
3817                        account = accounts.size() > 0 ? accounts.get(0) : null;
3818                        showWaitFragment(account);
3819                    }
3820                }
3821                break;
3822        }
3823    }
3824
3825    private void showWaitFragment(Account account) {
3826        WaitFragment fragment = getWaitFragment();
3827        if (fragment != null) {
3828            fragment.updateAccount(account);
3829        } else {
3830            findViewById(R.id.wait).setVisibility(View.VISIBLE);
3831            replaceFragment(WaitFragment.newInstance(account, false /* expectingMessages */),
3832                    FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT);
3833        }
3834    }
3835
3836    private WaitFragment getWaitFragment() {
3837        return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT);
3838    }
3839
3840    private int replaceFragment(Fragment fragment, int transition, String tag) {
3841        FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction();
3842        fragmentTransaction.setTransition(transition);
3843        fragmentTransaction.replace(R.id.wait, fragment, tag);
3844        final int transactionId = fragmentTransaction.commitAllowingStateLoss();
3845        return transactionId;
3846    }
3847
3848    @Override
3849    public void onLoaderReset(Loader<Cursor> arg0) {
3850        // Do nothing.
3851    }
3852
3853    /**
3854     * Background task to convert the message's html to Spanned.
3855     */
3856    private class HtmlToSpannedTask extends AsyncTask<String, Void, Spanned> {
3857
3858        @Override
3859        protected Spanned doInBackground(String... input) {
3860            return HtmlUtils.htmlToSpan(input[0], mSpanConverterFactory);
3861        }
3862
3863        @Override
3864        protected void onPostExecute(Spanned spanned) {
3865            mBodyView.removeTextChangedListener(ComposeActivity.this);
3866            mBodyView.setText(spanned);
3867            mTextChanged = false;
3868            mBodyView.addTextChangedListener(ComposeActivity.this);
3869        }
3870    }
3871}
3872