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