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