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