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