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