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