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