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