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