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