ComposeActivity.java revision c825c1b98e5f416a95fea84171465eac86af374b
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                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC),
2442                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
2443            }
2444            mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
2445        }
2446
2447        private void incrementRecipientsTimesContacted(
2448                final String toAddresses, final String ccAddresses, final String bccAddresses) {
2449            final List<String> recipients = Lists.newArrayList();
2450            addAddressesToRecipientList(recipients, toAddresses);
2451            addAddressesToRecipientList(recipients, ccAddresses);
2452            addAddressesToRecipientList(recipients, bccAddresses);
2453            mSendOrSaveCallback.incrementRecipientsTimesContacted(recipients);
2454        }
2455
2456        private void addAddressesToRecipientList(
2457                final List<String> recipients, final String addressString) {
2458            if (recipients == null) {
2459                throw new IllegalArgumentException("recipientList cannot be null");
2460            }
2461            if (TextUtils.isEmpty(addressString)) {
2462                return;
2463            }
2464            final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString);
2465            for (final Rfc822Token token : tokens) {
2466                recipients.add(token.getAddress());
2467            }
2468        }
2469
2470        /**
2471         * Send or Save a message.
2472         */
2473        private void sendOrSaveMessage(final long messageIdToSave,
2474                final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) {
2475            final ContentResolver resolver = mContext.getContentResolver();
2476            final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID;
2477
2478            final String accountMethod = sendOrSaveMessage.mSave ?
2479                    UIProvider.AccountCallMethods.SAVE_MESSAGE :
2480                    UIProvider.AccountCallMethods.SEND_MESSAGE;
2481
2482            try {
2483                if (updateExistingMessage) {
2484                    sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
2485
2486                    callAccountSendSaveMethod(resolver,
2487                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2488                } else {
2489                    Uri messageUri = null;
2490                    final Bundle result = callAccountSendSaveMethod(resolver,
2491                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2492                    if (result != null) {
2493                        // If a non-null value was returned, then the provider handled the call
2494                        // method
2495                        messageUri = result.getParcelable(UIProvider.MessageColumns.URI);
2496                    }
2497                    if (sendOrSaveMessage.mSave && messageUri != null) {
2498                        final Cursor messageCursor = resolver.query(messageUri,
2499                                UIProvider.MESSAGE_PROJECTION, null, null, null);
2500                        if (messageCursor != null) {
2501                            try {
2502                                if (messageCursor.moveToFirst()) {
2503                                    // Broadcast notification that a new message has
2504                                    // been allocated
2505                                    mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
2506                                            new Message(messageCursor));
2507                                }
2508                            } finally {
2509                                messageCursor.close();
2510                            }
2511                        }
2512                    }
2513                }
2514            } finally {
2515                // Close any opened file descriptors
2516                closeOpenedAttachmentFds(sendOrSaveMessage);
2517            }
2518        }
2519
2520        private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) {
2521            final Bundle openedFds = sendOrSaveMessage.attachmentFds();
2522            if (openedFds != null) {
2523                final Set<String> keys = openedFds.keySet();
2524                for (final String key : keys) {
2525                    final ParcelFileDescriptor fd = openedFds.getParcelable(key);
2526                    if (fd != null) {
2527                        try {
2528                            fd.close();
2529                        } catch (IOException e) {
2530                            // Do nothing
2531                        }
2532                    }
2533                }
2534            }
2535        }
2536
2537        /**
2538         * Use the {@link ContentResolver#call} method to send or save the message.
2539         *
2540         * If this was successful, this method will return an non-null Bundle instance
2541         */
2542        private static Bundle callAccountSendSaveMethod(final ContentResolver resolver,
2543                final Account account, final String method,
2544                final SendOrSaveMessage sendOrSaveMessage) {
2545            // Copy all of the values from the content values to the bundle
2546            final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size());
2547            final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet();
2548
2549            for (Entry<String, Object> entry : valueSet) {
2550                final Object entryValue = entry.getValue();
2551                final String key = entry.getKey();
2552                if (entryValue instanceof String) {
2553                    methodExtras.putString(key, (String)entryValue);
2554                } else if (entryValue instanceof Boolean) {
2555                    methodExtras.putBoolean(key, (Boolean)entryValue);
2556                } else if (entryValue instanceof Integer) {
2557                    methodExtras.putInt(key, (Integer)entryValue);
2558                } else if (entryValue instanceof Long) {
2559                    methodExtras.putLong(key, (Long)entryValue);
2560                } else {
2561                    LogUtils.wtf(LOG_TAG, "Unexpected object type: %s",
2562                            entryValue.getClass().getName());
2563                }
2564            }
2565
2566            // If the SendOrSaveMessage has some opened fds, add them to the bundle
2567            final Bundle fdMap = sendOrSaveMessage.attachmentFds();
2568            if (fdMap != null) {
2569                methodExtras.putParcelable(
2570                        UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap);
2571            }
2572
2573            return resolver.call(account.uri, method, account.uri.toString(), methodExtras);
2574        }
2575    }
2576
2577    /**
2578     * Reports recipients that have been contacted in order to improve auto-complete
2579     * suggestions. Default behavior updates usage statistics in ContactsProvider.
2580     * @param recipients addresses
2581     */
2582    protected void incrementRecipientsTimesContacted(List<String> recipients) {
2583        final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(this);
2584        statsUpdater.updateWithAddress(recipients);
2585    }
2586
2587    @VisibleForTesting
2588    public static class SendOrSaveMessage {
2589        final ReplyFromAccount mAccount;
2590        final ContentValues mValues;
2591        final String mRefMessageId;
2592        @VisibleForTesting
2593        public final boolean mSave;
2594        final int mRequestId;
2595        private final Bundle mAttachmentFds;
2596
2597        public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values,
2598                String refMessageId, List<Attachment> attachments, boolean save) {
2599            mAccount = account;
2600            mValues = values;
2601            mRefMessageId = refMessageId;
2602            mSave = save;
2603            mRequestId = mValues.hashCode() ^ hashCode();
2604
2605            mAttachmentFds = initializeAttachmentFds(context, attachments);
2606        }
2607
2608        int requestId() {
2609            return mRequestId;
2610        }
2611
2612        Bundle attachmentFds() {
2613            return mAttachmentFds;
2614        }
2615
2616        /**
2617         * Opens {@link ParcelFileDescriptor} for each of the attachments.  This method must be
2618         * called before the ComposeActivity finishes.
2619         * Note: The caller is responsible for closing these file descriptors.
2620         */
2621        private static Bundle initializeAttachmentFds(final Context context,
2622                final List<Attachment> attachments) {
2623            if (attachments == null || attachments.size() == 0) {
2624                return null;
2625            }
2626
2627            final Bundle result = new Bundle(attachments.size());
2628            final ContentResolver resolver = context.getContentResolver();
2629
2630            for (Attachment attachment : attachments) {
2631                if (attachment == null || Utils.isEmpty(attachment.contentUri)) {
2632                    continue;
2633                }
2634
2635                ParcelFileDescriptor fileDescriptor;
2636                try {
2637                    fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r");
2638                } catch (FileNotFoundException e) {
2639                    LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment");
2640                    fileDescriptor = null;
2641                } catch (SecurityException e) {
2642                    // We have encountered a security exception when attempting to open the file
2643                    // specified by the content uri.  If the attachment has been cached, this
2644                    // isn't a problem, as even through the original permission may have been
2645                    // revoked, we have cached the file.  This will happen when saving/sending
2646                    // a previously saved draft.
2647                    // TODO(markwei): Expose whether the attachment has been cached through the
2648                    // attachment object.  This would allow us to limit when the log is made, as
2649                    // if the attachment has been cached, this really isn't an error
2650                    LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment");
2651                    // Just set the file descriptor to null, as the underlying provider needs
2652                    // to handle the file descriptor not being set.
2653                    fileDescriptor = null;
2654                }
2655
2656                if (fileDescriptor != null) {
2657                    result.putParcelable(attachment.contentUri.toString(), fileDescriptor);
2658                }
2659            }
2660
2661            return result;
2662        }
2663    }
2664
2665    /**
2666     * Get the to recipients.
2667     */
2668    public String[] getToAddresses() {
2669        return getAddressesFromList(mTo);
2670    }
2671
2672    /**
2673     * Get the cc recipients.
2674     */
2675    public String[] getCcAddresses() {
2676        return getAddressesFromList(mCc);
2677    }
2678
2679    /**
2680     * Get the bcc recipients.
2681     */
2682    public String[] getBccAddresses() {
2683        return getAddressesFromList(mBcc);
2684    }
2685
2686    public String[] getAddressesFromList(RecipientEditTextView list) {
2687        if (list == null) {
2688            return new String[0];
2689        }
2690        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
2691        int count = tokens.length;
2692        String[] result = new String[count];
2693        for (int i = 0; i < count; i++) {
2694            result[i] = tokens[i].toString();
2695        }
2696        return result;
2697    }
2698
2699    /**
2700     * Check for invalid email addresses.
2701     * @param to String array of email addresses to check.
2702     * @param wrongEmailsOut Emails addresses that were invalid.
2703     */
2704    public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) {
2705        if (mValidator == null) {
2706            return;
2707        }
2708        for (final String email : to) {
2709            if (!mValidator.isValid(email)) {
2710                wrongEmailsOut.add(email);
2711            }
2712        }
2713    }
2714
2715    public static class RecipientErrorDialogFragment extends DialogFragment {
2716        // Public no-args constructor needed for fragment re-instantiation
2717        public RecipientErrorDialogFragment() {}
2718
2719        public static RecipientErrorDialogFragment newInstance(final String message) {
2720            final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment();
2721            final Bundle args = new Bundle(1);
2722            args.putString("message", message);
2723            frag.setArguments(args);
2724            return frag;
2725        }
2726
2727        @Override
2728        public Dialog onCreateDialog(Bundle savedInstanceState) {
2729            final String message = getArguments().getString("message");
2730            return new AlertDialog.Builder(getActivity())
2731                    .setMessage(message)
2732                    .setPositiveButton(
2733                            R.string.ok, new Dialog.OnClickListener() {
2734                        @Override
2735                        public void onClick(DialogInterface dialog, int which) {
2736                            ((ComposeActivity)getActivity()).finishRecipientErrorDialog();
2737                        }
2738                    }).create();
2739        }
2740    }
2741
2742    private void finishRecipientErrorDialog() {
2743        // after the user dismisses the recipient error
2744        // dialog we want to make sure to refocus the
2745        // recipient to field so they can fix the issue
2746        // easily
2747        if (mTo != null) {
2748            mTo.requestFocus();
2749        }
2750    }
2751
2752    /**
2753     * Show an error because the user has entered an invalid recipient.
2754     */
2755    private void showRecipientErrorDialog(final String message) {
2756        final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message);
2757        frag.show(getFragmentManager(), "recipient error");
2758    }
2759
2760    /**
2761     * Update the state of the UI based on whether or not the current draft
2762     * needs to be saved and the message is not empty.
2763     */
2764    public void updateSaveUi() {
2765        if (mSave != null) {
2766            mSave.setEnabled((shouldSave() && !isBlank()));
2767        }
2768    }
2769
2770    /**
2771     * Returns true if we need to save the current draft.
2772     */
2773    private boolean shouldSave() {
2774        synchronized (mDraftLock) {
2775            // The message should only be saved if:
2776            // It hasn't been sent AND
2777            // Some text has been added to the message OR
2778            // an attachment has been added or removed
2779            // AND there is actually something in the draft to save.
2780            return (mTextChanged || mAttachmentsChanged || mReplyFromChanged)
2781                    && !isBlank();
2782        }
2783    }
2784
2785    /**
2786     * Check if all fields are blank.
2787     * @return boolean
2788     */
2789    public boolean isBlank() {
2790        // Need to check for null since isBlank() can be called from onPause()
2791        // before findViews() is called
2792        if (mSubject == null || mBodyView == null || mTo == null || mCc == null ||
2793                mAttachmentsView == null) {
2794            LogUtils.w(LOG_TAG, "null views in isBlank check");
2795            return true;
2796        }
2797        return mSubject.getText().length() == 0
2798                && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
2799                        mBodyView.getText().toString()) == 0)
2800                && mTo.length() == 0
2801                && mCc.length() == 0 && mBcc.length() == 0
2802                && mAttachmentsView.getAttachments().size() == 0;
2803    }
2804
2805    @VisibleForTesting
2806    protected int getSignatureStartPosition(String signature, String bodyText) {
2807        int startPos = -1;
2808
2809        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
2810            return startPos;
2811        }
2812
2813        int bodyLength = bodyText.length();
2814        int signatureLength = signature.length();
2815        String printableVersion = convertToPrintableSignature(signature);
2816        int printableLength = printableVersion.length();
2817
2818        if (bodyLength >= printableLength
2819                && bodyText.substring(bodyLength - printableLength)
2820                .equals(printableVersion)) {
2821            startPos = bodyLength - printableLength;
2822        } else if (bodyLength >= signatureLength
2823                && bodyText.substring(bodyLength - signatureLength)
2824                .equals(signature)) {
2825            startPos = bodyLength - signatureLength;
2826        }
2827        return startPos;
2828    }
2829
2830    /**
2831     * Allows any changes made by the user to be ignored. Called when the user
2832     * decides to discard a draft.
2833     */
2834    private void discardChanges() {
2835        mTextChanged = false;
2836        mAttachmentsChanged = false;
2837        mReplyFromChanged = false;
2838    }
2839
2840    /**
2841     * @param save True to save, false to send
2842     * @param showToast True to show a toast once the message is sent/saved
2843     */
2844    protected void sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
2845            final boolean orientationChanged, final boolean autoSend) {
2846        if (mAccounts == null || mAccount == null) {
2847            Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show();
2848            if (autoSend) {
2849                finish();
2850            }
2851            return;
2852        }
2853
2854        final String[] to, cc, bcc;
2855        if (orientationChanged) {
2856            to = cc = bcc = new String[0];
2857        } else {
2858            to = getToAddresses();
2859            cc = getCcAddresses();
2860            bcc = getBccAddresses();
2861        }
2862
2863        final ArrayList<String> recipients = buildEmailAddressList(to);
2864        recipients.addAll(buildEmailAddressList(cc));
2865        recipients.addAll(buildEmailAddressList(bcc));
2866
2867        // Don't let the user send to nobody (but it's okay to save a message
2868        // with no recipients)
2869        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
2870            showRecipientErrorDialog(getString(R.string.recipient_needed));
2871            return;
2872        }
2873
2874        List<String> wrongEmails = new ArrayList<String>();
2875        if (!save) {
2876            checkInvalidEmails(to, wrongEmails);
2877            checkInvalidEmails(cc, wrongEmails);
2878            checkInvalidEmails(bcc, wrongEmails);
2879        }
2880
2881        // Don't let the user send an email with invalid recipients
2882        if (wrongEmails.size() > 0) {
2883            String errorText = String.format(getString(R.string.invalid_recipient),
2884                    wrongEmails.get(0));
2885            showRecipientErrorDialog(errorText);
2886            return;
2887        }
2888
2889        if (!save) {
2890            if (autoSend) {
2891                // Skip all further checks during autosend. This flow is used by Android Wear
2892                // and Google Now.
2893                sendOrSave(save, showToast);
2894                return;
2895            }
2896
2897            // Show a warning before sending only if there are no attachments, body, or subject.
2898            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
2899                boolean warnAboutEmptySubject = isSubjectEmpty();
2900                boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0;
2901
2902                // A warning about an empty body may not be warranted when
2903                // forwarding mails, since a common use case is to forward
2904                // quoted text and not append any more text.
2905                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
2906
2907                // When we bring up a dialog warning the user about a send,
2908                // assume that they accept sending the message. If they do not,
2909                // the dialog listener is required to enable sending again.
2910                if (warnAboutEmptySubject) {
2911                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject,
2912                            showToast, recipients);
2913                    return;
2914                }
2915
2916                if (warnAboutEmptyBody) {
2917                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body,
2918                            showToast, recipients);
2919                    return;
2920                }
2921            }
2922            // Ask for confirmation to send.
2923            if (showSendConfirmation()) {
2924                showSendConfirmDialog(R.string.confirm_send_message, showToast, recipients);
2925                return;
2926            }
2927        }
2928
2929        performAdditionalSendOrSaveSanityChecks(save, showToast, recipients);
2930    }
2931
2932    /**
2933     * Returns a boolean indicating whether warnings should be shown for empty
2934     * subject and body fields
2935     *
2936     * @return True if a warning should be shown for empty text fields
2937     */
2938    protected boolean showEmptyTextWarnings() {
2939        return mAttachmentsView.getAttachments().size() == 0;
2940    }
2941
2942    /**
2943     * Returns a boolean indicating whether the user should confirm each send
2944     *
2945     * @return True if a warning should be on each send
2946     */
2947    protected boolean showSendConfirmation() {
2948        return mCachedSettings != null && mCachedSettings.confirmSend;
2949    }
2950
2951    public static class SendConfirmDialogFragment extends DialogFragment
2952            implements DialogInterface.OnClickListener {
2953
2954        private static final String MESSAGE_ID = "messageId";
2955        private static final String SHOW_TOAST = "showToast";
2956        private static final String RECIPIENTS = "recipients";
2957
2958        private boolean mShowToast;
2959
2960        private ArrayList<String> mRecipients;
2961
2962        // Public no-args constructor needed for fragment re-instantiation
2963        public SendConfirmDialogFragment() {}
2964
2965        public static SendConfirmDialogFragment newInstance(final int messageId,
2966                final boolean showToast, final ArrayList<String> recipients) {
2967            final SendConfirmDialogFragment frag = new SendConfirmDialogFragment();
2968            final Bundle args = new Bundle(3);
2969            args.putInt(MESSAGE_ID, messageId);
2970            args.putBoolean(SHOW_TOAST, showToast);
2971            args.putStringArrayList(RECIPIENTS, recipients);
2972            frag.setArguments(args);
2973            return frag;
2974        }
2975
2976        @Override
2977        public Dialog onCreateDialog(Bundle savedInstanceState) {
2978            final int messageId = getArguments().getInt(MESSAGE_ID);
2979            mShowToast = getArguments().getBoolean(SHOW_TOAST);
2980            mRecipients = getArguments().getStringArrayList(RECIPIENTS);
2981
2982            final int confirmTextId = (messageId == R.string.confirm_send_message) ?
2983                    R.string.ok : R.string.send;
2984
2985            return new AlertDialog.Builder(getActivity())
2986                    .setMessage(messageId)
2987                    .setPositiveButton(confirmTextId, this)
2988                    .setNegativeButton(R.string.cancel, null)
2989                    .create();
2990        }
2991
2992        @Override
2993        public void onClick(DialogInterface dialog, int which) {
2994            if (which == DialogInterface.BUTTON_POSITIVE) {
2995                ((ComposeActivity) getActivity()).finishSendConfirmDialog(mShowToast, mRecipients);
2996            }
2997        }
2998    }
2999
3000    private void finishSendConfirmDialog(
3001            final boolean showToast, final ArrayList<String> recipients) {
3002        performAdditionalSendOrSaveSanityChecks(false /* save */, showToast, recipients);
3003    }
3004
3005    // The list of recipients are used by the additional sendOrSave checks.
3006    // However, the send confirm dialog may be shown before performing
3007    // the additional checks. As a result, we need to plumb the recipient
3008    // list through the send confirm dialog so that
3009    // performAdditionalSendOrSaveChecks can be performed properly.
3010    private void showSendConfirmDialog(final int messageId,
3011            final boolean showToast, final ArrayList<String> recipients) {
3012        final DialogFragment frag = SendConfirmDialogFragment.newInstance(
3013                messageId, showToast, recipients);
3014        frag.show(getFragmentManager(), "send confirm");
3015    }
3016
3017    /**
3018     * Returns whether the ComposeArea believes there is any text in the body of
3019     * the composition. TODO: When ComposeArea controls the Body as well, add
3020     * that here.
3021     */
3022    public boolean isBodyEmpty() {
3023        return !mQuotedTextView.isTextIncluded();
3024    }
3025
3026    /**
3027     * Test to see if the subject is empty.
3028     *
3029     * @return boolean.
3030     */
3031    // TODO: this will likely go away when composeArea.focus() is implemented
3032    // after all the widget control is moved over.
3033    public boolean isSubjectEmpty() {
3034        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
3035    }
3036
3037    @VisibleForTesting
3038    public String getSubject() {
3039        return mSubject.getText().toString();
3040    }
3041
3042    private int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount,
3043            Message message, final Message refMessage, final CharSequence quotedText,
3044            SendOrSaveCallback callback, Handler handler, boolean save, int composeMode,
3045            ReplyFromAccount draftAccount, final ContentValues extraValues) {
3046        final ContentValues values = new ContentValues();
3047
3048        final String refMessageId = refMessage != null ? refMessage.uri.toString() : "";
3049
3050        MessageModification.putToAddresses(values, message.getToAddresses());
3051        MessageModification.putCcAddresses(values, message.getCcAddresses());
3052        MessageModification.putBccAddresses(values, message.getBccAddresses());
3053        MessageModification.putCustomFromAddress(values, message.getFrom());
3054
3055        MessageModification.putSubject(values, message.subject);
3056
3057        // bodyHtml already have the composing spans removed.
3058        final String htmlBody = message.bodyHtml;
3059        final String textBody = Utils.convertHtmlToPlainText(htmlBody);
3060        // fullbody will contain the actual body plus the quoted text.
3061        final String fullBody;
3062        final String quotedString;
3063        final boolean hasQuotedText = !TextUtils.isEmpty(quotedText);
3064        if (hasQuotedText) {
3065            // The quoted text is HTML at this point.
3066            quotedString = quotedText.toString();
3067            fullBody = htmlBody + quotedString;
3068            MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
3069            MessageModification.putAppendRefMessageContent(values, true /* include quoted */);
3070        } else {
3071            fullBody = htmlBody;
3072            quotedString = null;
3073        }
3074        if (refMessage != null) {
3075            // The code below might need to be revisited. The quoted text position is different
3076            // between text/html and text/plain parts and they should be stored seperately and
3077            // the right version should be used in the UI. text/html should have preference
3078            // if both exist.  Issues like this made me file b/14256940 to make sure that we
3079            // properly handle the existing of both text/html and text/plain parts and to verify
3080            // that we are not making some assumptions that break if there is no text/html part.
3081            int quotedTextPos = -1;
3082            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
3083                MessageModification.putBodyHtml(values, fullBody.toString());
3084                if (hasQuotedText) {
3085                    quotedTextPos = htmlBody.length() +
3086                            QuotedTextView.getQuotedTextOffset(quotedString);
3087                }
3088            }
3089            if (!TextUtils.isEmpty(refMessage.bodyText)) {
3090                MessageModification.putBody(values,
3091                        Utils.convertHtmlToPlainText(fullBody.toString()));
3092                if (hasQuotedText && (quotedTextPos == -1)) {
3093                    quotedTextPos = textBody.length();
3094                }
3095            }
3096            if (quotedTextPos != -1) {
3097                // The quoted text pos is the text/html version first and the text/plan version
3098                // if there is no text/html part. The reason for this is because preference
3099                // is given to text/html in the compose window if it exists. In the future, we
3100                // should calculate the index for both since the user could choose to compose
3101                // explicitly in text/plain.
3102                MessageModification.putQuoteStartPos(values, quotedTextPos);
3103            }
3104        } else {
3105            MessageModification.putBodyHtml(values, fullBody.toString());
3106            MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString()));
3107        }
3108        int draftType = getDraftType(composeMode);
3109        MessageModification.putDraftType(values, draftType);
3110        MessageModification.putAttachments(values, message.getAttachments());
3111        if (!TextUtils.isEmpty(refMessageId)) {
3112            MessageModification.putRefMessageId(values, refMessageId);
3113        }
3114        if (extraValues != null) {
3115            values.putAll(extraValues);
3116        }
3117        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount,
3118                values, refMessageId, message.getAttachments(), save);
3119        SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback,
3120                draftAccount);
3121
3122        callback.initializeSendOrSave(sendOrSaveTask);
3123        // Do the send/save action on the specified handler to avoid possible
3124        // ANRs
3125        handler.post(sendOrSaveTask);
3126
3127        return sendOrSaveMessage.requestId();
3128    }
3129
3130    /**
3131     * Removes any composing spans from the specified string.  This will create a new
3132     * SpannableString instance, as to not modify the behavior of the EditText view.
3133     */
3134    private static SpannableString removeComposingSpans(Spanned body) {
3135        final SpannableString messageBody = new SpannableString(body);
3136        BaseInputConnection.removeComposingSpans(messageBody);
3137
3138        // Remove watcher spans while we're at it, so any off-UI thread manipulation of these
3139        // spans doesn't trigger unexpected side-effects. This copy is essentially 100% detached
3140        // from the EditText.
3141        //
3142        // (must remove SpanWatchers first to avoid triggering them as we remove other spans)
3143        removeSpansOfType(messageBody, SpanWatcher.class);
3144        removeSpansOfType(messageBody, TextWatcher.class);
3145
3146        return messageBody;
3147    }
3148
3149    private static void removeSpansOfType(SpannableString str, Class<?> cls) {
3150        for (Object span : str.getSpans(0, str.length(), cls)) {
3151            str.removeSpan(span);
3152        }
3153    }
3154
3155    private static int getDraftType(int mode) {
3156        int draftType = -1;
3157        switch (mode) {
3158            case ComposeActivity.COMPOSE:
3159                draftType = DraftType.COMPOSE;
3160                break;
3161            case ComposeActivity.REPLY:
3162                draftType = DraftType.REPLY;
3163                break;
3164            case ComposeActivity.REPLY_ALL:
3165                draftType = DraftType.REPLY_ALL;
3166                break;
3167            case ComposeActivity.FORWARD:
3168                draftType = DraftType.FORWARD;
3169                break;
3170        }
3171        return draftType;
3172    }
3173
3174    /**
3175     * Derived classes should override this step to perform additional checks before
3176     * send or save. The default implementation simply calls {@link #sendOrSave(boolean, boolean)}.
3177     */
3178    protected void performAdditionalSendOrSaveSanityChecks(
3179            final boolean save, final boolean showToast, ArrayList<String> recipients) {
3180        sendOrSave(save, showToast);
3181    }
3182
3183    protected void sendOrSave(final boolean save, final boolean showToast) {
3184        // Check if user is a monkey. Monkeys can compose and hit send
3185        // button but are not allowed to send anything off the device.
3186        if (ActivityManager.isUserAMonkey()) {
3187            return;
3188        }
3189
3190        final SendOrSaveCallback callback = new SendOrSaveCallback() {
3191            // FIXME: unused
3192            private int mRestoredRequestId;
3193
3194            @Override
3195            public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
3196                synchronized (mActiveTasks) {
3197                    int numTasks = mActiveTasks.size();
3198                    if (numTasks == 0) {
3199                        // Start service so we won't be killed if this app is
3200                        // put in the background.
3201                        startService(new Intent(ComposeActivity.this, EmptyService.class));
3202                    }
3203
3204                    mActiveTasks.add(sendOrSaveTask);
3205                }
3206                if (sTestSendOrSaveCallback != null) {
3207                    sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
3208                }
3209            }
3210
3211            @Override
3212            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
3213                    Message message) {
3214                synchronized (mDraftLock) {
3215                    mDraftAccount = sendOrSaveMessage.mAccount;
3216                    mDraftId = message.id;
3217                    mDraft = message;
3218                    if (sRequestMessageIdMap != null) {
3219                        sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
3220                    }
3221                    // Cache request message map, in case the process is killed
3222                    saveRequestMap();
3223                }
3224                if (sTestSendOrSaveCallback != null) {
3225                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
3226                }
3227            }
3228
3229            @Override
3230            public Message getMessage() {
3231                synchronized (mDraftLock) {
3232                    return mDraft;
3233                }
3234            }
3235
3236            @Override
3237            public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
3238                // Update the last sent from account.
3239                if (mAccount != null) {
3240                    MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString());
3241                }
3242                if (success) {
3243                    // Successfully sent or saved so reset change markers
3244                    discardChanges();
3245                } else {
3246                    // A failure happened with saving/sending the draft
3247                    // TODO(pwestbro): add a better string that should be used
3248                    // when failing to send or save
3249                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
3250                            .show();
3251                }
3252
3253                int numTasks;
3254                synchronized (mActiveTasks) {
3255                    // Remove the task from the list of active tasks
3256                    mActiveTasks.remove(task);
3257                    numTasks = mActiveTasks.size();
3258                }
3259
3260                if (numTasks == 0) {
3261                    // Stop service so we can be killed.
3262                    stopService(new Intent(ComposeActivity.this, EmptyService.class));
3263                }
3264                if (sTestSendOrSaveCallback != null) {
3265                    sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
3266                }
3267            }
3268
3269            @Override
3270            public void incrementRecipientsTimesContacted(final List<String> recipients) {
3271                ComposeActivity.this.incrementRecipientsTimesContacted(recipients);
3272            }
3273        };
3274        setAccount(mReplyFromAccount.account);
3275
3276        final Spanned body = removeComposingSpans(mBodyView.getText());
3277        SEND_SAVE_TASK_HANDLER.post(new Runnable() {
3278            @Override
3279            public void run() {
3280                final Message msg = createMessage(mReplyFromAccount, mRefMessage, getMode(), body);
3281                mRequestId = sendOrSaveInternal(ComposeActivity.this, mReplyFromAccount, msg,
3282                        mRefMessage, mQuotedTextView.getQuotedTextIfIncluded(), callback,
3283                        SEND_SAVE_TASK_HANDLER, save, mComposeMode, mDraftAccount, mExtraValues);
3284            }
3285        });
3286
3287        // Don't display the toast if the user is just changing the orientation,
3288        // but we still need to save the draft to the cursor because this is how we restore
3289        // the attachments when the configuration change completes.
3290        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
3291            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
3292                    Toast.LENGTH_LONG).show();
3293        }
3294
3295        // Need to update variables here because the send or save completes
3296        // asynchronously even though the toast shows right away.
3297        discardChanges();
3298        updateSaveUi();
3299
3300        // If we are sending, finish the activity
3301        if (!save) {
3302            finish();
3303        }
3304    }
3305
3306    /**
3307     * Save the state of the request messageid map. This allows for the Gmail
3308     * process to be killed, but and still allow for ComposeActivity instances
3309     * to be recreated correctly.
3310     */
3311    private void saveRequestMap() {
3312        // TODO: store the request map in user preferences.
3313    }
3314
3315    @SuppressLint("NewApi")
3316    private void doAttach(String type) {
3317        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
3318        i.addCategory(Intent.CATEGORY_OPENABLE);
3319        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
3320        i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
3321        i.setType(type);
3322        mAddingAttachment = true;
3323        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
3324                RESULT_PICK_ATTACHMENT);
3325    }
3326
3327    private void showCcBccViews() {
3328        mCcBccView.show(true, true, true);
3329        if (mCcBccButton != null) {
3330            mCcBccButton.setVisibility(View.INVISIBLE);
3331        }
3332    }
3333
3334    private static String getActionString(int action) {
3335        final String msgType;
3336        switch (action) {
3337            case COMPOSE:
3338                msgType = "new_message";
3339                break;
3340            case REPLY:
3341                msgType = "reply";
3342                break;
3343            case REPLY_ALL:
3344                msgType = "reply_all";
3345                break;
3346            case FORWARD:
3347                msgType = "forward";
3348                break;
3349            default:
3350                msgType = "unknown";
3351                break;
3352        }
3353        return msgType;
3354    }
3355
3356    private void logSendOrSave(boolean save) {
3357        if (!Analytics.isLoggable() || mAttachmentsView == null) {
3358            return;
3359        }
3360
3361        final String category = (save) ? "message_save" : "message_send";
3362        final int attachmentCount = getAttachments().size();
3363        final String msgType = getActionString(mComposeMode);
3364        final String label;
3365        final long value;
3366        if (mComposeMode == COMPOSE) {
3367            label = Integer.toString(attachmentCount);
3368            value = attachmentCount;
3369        } else {
3370            label = null;
3371            value = 0;
3372        }
3373        Analytics.getInstance().sendEvent(category, msgType, label, value);
3374    }
3375
3376    @Override
3377    public boolean onNavigationItemSelected(int position, long itemId) {
3378        int initialComposeMode = mComposeMode;
3379        if (position == ComposeActivity.REPLY) {
3380            mComposeMode = ComposeActivity.REPLY;
3381        } else if (position == ComposeActivity.REPLY_ALL) {
3382            mComposeMode = ComposeActivity.REPLY_ALL;
3383        } else if (position == ComposeActivity.FORWARD) {
3384            mComposeMode = ComposeActivity.FORWARD;
3385        }
3386        clearChangeListeners();
3387        if (initialComposeMode != mComposeMode) {
3388            resetMessageForModeChange();
3389            if (mRefMessage != null) {
3390                setFieldsFromRefMessage(mComposeMode);
3391            }
3392            boolean showCc = false;
3393            boolean showBcc = false;
3394            if (mDraft != null) {
3395                // Following desktop behavior, if the user has added a BCC
3396                // field to a draft, we show it regardless of compose mode.
3397                showBcc = !TextUtils.isEmpty(mDraft.getBcc());
3398                // Use the draft to determine what to populate.
3399                // If the Bcc field is showing, show the Cc field whether it is populated or not.
3400                showCc = showBcc
3401                        || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL);
3402            }
3403            if (mRefMessage != null) {
3404                showCc = !TextUtils.isEmpty(mCc.getText());
3405                showBcc = !TextUtils.isEmpty(mBcc.getText());
3406            }
3407            mCcBccView.show(false, showCc, showBcc);
3408        }
3409        updateHideOrShowCcBcc();
3410        initChangeListeners();
3411        return true;
3412    }
3413
3414    @VisibleForTesting
3415    protected void resetMessageForModeChange() {
3416        // When switching between reply, reply all, forward,
3417        // follow the behavior of webview.
3418        // The contents of the following fields are cleared
3419        // so that they can be populated directly from the
3420        // ref message:
3421        // 1) Any recipient fields
3422        // 2) The subject
3423        mTo.setText("");
3424        mCc.setText("");
3425        mBcc.setText("");
3426        // Any edits to the subject are replaced with the original subject.
3427        mSubject.setText("");
3428
3429        // Any changes to the contents of the following fields are kept:
3430        // 1) Body
3431        // 2) Attachments
3432        // If the user made changes to attachments, keep their changes.
3433        if (!mAttachmentsChanged) {
3434            mAttachmentsView.deleteAllAttachments();
3435        }
3436    }
3437
3438    private class ComposeModeAdapter extends ArrayAdapter<String> {
3439
3440        private LayoutInflater mInflater;
3441
3442        public ComposeModeAdapter(Context context) {
3443            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
3444                    .getStringArray(R.array.compose_modes));
3445        }
3446
3447        private LayoutInflater getInflater() {
3448            if (mInflater == null) {
3449                mInflater = LayoutInflater.from(getContext());
3450            }
3451            return mInflater;
3452        }
3453
3454        @Override
3455        public View getView(int position, View convertView, ViewGroup parent) {
3456            if (convertView == null) {
3457                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
3458            }
3459            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
3460            return super.getView(position, convertView, parent);
3461        }
3462    }
3463
3464    @Override
3465    public void onRespondInline(String text) {
3466        appendToBody(text, false);
3467        mQuotedTextView.setUpperDividerVisible(false);
3468        mRespondedInline = true;
3469        if (!mBodyView.hasFocus()) {
3470            mBodyView.requestFocus();
3471        }
3472    }
3473
3474    /**
3475     * Append text to the body of the message. If there is no existing body
3476     * text, just sets the body to text.
3477     *
3478     * @param text Text to append
3479     * @param withSignature True to append a signature.
3480     */
3481    public void appendToBody(CharSequence text, boolean withSignature) {
3482        Editable bodyText = mBodyView.getEditableText();
3483        if (bodyText != null && bodyText.length() > 0) {
3484            bodyText.append(text);
3485        } else {
3486            setBody(text, withSignature);
3487        }
3488    }
3489
3490    /**
3491     * Set the body of the message.
3492     *
3493     * @param text text to set
3494     * @param withSignature True to append a signature.
3495     */
3496    public void setBody(CharSequence text, boolean withSignature) {
3497        mBodyView.setText(text);
3498        if (withSignature) {
3499            appendSignature();
3500        }
3501    }
3502
3503    private void appendSignature() {
3504        final String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
3505        final int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString());
3506        if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) {
3507            mSignature = newSignature;
3508            if (!TextUtils.isEmpty(mSignature)) {
3509                // Appending a signature does not count as changing text.
3510                mBodyView.removeTextChangedListener(this);
3511                mBodyView.append(convertToPrintableSignature(mSignature));
3512                mBodyView.addTextChangedListener(this);
3513            }
3514            resetBodySelection();
3515        }
3516    }
3517
3518    private String convertToPrintableSignature(String signature) {
3519        String signatureResource = getResources().getString(R.string.signature);
3520        if (signature == null) {
3521            signature = "";
3522        }
3523        return String.format(signatureResource, signature);
3524    }
3525
3526    @Override
3527    public void onAccountChanged() {
3528        mReplyFromAccount = mFromSpinner.getCurrentAccount();
3529        if (!mAccount.equals(mReplyFromAccount.account)) {
3530            // Clear a signature, if there was one.
3531            mBodyView.removeTextChangedListener(this);
3532            String oldSignature = mSignature;
3533            String bodyText = getBody().getText().toString();
3534            if (!TextUtils.isEmpty(oldSignature)) {
3535                int pos = getSignatureStartPosition(oldSignature, bodyText);
3536                if (pos > -1) {
3537                    mBodyView.setText(bodyText.substring(0, pos));
3538                }
3539            }
3540            setAccount(mReplyFromAccount.account);
3541            mBodyView.addTextChangedListener(this);
3542            // TODO: handle discarding attachments when switching accounts.
3543            // Only enable save for this draft if there is any other content
3544            // in the message.
3545            if (!isBlank()) {
3546                enableSave(true);
3547            }
3548            mReplyFromChanged = true;
3549            initRecipients();
3550        }
3551    }
3552
3553    public void enableSave(boolean enabled) {
3554        if (mSave != null) {
3555            mSave.setEnabled(enabled);
3556        }
3557    }
3558
3559    public static class DiscardConfirmDialogFragment extends DialogFragment {
3560        // Public no-args constructor needed for fragment re-instantiation
3561        public DiscardConfirmDialogFragment() {}
3562
3563        @Override
3564        public Dialog onCreateDialog(Bundle savedInstanceState) {
3565            return new AlertDialog.Builder(getActivity())
3566                    .setMessage(R.string.confirm_discard_text)
3567                    .setPositiveButton(R.string.discard,
3568                            new DialogInterface.OnClickListener() {
3569                                @Override
3570                                public void onClick(DialogInterface dialog, int which) {
3571                                    ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation();
3572                                }
3573                            })
3574                    .setNegativeButton(R.string.cancel, null)
3575                    .create();
3576        }
3577    }
3578
3579    private void doDiscard() {
3580        final DialogFragment frag = new DiscardConfirmDialogFragment();
3581        frag.show(getFragmentManager(), "discard confirm");
3582    }
3583    /**
3584     * Effectively discard the current message.
3585     *
3586     * This method is either invoked from the menu or from the dialog
3587     * once the user has confirmed that they want to discard the message.
3588     */
3589    private void doDiscardWithoutConfirmation() {
3590        synchronized (mDraftLock) {
3591            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
3592                ContentValues values = new ContentValues();
3593                values.put(BaseColumns._ID, mDraftId);
3594                if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) {
3595                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
3596                } else {
3597                    getContentResolver().delete(mDraft.uri, null, null);
3598                }
3599                // This is not strictly necessary (since we should not try to
3600                // save the draft after calling this) but it ensures that if we
3601                // do save again for some reason we make a new draft rather than
3602                // trying to resave an expunged draft.
3603                mDraftId = UIProvider.INVALID_MESSAGE_ID;
3604            }
3605        }
3606
3607        // Display a toast to let the user know
3608        Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
3609
3610        // This prevents the draft from being saved in onPause().
3611        discardChanges();
3612        mPerformedSendOrDiscard = true;
3613        finish();
3614    }
3615
3616    private void saveIfNeeded() {
3617        if (mAccount == null) {
3618            // We have not chosen an account yet so there's no way that we can save. This is ok,
3619            // though, since we are saving our state before AccountsActivity is activated. Thus, the
3620            // user has not interacted with us yet and there is no real state to save.
3621            return;
3622        }
3623
3624        if (shouldSave()) {
3625            doSave(!mAddingAttachment /* show toast */);
3626        }
3627    }
3628
3629    @Override
3630    public void onAttachmentDeleted() {
3631        mAttachmentsChanged = true;
3632        // If we are showing any attachments, make sure we have an upper
3633        // divider.
3634        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3635        updateSaveUi();
3636    }
3637
3638    @Override
3639    public void onAttachmentAdded() {
3640        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3641        mAttachmentsView.focusLastAttachment();
3642    }
3643
3644    /**
3645     * This is called any time one of our text fields changes.
3646     */
3647    @Override
3648    public void afterTextChanged(Editable s) {
3649        mTextChanged = true;
3650        updateSaveUi();
3651    }
3652
3653    @Override
3654    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3655        // Do nothing.
3656    }
3657
3658    @Override
3659    public void onTextChanged(CharSequence s, int start, int before, int count) {
3660        // Do nothing.
3661    }
3662
3663
3664    // There is a big difference between the text associated with an address changing
3665    // to add the display name or to format properly and a recipient being added or deleted.
3666    // Make sure we only notify of changes when a recipient has been added or deleted.
3667    private class RecipientTextWatcher implements TextWatcher {
3668        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
3669
3670        private RecipientEditTextView mView;
3671
3672        private TextWatcher mListener;
3673
3674        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
3675            mView = view;
3676            mListener = listener;
3677        }
3678
3679        @Override
3680        public void afterTextChanged(Editable s) {
3681            if (hasChanged()) {
3682                mListener.afterTextChanged(s);
3683            }
3684        }
3685
3686        private boolean hasChanged() {
3687            final ArrayList<String> currRecips = buildEmailAddressList(getAddressesFromList(mView));
3688            int totalCount = currRecips.size();
3689            int totalPrevCount = 0;
3690            for (Entry<String, Integer> entry : mContent.entrySet()) {
3691                totalPrevCount += entry.getValue();
3692            }
3693            if (totalCount != totalPrevCount) {
3694                return true;
3695            }
3696
3697            for (String recip : currRecips) {
3698                if (!mContent.containsKey(recip)) {
3699                    return true;
3700                } else {
3701                    int count = mContent.get(recip) - 1;
3702                    if (count < 0) {
3703                        return true;
3704                    } else {
3705                        mContent.put(recip, count);
3706                    }
3707                }
3708            }
3709            return false;
3710        }
3711
3712        @Override
3713        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3714            final ArrayList<String> recips = buildEmailAddressList(getAddressesFromList(mView));
3715            for (String recip : recips) {
3716                if (!mContent.containsKey(recip)) {
3717                    mContent.put(recip, 1);
3718                } else {
3719                    mContent.put(recip, (mContent.get(recip)) + 1);
3720                }
3721            }
3722        }
3723
3724        @Override
3725        public void onTextChanged(CharSequence s, int start, int before, int count) {
3726            // Do nothing.
3727        }
3728    }
3729
3730    /**
3731     * Returns a list of email addresses from the recipients. List only contains
3732     * email addresses strips additional info like the recipient's name.
3733     */
3734    private static ArrayList<String> buildEmailAddressList(String[] recips) {
3735        // Tokenize them all and put them in the list.
3736        final ArrayList<String> recipAddresses = Lists.newArrayListWithCapacity(recips.length);
3737        for (int i = 0; i < recips.length; i++) {
3738            recipAddresses.add(Rfc822Tokenizer.tokenize(recips[i])[0].getAddress());
3739        }
3740        return recipAddresses;
3741    }
3742
3743    public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) {
3744        if (sTestSendOrSaveCallback != null && testCallback != null) {
3745            throw new IllegalStateException("Attempting to register more than one test callback");
3746        }
3747        sTestSendOrSaveCallback = testCallback;
3748    }
3749
3750    @VisibleForTesting
3751    protected ArrayList<Attachment> getAttachments() {
3752        return mAttachmentsView.getAttachments();
3753    }
3754
3755    @Override
3756    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
3757        switch (id) {
3758            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3759                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3760                        null, null);
3761            case REFERENCE_MESSAGE_LOADER:
3762                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3763                        null, null);
3764            case LOADER_ACCOUNT_CURSOR:
3765                return new CursorLoader(this, MailAppProvider.getAccountsUri(),
3766                        UIProvider.ACCOUNTS_PROJECTION, null, null, null);
3767        }
3768        return null;
3769    }
3770
3771    @Override
3772    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
3773        int id = loader.getId();
3774        switch (id) {
3775            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3776                if (data != null && data.moveToFirst()) {
3777                    mRefMessage = new Message(data);
3778                    Intent intent = getIntent();
3779                    initFromRefMessage(mComposeMode);
3780                    finishSetup(mComposeMode, intent, null);
3781                    if (mComposeMode != FORWARD) {
3782                        String to = intent.getStringExtra(EXTRA_TO);
3783                        if (!TextUtils.isEmpty(to)) {
3784                            mRefMessage.setTo(null);
3785                            mRefMessage.setFrom(null);
3786                            clearChangeListeners();
3787                            mTo.append(to);
3788                            initChangeListeners();
3789                        }
3790                    }
3791                } else {
3792                    finish();
3793                }
3794                break;
3795            case REFERENCE_MESSAGE_LOADER:
3796                // Only populate mRefMessage and leave other fields untouched.
3797                if (data != null && data.moveToFirst()) {
3798                    mRefMessage = new Message(data);
3799                }
3800                finishSetup(mComposeMode, getIntent(), mInnerSavedState);
3801                break;
3802            case LOADER_ACCOUNT_CURSOR:
3803                if (data != null && data.moveToFirst()) {
3804                    // there are accounts now!
3805                    Account account;
3806                    final ArrayList<Account> accounts = new ArrayList<Account>();
3807                    final ArrayList<Account> initializedAccounts = new ArrayList<Account>();
3808                    do {
3809                        account = new Account(data);
3810                        if (account.isAccountReady()) {
3811                            initializedAccounts.add(account);
3812                        }
3813                        accounts.add(account);
3814                    } while (data.moveToNext());
3815                    if (initializedAccounts.size() > 0) {
3816                        findViewById(R.id.wait).setVisibility(View.GONE);
3817                        getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR);
3818                        findViewById(R.id.compose).setVisibility(View.VISIBLE);
3819                        mAccounts = initializedAccounts.toArray(
3820                                new Account[initializedAccounts.size()]);
3821
3822                        finishCreate();
3823                        invalidateOptionsMenu();
3824                    } else {
3825                        // Show "waiting"
3826                        account = accounts.size() > 0 ? accounts.get(0) : null;
3827                        showWaitFragment(account);
3828                    }
3829                }
3830                break;
3831        }
3832    }
3833
3834    private void showWaitFragment(Account account) {
3835        WaitFragment fragment = getWaitFragment();
3836        if (fragment != null) {
3837            fragment.updateAccount(account);
3838        } else {
3839            findViewById(R.id.wait).setVisibility(View.VISIBLE);
3840            replaceFragment(WaitFragment.newInstance(account, false /* expectingMessages */),
3841                    FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT);
3842        }
3843    }
3844
3845    private WaitFragment getWaitFragment() {
3846        return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT);
3847    }
3848
3849    private int replaceFragment(Fragment fragment, int transition, String tag) {
3850        FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction();
3851        fragmentTransaction.setTransition(transition);
3852        fragmentTransaction.replace(R.id.wait, fragment, tag);
3853        final int transactionId = fragmentTransaction.commitAllowingStateLoss();
3854        return transactionId;
3855    }
3856
3857    @Override
3858    public void onLoaderReset(Loader<Cursor> arg0) {
3859        // Do nothing.
3860    }
3861
3862    /**
3863     * Background task to convert the message's html to Spanned.
3864     */
3865    private class HtmlToSpannedTask extends AsyncTask<String, Void, Spanned> {
3866
3867        @Override
3868        protected Spanned doInBackground(String... input) {
3869            return HtmlUtils.htmlToSpan(input[0], mSpanConverterFactory);
3870        }
3871
3872        @Override
3873        protected void onPostExecute(Spanned spanned) {
3874            mBodyView.removeTextChangedListener(ComposeActivity.this);
3875            mBodyView.setText(spanned);
3876            mTextChanged = false;
3877            mBodyView.addTextChangedListener(ComposeActivity.this);
3878        }
3879    }
3880}
3881