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