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