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