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