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