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