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         try {
2022 
2023             if (handleSpecialAttachmentUri(contentUri)) {
2024                 return;
2025             }
2026 
2027             addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri));
2028         } catch (AttachmentFailureException e) {
2029             LogUtils.e(LOG_TAG, e, "Error adding attachment");
2030             showErrorToast(getResources().getString(
2031                     e.getErrorRes(),
2032                     AttachmentUtils.convertToHumanReadableSize(
2033                             getApplicationContext(), mAccount.settings.getMaxAttachmentSize())));
2034         }
2035     }
2036 
2037     /**
2038      * Allow subclasses to implement custom handling of attachments.
2039      *
2040      * @param contentUri a passed-in URI from a pick intent
2041      * @return true iff handled
2042      */
handleSpecialAttachmentUri(final Uri contentUri)2043     protected boolean handleSpecialAttachmentUri(final Uri contentUri) {
2044         return false;
2045     }
2046 
addAttachmentAndUpdateView(Attachment attachment)2047     private void addAttachmentAndUpdateView(Attachment attachment) {
2048         try {
2049             long size = mAttachmentsView.addAttachment(mAccount, attachment);
2050             if (size > 0) {
2051                 mAttachmentsChanged = true;
2052                 updateSaveUi();
2053             }
2054         } catch (AttachmentFailureException e) {
2055             LogUtils.e(LOG_TAG, e, "Error adding attachment");
2056             showAttachmentTooBigToast(e.getErrorRes());
2057         }
2058     }
2059 
initRecipientsFromRefMessage(Message refMessage, int action)2060     void initRecipientsFromRefMessage(Message refMessage, int action) {
2061         // Don't populate the address if this is a forward.
2062         if (action == ComposeActivity.FORWARD) {
2063             return;
2064         }
2065         initReplyRecipients(refMessage, action);
2066     }
2067 
2068     // TODO: This should be private.  This method shouldn't be used by ComposeActivityTests, as
2069     // it doesn't setup the state of the activity correctly
2070     @VisibleForTesting
initReplyRecipients(final Message refMessage, final int action)2071     void initReplyRecipients(final Message refMessage, final int action) {
2072         String[] sentToAddresses = refMessage.getToAddressesUnescaped();
2073         final Collection<String> toAddresses;
2074         final String[] fromAddresses = refMessage.getFromAddressesUnescaped();
2075         final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null;
2076         final String[] replyToAddresses = getReplyToAddresses(
2077                 refMessage.getReplyToAddressesUnescaped(), fromAddress);
2078 
2079         // If this is a reply, the Cc list is empty. If this is a reply-all, the
2080         // Cc list is the union of the To and Cc recipients of the original
2081         // message, excluding the current user's email address and any addresses
2082         // already on the To list.
2083         if (action == ComposeActivity.REPLY) {
2084             toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses);
2085             addToAddresses(toAddresses);
2086         } else if (action == ComposeActivity.REPLY_ALL) {
2087             final Set<String> ccAddresses = Sets.newHashSet();
2088             toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses);
2089             addToAddresses(toAddresses);
2090             addRecipients(ccAddresses, sentToAddresses);
2091             addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped());
2092             addCcAddresses(ccAddresses, toAddresses);
2093         }
2094     }
2095 
2096     // If there is no reply to address, the reply to address is the sender.
getReplyToAddresses(String[] replyTo, String from)2097     private static String[] getReplyToAddresses(String[] replyTo, String from) {
2098         boolean hasReplyTo = false;
2099         for (final String replyToAddress : replyTo) {
2100             if (!TextUtils.isEmpty(replyToAddress)) {
2101                 hasReplyTo = true;
2102             }
2103         }
2104         return hasReplyTo ? replyTo : new String[] {from};
2105     }
2106 
addToAddresses(Collection<String> addresses)2107     private void addToAddresses(Collection<String> addresses) {
2108         addAddressesToList(addresses, mTo);
2109     }
2110 
addCcAddresses(Collection<String> addresses, Collection<String> toAddresses)2111     private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
2112         addCcAddressesToList(tokenizeAddressList(addresses),
2113                 toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc);
2114     }
2115 
addBccAddresses(Collection<String> addresses)2116     private void addBccAddresses(Collection<String> addresses) {
2117         addAddressesToList(addresses, mBcc);
2118     }
2119 
2120     @VisibleForTesting
addCcAddressesToList(List<Rfc822Token[]> addresses, List<Rfc822Token[]> compareToList, RecipientEditTextView list)2121     protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
2122             List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
2123         String address;
2124 
2125         if (compareToList == null) {
2126             for (final Rfc822Token[] tokens : addresses) {
2127                 for (final Rfc822Token token : tokens) {
2128                     address = token.toString();
2129                     list.append(address + END_TOKEN);
2130                 }
2131             }
2132         } else {
2133             HashSet<String> compareTo = convertToHashSet(compareToList);
2134             for (final Rfc822Token[] tokens : addresses) {
2135                 for (final Rfc822Token token : tokens) {
2136                     address = token.toString();
2137                     // Check if this is a duplicate:
2138                     if (!compareTo.contains(token.getAddress())) {
2139                         // Get the address here
2140                         list.append(address + END_TOKEN);
2141                     }
2142                 }
2143             }
2144         }
2145     }
2146 
convertToHashSet(final List<Rfc822Token[]> list)2147     private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) {
2148         final HashSet<String> hash = new HashSet<String>();
2149         for (final Rfc822Token[] tokens : list) {
2150             for (final Rfc822Token token : tokens) {
2151                 hash.add(token.getAddress());
2152             }
2153         }
2154         return hash;
2155     }
2156 
tokenizeAddressList(Collection<String> addresses)2157     protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
2158         @VisibleForTesting
2159         List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
2160 
2161         for (String address: addresses) {
2162             tokenized.add(Rfc822Tokenizer.tokenize(address));
2163         }
2164         return tokenized;
2165     }
2166 
2167     @VisibleForTesting
addAddressesToList(Collection<String> addresses, RecipientEditTextView list)2168     void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
2169         for (String address : addresses) {
2170             addAddressToList(address, list);
2171         }
2172     }
2173 
addAddressToList(final String address, final RecipientEditTextView list)2174     private static void addAddressToList(final String address, final RecipientEditTextView list) {
2175         if (address == null || list == null)
2176             return;
2177 
2178         final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
2179 
2180         for (final Rfc822Token token : tokens) {
2181             list.append(token + END_TOKEN);
2182         }
2183     }
2184 
2185     @VisibleForTesting
initToRecipients(final String fullSenderAddress, final String[] replyToAddresses, final String[] inToAddresses)2186     protected Collection<String> initToRecipients(final String fullSenderAddress,
2187             final String[] replyToAddresses, final String[] inToAddresses) {
2188         // The To recipient is the reply-to address specified in the original
2189         // message, unless it is:
2190         // the current user OR a custom from of the current user, in which case
2191         // it's the To recipient list of the original message.
2192         // OR missing, in which case use the sender of the original message
2193         Set<String> toAddresses = Sets.newHashSet();
2194         for (final String replyToAddress : replyToAddresses) {
2195             if (!TextUtils.isEmpty(replyToAddress)
2196                     && !recipientMatchesThisAccount(replyToAddress)) {
2197                 toAddresses.add(replyToAddress);
2198             }
2199         }
2200         if (toAddresses.size() == 0) {
2201             // In this case, the user is replying to a message in which their
2202             // current account or some of their custom from addresses are the only
2203             // recipients and they sent the original message.
2204             if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress)
2205                     && recipientMatchesThisAccount(inToAddresses[0])) {
2206                 toAddresses.add(inToAddresses[0]);
2207                 return toAddresses;
2208             }
2209             // This happens if the user replies to a message they originally
2210             // wrote. In this case, "reply" really means "re-send," so we
2211             // target the original recipients. This works as expected even
2212             // if the user sent the original message to themselves.
2213             for (String address : inToAddresses) {
2214                 if (!recipientMatchesThisAccount(address)) {
2215                     toAddresses.add(address);
2216                 }
2217             }
2218         }
2219         return toAddresses;
2220     }
2221 
addRecipients(final Set<String> recipients, final String[] addresses)2222     private void addRecipients(final Set<String> recipients, final String[] addresses) {
2223         for (final String email : addresses) {
2224             // Do not add this account, or any of its custom from addresses, to
2225             // the list of recipients.
2226             final String recipientAddress = Address.getEmailAddress(email).getAddress();
2227             if (!recipientMatchesThisAccount(recipientAddress)) {
2228                 recipients.add(email.replace("\"\"", ""));
2229             }
2230         }
2231     }
2232 
2233     /**
2234      * A recipient matches this account if it has the same address as the
2235      * currently selected account OR one of the custom from addresses associated
2236      * with the currently selected account.
2237      * @param recipientAddress address we are comparing with the currently selected account
2238      */
recipientMatchesThisAccount(String recipientAddress)2239     protected boolean recipientMatchesThisAccount(String recipientAddress) {
2240         return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress,
2241                         mAccount.getReplyFroms());
2242     }
2243 
2244     /**
2245      * Returns a formatted subject string with the appropriate prefix for the action type.
2246      * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}.
2247      */
buildFormattedSubject(Resources res, String subject, int action)2248     public static String buildFormattedSubject(Resources res, String subject, int action) {
2249         final String prefix;
2250         final String correctedSubject;
2251         if (action == ComposeActivity.COMPOSE) {
2252             prefix = "";
2253         } else if (action == ComposeActivity.FORWARD) {
2254             prefix = res.getString(R.string.forward_subject_label);
2255         } else {
2256             prefix = res.getString(R.string.reply_subject_label);
2257         }
2258 
2259         if (TextUtils.isEmpty(subject)) {
2260             correctedSubject = prefix;
2261         } else {
2262             // Don't duplicate the prefix
2263             if (subject.toLowerCase().startsWith(prefix.toLowerCase())) {
2264                 correctedSubject = subject;
2265             } else {
2266                 correctedSubject = String.format(
2267                         res.getString(R.string.formatted_subject), prefix, subject);
2268             }
2269         }
2270 
2271         return correctedSubject;
2272     }
2273 
setSubject(Message refMessage, int action)2274     private void setSubject(Message refMessage, int action) {
2275         mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action));
2276     }
2277 
initRecipients()2278     private void initRecipients() {
2279         setupRecipients(mTo);
2280         setupRecipients(mCc);
2281         setupRecipients(mBcc);
2282     }
2283 
setupRecipients(RecipientEditTextView view)2284     private void setupRecipients(RecipientEditTextView view) {
2285         final DropdownChipLayouter layouter = getDropdownChipLayouter();
2286         if (layouter != null) {
2287             view.setDropdownChipLayouter(layouter);
2288         }
2289         view.setAdapter(getRecipientAdapter());
2290         view.setRecipientEntryItemClickedListener(this);
2291         if (mValidator == null) {
2292             final String accountName = mAccount.getEmailAddress();
2293             int offset = accountName.indexOf("@") + 1;
2294             String account = accountName;
2295             if (offset > 0) {
2296                 account = account.substring(offset);
2297             }
2298             mValidator = new Rfc822Validator(account);
2299         }
2300         view.setValidator(mValidator);
2301     }
2302 
2303     /**
2304      * Derived classes should override if they wish to provide their own autocomplete behavior.
2305      */
getRecipientAdapter()2306     public BaseRecipientAdapter getRecipientAdapter() {
2307         return new RecipientAdapter(this, mAccount);
2308     }
2309 
2310     /**
2311      * Derived classes should override this to provide their own dropdown behavior.
2312      * If the result is null, the default {@link com.android.ex.chips.DropdownChipLayouter}
2313      * is used.
2314      */
getDropdownChipLayouter()2315     public DropdownChipLayouter getDropdownChipLayouter() {
2316         return null;
2317     }
2318 
2319     @Override
onClick(View v)2320     public void onClick(View v) {
2321         final int id = v.getId();
2322         if (id == R.id.add_cc_bcc) {
2323             // Verify that cc/ bcc aren't showing.
2324             // Animate in cc/bcc.
2325             showCcBccViews();
2326         }
2327     }
2328 
2329     @Override
onFocusChange(View v, boolean hasFocus)2330     public void onFocusChange (View v, boolean hasFocus) {
2331         final int id = v.getId();
2332         if (hasFocus && (id == R.id.subject || id == R.id.body)) {
2333             // Collapse cc/bcc iff both are empty
2334             final boolean showCcBccFields = !TextUtils.isEmpty(mCc.getText()) ||
2335                     !TextUtils.isEmpty(mBcc.getText());
2336             mCcBccView.show(false /* animate */, showCcBccFields, showCcBccFields);
2337             mCcBccButton.setVisibility(showCcBccFields ? View.GONE : View.VISIBLE);
2338 
2339             // On phones autoscroll down so that Cc aligns to the top if we are showing cc/bcc.
2340             if (getResources().getBoolean(R.bool.auto_scroll_cc) && showCcBccFields) {
2341                 final int[] coords = new int[2];
2342                 mCc.getLocationOnScreen(coords);
2343 
2344                 // Subtract status bar and action bar height from y-coord.
2345                 getWindow().getDecorView().getWindowVisibleDisplayFrame(mRect);
2346                 final int deltaY = coords[1] - getSupportActionBar().getHeight() - mRect.top;
2347 
2348                 // Only scroll down
2349                 if (deltaY > 0) {
2350                     mScrollView.smoothScrollBy(0, deltaY);
2351                 }
2352             }
2353         }
2354     }
2355 
2356     @Override
onCreateOptionsMenu(Menu menu)2357     public boolean onCreateOptionsMenu(Menu menu) {
2358         final boolean superCreated = super.onCreateOptionsMenu(menu);
2359         // Don't render any menu items when there are no accounts.
2360         if (mAccounts == null || mAccounts.length == 0) {
2361             return superCreated;
2362         }
2363         MenuInflater inflater = getMenuInflater();
2364         inflater.inflate(R.menu.compose_menu, menu);
2365 
2366         /*
2367          * Start save in the correct enabled state.
2368          * 1) If a user launches compose from within gmail, save is disabled
2369          * until they add something, at which point, save is enabled, auto save
2370          * on exit; if the user empties everything, save is disabled, exiting does not
2371          * auto-save
2372          * 2) if a user replies/ reply all/ forwards from within gmail, save is
2373          * disabled until they change something, at which point, save is
2374          * enabled, auto save on exit; if the user empties everything, save is
2375          * disabled, exiting does not auto-save.
2376          * 3) If a user launches compose from another application and something
2377          * gets populated (attachments, recipients, body, subject, etc), save is
2378          * enabled, auto save on exit; if the user empties everything, save is
2379          * disabled, exiting does not auto-save
2380          */
2381         mSave = menu.findItem(R.id.save);
2382         String action = getIntent() != null ? getIntent().getAction() : null;
2383         enableSave(mInnerSavedState != null ?
2384                 mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED)
2385                     : (Intent.ACTION_SEND.equals(action)
2386                             || Intent.ACTION_SEND_MULTIPLE.equals(action)
2387                             || Intent.ACTION_SENDTO.equals(action)
2388                             || isDraftDirty()));
2389 
2390         final MenuItem helpItem = menu.findItem(R.id.help_info_menu_item);
2391         final MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item);
2392         final MenuItem attachFromServiceItem = menu.findItem(R.id.attach_from_service_stub1);
2393         if (helpItem != null) {
2394             helpItem.setVisible(mAccount != null
2395                     && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT));
2396         }
2397         if (sendFeedbackItem != null) {
2398             sendFeedbackItem.setVisible(mAccount != null
2399                     && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK));
2400         }
2401         if (attachFromServiceItem != null) {
2402             attachFromServiceItem.setVisible(shouldEnableAttachFromServiceMenu(mAccount));
2403         }
2404 
2405         // Show attach picture on pre-K devices.
2406         menu.findItem(R.id.add_photo_attachment).setVisible(!Utils.isRunningKitkatOrLater());
2407 
2408         return true;
2409     }
2410 
2411     @Override
onOptionsItemSelected(MenuItem item)2412     public boolean onOptionsItemSelected(MenuItem item) {
2413         final int id = item.getItemId();
2414 
2415         Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id,
2416                 "compose", 0);
2417 
2418         boolean handled = true;
2419         if (id == R.id.add_file_attachment) {
2420             doAttach(MIME_TYPE_ALL);
2421         } else if (id == R.id.add_photo_attachment) {
2422             doAttach(MIME_TYPE_PHOTO);
2423         } else if (id == R.id.save) {
2424             doSave(true);
2425         } else if (id == R.id.send) {
2426             doSend();
2427         } else if (id == R.id.discard) {
2428             doDiscard();
2429         } else if (id == R.id.settings) {
2430             Utils.showSettings(this, mAccount);
2431         } else if (id == android.R.id.home) {
2432             onAppUpPressed();
2433         } else if (id == R.id.help_info_menu_item) {
2434             Utils.showHelp(this, mAccount, getString(R.string.compose_help_context));
2435         } else {
2436             handled = false;
2437         }
2438         return handled || super.onOptionsItemSelected(item);
2439     }
2440 
2441     @Override
onBackPressed()2442     public void onBackPressed() {
2443         // If we are showing the wait fragment, just exit.
2444         if (getWaitFragment() != null) {
2445             finish();
2446         } else {
2447             super.onBackPressed();
2448         }
2449     }
2450 
2451     /**
2452      * Carries out the "up" action in the action bar.
2453      */
onAppUpPressed()2454     private void onAppUpPressed() {
2455         if (mLaunchedFromEmail) {
2456             // If this was started from Gmail, simply treat app up as the system back button, so
2457             // that the last view is restored.
2458             onBackPressed();
2459             return;
2460         }
2461 
2462         // Fire the main activity to ensure it launches the "top" screen of mail.
2463         // Since the main Activity is singleTask, it should revive that task if it was already
2464         // started.
2465         final Intent mailIntent = Utils.createViewInboxIntent(mAccount);
2466         mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
2467                 Intent.FLAG_ACTIVITY_TASK_ON_HOME);
2468         startActivity(mailIntent);
2469         finish();
2470     }
2471 
doSend()2472     private void doSend() {
2473         sendOrSaveWithSanityChecks(false, true, false, false);
2474         logSendOrSave(false /* save */);
2475         mPerformedSendOrDiscard = true;
2476     }
2477 
doSave(boolean showToast)2478     private void doSave(boolean showToast) {
2479         sendOrSaveWithSanityChecks(true, showToast, false, false);
2480     }
2481 
2482     @Override
onRecipientEntryItemClicked(int charactersTyped, int position)2483     public void onRecipientEntryItemClicked(int charactersTyped, int position) {
2484         // Send analytics of characters typed and position in dropdown selected.
2485         Analytics.getInstance().sendEvent(
2486                 "suggest_click", Integer.toString(charactersTyped), Integer.toString(position), 0);
2487     }
2488 
2489     @VisibleForTesting
2490     public interface SendOrSaveCallback {
initializeSendOrSave()2491         void initializeSendOrSave();
notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message)2492         void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
getMessageId()2493         long getMessageId();
sendOrSaveFinished(SendOrSaveMessage message, boolean success)2494         void sendOrSaveFinished(SendOrSaveMessage message, boolean success);
2495     }
2496 
runSendOrSaveProviderCalls(SendOrSaveMessage sendOrSaveMessage, SendOrSaveCallback callback, ReplyFromAccount currReplyFromAccount, ReplyFromAccount originalReplyFromAccount)2497     private void runSendOrSaveProviderCalls(SendOrSaveMessage sendOrSaveMessage,
2498             SendOrSaveCallback callback, ReplyFromAccount currReplyFromAccount,
2499             ReplyFromAccount originalReplyFromAccount) {
2500         long messageId = callback.getMessageId();
2501         // If a previous draft has been saved, in an account that is different
2502         // than what the user wants to send from, remove the old draft, and treat this
2503         // as a new message
2504         if (originalReplyFromAccount != null
2505                 && !currReplyFromAccount.account.uri.equals(originalReplyFromAccount.account.uri)) {
2506             if (messageId != UIProvider.INVALID_MESSAGE_ID) {
2507                 ContentResolver resolver = getContentResolver();
2508                 ContentValues values = new ContentValues();
2509                 values.put(BaseColumns._ID, messageId);
2510                 if (originalReplyFromAccount.account.expungeMessageUri != null) {
2511                     new ContentProviderTask.UpdateTask()
2512                             .run(resolver, originalReplyFromAccount.account.expungeMessageUri,
2513                                     values, null, null);
2514                 } else {
2515                     // TODO(mindyp) delete the conversation.
2516                 }
2517                 // reset messageId to 0, so a new message will be created
2518                 messageId = UIProvider.INVALID_MESSAGE_ID;
2519             }
2520         }
2521 
2522         final long messageIdToSave = messageId;
2523         sendOrSaveMessage(callback, messageIdToSave, sendOrSaveMessage, currReplyFromAccount);
2524 
2525         if (!sendOrSaveMessage.mSave) {
2526             incrementRecipientsTimesContacted(
2527                     (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO),
2528                     (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC),
2529                     (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
2530         }
2531         callback.sendOrSaveFinished(sendOrSaveMessage, true);
2532     }
2533 
incrementRecipientsTimesContacted( final String toAddresses, final String ccAddresses, final String bccAddresses)2534     private void incrementRecipientsTimesContacted(
2535             final String toAddresses, final String ccAddresses, final String bccAddresses) {
2536         final List<String> recipients = Lists.newArrayList();
2537         addAddressesToRecipientList(recipients, toAddresses);
2538         addAddressesToRecipientList(recipients, ccAddresses);
2539         addAddressesToRecipientList(recipients, bccAddresses);
2540         incrementRecipientsTimesContacted(recipients);
2541     }
2542 
addAddressesToRecipientList( final List<String> recipients, final String addressString)2543     private void addAddressesToRecipientList(
2544             final List<String> recipients, final String addressString) {
2545         if (recipients == null) {
2546             throw new IllegalArgumentException("recipientList cannot be null");
2547         }
2548         if (TextUtils.isEmpty(addressString)) {
2549             return;
2550         }
2551         final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString);
2552         for (final Rfc822Token token : tokens) {
2553             recipients.add(token.getAddress());
2554         }
2555     }
2556 
2557     /**
2558      * Send or Save a message.
2559      */
sendOrSaveMessage(SendOrSaveCallback callback, final long messageIdToSave, final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount)2560     private void sendOrSaveMessage(SendOrSaveCallback callback, final long messageIdToSave,
2561             final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) {
2562         final ContentResolver resolver = getContentResolver();
2563         final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID;
2564 
2565         final String accountMethod = sendOrSaveMessage.mSave ?
2566                 UIProvider.AccountCallMethods.SAVE_MESSAGE :
2567                 UIProvider.AccountCallMethods.SEND_MESSAGE;
2568 
2569         try {
2570             if (updateExistingMessage) {
2571                 sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
2572 
2573                 callAccountSendSaveMethod(resolver,
2574                         selectedAccount.account, accountMethod, sendOrSaveMessage);
2575             } else {
2576                 Uri messageUri = null;
2577                 final Bundle result = callAccountSendSaveMethod(resolver,
2578                         selectedAccount.account, accountMethod, sendOrSaveMessage);
2579                 if (result != null) {
2580                     // If a non-null value was returned, then the provider handled the call
2581                     // method
2582                     messageUri = result.getParcelable(UIProvider.MessageColumns.URI);
2583                 }
2584                 if (sendOrSaveMessage.mSave && messageUri != null) {
2585                     final Cursor messageCursor = resolver.query(messageUri,
2586                             UIProvider.MESSAGE_PROJECTION, null, null, null);
2587                     if (messageCursor != null) {
2588                         try {
2589                             if (messageCursor.moveToFirst()) {
2590                                 // Broadcast notification that a new message has
2591                                 // been allocated
2592                                 callback.notifyMessageIdAllocated(sendOrSaveMessage,
2593                                         new Message(messageCursor));
2594                             }
2595                         } finally {
2596                             messageCursor.close();
2597                         }
2598                     }
2599                 }
2600             }
2601         } finally {
2602             // Close any opened file descriptors
2603             closeOpenedAttachmentFds(sendOrSaveMessage);
2604         }
2605     }
2606 
closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage)2607     private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) {
2608         final Bundle openedFds = sendOrSaveMessage.attachmentFds();
2609         if (openedFds != null) {
2610             final Set<String> keys = openedFds.keySet();
2611             for (final String key : keys) {
2612                 final ParcelFileDescriptor fd = openedFds.getParcelable(key);
2613                 if (fd != null) {
2614                     try {
2615                         fd.close();
2616                     } catch (IOException e) {
2617                         // Do nothing
2618                     }
2619                 }
2620             }
2621         }
2622     }
2623 
2624     /**
2625      * Use the {@link ContentResolver#call} method to send or save the message.
2626      *
2627      * If this was successful, this method will return an non-null Bundle instance
2628      */
callAccountSendSaveMethod(final ContentResolver resolver, final Account account, final String method, final SendOrSaveMessage sendOrSaveMessage)2629     private static Bundle callAccountSendSaveMethod(final ContentResolver resolver,
2630             final Account account, final String method,
2631             final SendOrSaveMessage sendOrSaveMessage) {
2632         // Copy all of the values from the content values to the bundle
2633         final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size());
2634         final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet();
2635 
2636         for (Entry<String, Object> entry : valueSet) {
2637             final Object entryValue = entry.getValue();
2638             final String key = entry.getKey();
2639             if (entryValue instanceof String) {
2640                 methodExtras.putString(key, (String)entryValue);
2641             } else if (entryValue instanceof Boolean) {
2642                 methodExtras.putBoolean(key, (Boolean)entryValue);
2643             } else if (entryValue instanceof Integer) {
2644                 methodExtras.putInt(key, (Integer)entryValue);
2645             } else if (entryValue instanceof Long) {
2646                 methodExtras.putLong(key, (Long)entryValue);
2647             } else {
2648                 LogUtils.wtf(LOG_TAG, "Unexpected object type: %s",
2649                         entryValue.getClass().getName());
2650             }
2651         }
2652 
2653         // If the SendOrSaveMessage has some opened fds, add them to the bundle
2654         final Bundle fdMap = sendOrSaveMessage.attachmentFds();
2655         if (fdMap != null) {
2656             methodExtras.putParcelable(
2657                     UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap);
2658         }
2659 
2660         return resolver.call(account.uri, method, account.uri.toString(), methodExtras);
2661     }
2662 
2663     /**
2664      * Reports recipients that have been contacted in order to improve auto-complete
2665      * suggestions. Default behavior updates usage statistics in ContactsProvider.
2666      * @param recipients addresses
2667      */
incrementRecipientsTimesContacted(List<String> recipients)2668     protected void incrementRecipientsTimesContacted(List<String> recipients) {
2669         final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(this);
2670         statsUpdater.updateWithAddress(recipients);
2671     }
2672 
2673     @VisibleForTesting
2674     public static class SendOrSaveMessage {
2675         final int mRequestId;
2676         final ContentValues mValues;
2677         final String mRefMessageId;
2678         @VisibleForTesting
2679         public final boolean mSave;
2680         private final Bundle mAttachmentFds;
2681 
SendOrSaveMessage(Context context, int requestId, ContentValues values, String refMessageId, List<Attachment> attachments, Bundle optionalAttachmentFds, boolean save)2682         public SendOrSaveMessage(Context context, int requestId, ContentValues values,
2683                 String refMessageId, List<Attachment> attachments, Bundle optionalAttachmentFds,
2684                 boolean save) {
2685             mRequestId = requestId;
2686             mValues = values;
2687             mRefMessageId = refMessageId;
2688             mSave = save;
2689 
2690             // If the attachments are already open for us (pre-JB), then don't open them again
2691             if (optionalAttachmentFds != null) {
2692                 mAttachmentFds = optionalAttachmentFds;
2693             } else {
2694                 mAttachmentFds = initializeAttachmentFds(context, attachments);
2695             }
2696         }
2697 
attachmentFds()2698         Bundle attachmentFds() {
2699             return mAttachmentFds;
2700         }
2701     }
2702 
2703     /**
2704      * Opens {@link ParcelFileDescriptor} for each of the attachments.  This method must be
2705      * called before the ComposeActivity finishes.
2706      * Note: The caller is responsible for closing these file descriptors.
2707      */
initializeAttachmentFds(final Context context, final List<Attachment> attachments)2708     private static Bundle initializeAttachmentFds(final Context context,
2709             final List<Attachment> attachments) {
2710         if (attachments == null || attachments.size() == 0) {
2711             return null;
2712         }
2713 
2714         final Bundle result = new Bundle(attachments.size());
2715         final ContentResolver resolver = context.getContentResolver();
2716 
2717         for (Attachment attachment : attachments) {
2718             if (attachment == null || Utils.isEmpty(attachment.contentUri)) {
2719                 continue;
2720             }
2721 
2722             ParcelFileDescriptor fileDescriptor;
2723             try {
2724                 fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r");
2725             } catch (FileNotFoundException e) {
2726                 LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment");
2727                 fileDescriptor = null;
2728             } catch (SecurityException e) {
2729                 // We have encountered a security exception when attempting to open the file
2730                 // specified by the content uri.  If the attachment has been cached, this
2731                 // isn't a problem, as even through the original permission may have been
2732                 // revoked, we have cached the file.  This will happen when saving/sending
2733                 // a previously saved draft.
2734                 // TODO(markwei): Expose whether the attachment has been cached through the
2735                 // attachment object.  This would allow us to limit when the log is made, as
2736                 // if the attachment has been cached, this really isn't an error
2737                 LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment");
2738                 // Just set the file descriptor to null, as the underlying provider needs
2739                 // to handle the file descriptor not being set.
2740                 fileDescriptor = null;
2741             }
2742 
2743             if (fileDescriptor != null) {
2744                 result.putParcelable(attachment.contentUri.toString(), fileDescriptor);
2745             }
2746         }
2747 
2748         return result;
2749     }
2750 
2751     /**
2752      * Get the to recipients.
2753      */
getToAddresses()2754     public String[] getToAddresses() {
2755         return getAddressesFromList(mTo);
2756     }
2757 
2758     /**
2759      * Get the cc recipients.
2760      */
getCcAddresses()2761     public String[] getCcAddresses() {
2762         return getAddressesFromList(mCc);
2763     }
2764 
2765     /**
2766      * Get the bcc recipients.
2767      */
getBccAddresses()2768     public String[] getBccAddresses() {
2769         return getAddressesFromList(mBcc);
2770     }
2771 
getAddressesFromList(RecipientEditTextView list)2772     public String[] getAddressesFromList(RecipientEditTextView list) {
2773         if (list == null) {
2774             return new String[0];
2775         }
2776         Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
2777         int count = tokens.length;
2778         String[] result = new String[count];
2779         for (int i = 0; i < count; i++) {
2780             result[i] = tokens[i].toString();
2781         }
2782         return result;
2783     }
2784 
2785     /**
2786      * Check for invalid email addresses.
2787      * @param to String array of email addresses to check.
2788      * @param wrongEmailsOut Emails addresses that were invalid.
2789      */
checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut)2790     public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) {
2791         if (mValidator == null) {
2792             return;
2793         }
2794         for (final String email : to) {
2795             if (!mValidator.isValid(email)) {
2796                 wrongEmailsOut.add(email);
2797             }
2798         }
2799     }
2800 
2801     public static class RecipientErrorDialogFragment extends DialogFragment {
2802         // Public no-args constructor needed for fragment re-instantiation
RecipientErrorDialogFragment()2803         public RecipientErrorDialogFragment() {}
2804 
newInstance(final String message)2805         public static RecipientErrorDialogFragment newInstance(final String message) {
2806             final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment();
2807             final Bundle args = new Bundle(1);
2808             args.putString("message", message);
2809             frag.setArguments(args);
2810             return frag;
2811         }
2812 
2813         @Override
onCreateDialog(Bundle savedInstanceState)2814         public Dialog onCreateDialog(Bundle savedInstanceState) {
2815             final String message = getArguments().getString("message");
2816             return new AlertDialog.Builder(getActivity())
2817                     .setMessage(message)
2818                     .setPositiveButton(
2819                             R.string.ok, new Dialog.OnClickListener() {
2820                         @Override
2821                         public void onClick(DialogInterface dialog, int which) {
2822                             ((ComposeActivity)getActivity()).finishRecipientErrorDialog();
2823                         }
2824                     }).create();
2825         }
2826     }
2827 
2828     private void finishRecipientErrorDialog() {
2829         // after the user dismisses the recipient error
2830         // dialog we want to make sure to refocus the
2831         // recipient to field so they can fix the issue
2832         // easily
2833         if (mTo != null) {
2834             mTo.requestFocus();
2835         }
2836     }
2837 
2838     /**
2839      * Show an error because the user has entered an invalid recipient.
2840      */
2841     private void showRecipientErrorDialog(final String message) {
2842         final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message);
2843         frag.show(getFragmentManager(), "recipient error");
2844     }
2845 
2846     /**
2847      * Update the state of the UI based on whether or not the current draft
2848      * needs to be saved and the message is not empty.
2849      */
2850     public void updateSaveUi() {
2851         if (mSave != null) {
2852             mSave.setEnabled((isDraftDirty() && !isBlank()));
2853         }
2854     }
2855 
2856     /**
2857      * Returns true if the current draft is modified from the version we previously saved.
2858      */
2859     private boolean isDraftDirty() {
2860         synchronized (mDraftLock) {
2861             // The message should only be saved if:
2862             // It hasn't been sent AND
2863             // Some text has been added to the message OR
2864             // an attachment has been added or removed
2865             // AND there is actually something in the draft to save.
2866             return (mTextChanged || mAttachmentsChanged || mReplyFromChanged)
2867                     && !isBlank();
2868         }
2869     }
2870 
2871     /**
2872      * Returns whether the "Attach from Drive" menu item should be visible.
2873      */
2874     protected boolean shouldEnableAttachFromServiceMenu(Account mAccount) {
2875         return false;
2876     }
2877 
2878     /**
2879      * Check if all fields are blank.
2880      * @return boolean
2881      */
2882     public boolean isBlank() {
2883         // Need to check for null since isBlank() can be called from onPause()
2884         // before findViews() is called
2885         if (mSubject == null || mBodyView == null || mTo == null || mCc == null ||
2886                 mAttachmentsView == null) {
2887             LogUtils.w(LOG_TAG, "null views in isBlank check");
2888             return true;
2889         }
2890         return mSubject.getText().length() == 0
2891                 && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
2892                         mBodyView.getText().toString()) == 0)
2893                 && mTo.length() == 0
2894                 && mCc.length() == 0 && mBcc.length() == 0
2895                 && mAttachmentsView.getAttachments().size() == 0;
2896     }
2897 
2898     @VisibleForTesting
2899     protected int getSignatureStartPosition(String signature, String bodyText) {
2900         int startPos = -1;
2901 
2902         if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
2903             return startPos;
2904         }
2905 
2906         int bodyLength = bodyText.length();
2907         int signatureLength = signature.length();
2908         String printableVersion = convertToPrintableSignature(signature);
2909         int printableLength = printableVersion.length();
2910 
2911         if (bodyLength >= printableLength
2912                 && bodyText.substring(bodyLength - printableLength)
2913                 .equals(printableVersion)) {
2914             startPos = bodyLength - printableLength;
2915         } else if (bodyLength >= signatureLength
2916                 && bodyText.substring(bodyLength - signatureLength)
2917                 .equals(signature)) {
2918             startPos = bodyLength - signatureLength;
2919         }
2920         return startPos;
2921     }
2922 
2923     /**
2924      * Allows any changes made by the user to be ignored. Called when the user
2925      * decides to discard a draft.
2926      */
2927     private void discardChanges() {
2928         mTextChanged = false;
2929         mAttachmentsChanged = false;
2930         mReplyFromChanged = false;
2931     }
2932 
2933     /**
2934      * @param save True to save, false to send
2935      * @param showToast True to show a toast once the message is sent/saved
2936      */
2937     protected void sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
2938             final boolean orientationChanged, final boolean autoSend) {
2939         if (mAccounts == null || mAccount == null) {
2940             Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show();
2941             if (autoSend) {
2942                 finish();
2943             }
2944             return;
2945         }
2946 
2947         final String[] to, cc, bcc;
2948         if (orientationChanged) {
2949             to = cc = bcc = new String[0];
2950         } else {
2951             to = getToAddresses();
2952             cc = getCcAddresses();
2953             bcc = getBccAddresses();
2954         }
2955 
2956         final ArrayList<String> recipients = buildEmailAddressList(to);
2957         recipients.addAll(buildEmailAddressList(cc));
2958         recipients.addAll(buildEmailAddressList(bcc));
2959 
2960         // Don't let the user send to nobody (but it's okay to save a message
2961         // with no recipients)
2962         if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
2963             showRecipientErrorDialog(getString(R.string.recipient_needed));
2964             return;
2965         }
2966 
2967         List<String> wrongEmails = new ArrayList<String>();
2968         if (!save) {
2969             checkInvalidEmails(to, wrongEmails);
2970             checkInvalidEmails(cc, wrongEmails);
2971             checkInvalidEmails(bcc, wrongEmails);
2972         }
2973 
2974         // Don't let the user send an email with invalid recipients
2975         if (wrongEmails.size() > 0) {
2976             String errorText = String.format(getString(R.string.invalid_recipient),
2977                     wrongEmails.get(0));
2978             showRecipientErrorDialog(errorText);
2979             return;
2980         }
2981 
2982         if (!save) {
2983             if (autoSend) {
2984                 // Skip all further checks during autosend. This flow is used by Android Wear
2985                 // and Google Now.
2986                 sendOrSave(save, showToast);
2987                 return;
2988             }
2989 
2990             // Show a warning before sending only if there are no attachments, body, or subject.
2991             if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
2992                 boolean warnAboutEmptySubject = isSubjectEmpty();
2993                 boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0;
2994 
2995                 // A warning about an empty body may not be warranted when
2996                 // forwarding mails, since a common use case is to forward
2997                 // quoted text and not append any more text.
2998                 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
2999 
3000                 // When we bring up a dialog warning the user about a send,
3001                 // assume that they accept sending the message. If they do not,
3002                 // the dialog listener is required to enable sending again.
3003                 if (warnAboutEmptySubject) {
3004                     showSendConfirmDialog(R.string.confirm_send_message_with_no_subject,
3005                             showToast, recipients);
3006                     return;
3007                 }
3008 
3009                 if (warnAboutEmptyBody) {
3010                     showSendConfirmDialog(R.string.confirm_send_message_with_no_body,
3011                             showToast, recipients);
3012                     return;
3013                 }
3014             }
3015             // Ask for confirmation to send.
3016             if (showSendConfirmation()) {
3017                 showSendConfirmDialog(R.string.confirm_send_message, showToast, recipients);
3018                 return;
3019             }
3020         }
3021 
3022         performAdditionalSendOrSaveSanityChecks(save, showToast, recipients);
3023     }
3024 
3025     /**
3026      * Returns a boolean indicating whether warnings should be shown for empty
3027      * subject and body fields
3028      *
3029      * @return True if a warning should be shown for empty text fields
3030      */
3031     protected boolean showEmptyTextWarnings() {
3032         return mAttachmentsView.getAttachments().size() == 0;
3033     }
3034 
3035     /**
3036      * Returns a boolean indicating whether the user should confirm each send
3037      *
3038      * @return True if a warning should be on each send
3039      */
3040     protected boolean showSendConfirmation() {
3041         return mCachedSettings != null && mCachedSettings.confirmSend;
3042     }
3043 
3044     public static class SendConfirmDialogFragment extends DialogFragment
3045             implements DialogInterface.OnClickListener {
3046 
3047         private static final String MESSAGE_ID = "messageId";
3048         private static final String SHOW_TOAST = "showToast";
3049         private static final String RECIPIENTS = "recipients";
3050 
3051         private boolean mShowToast;
3052 
3053         private ArrayList<String> mRecipients;
3054 
3055         // Public no-args constructor needed for fragment re-instantiation
3056         public SendConfirmDialogFragment() {}
3057 
3058         public static SendConfirmDialogFragment newInstance(final int messageId,
3059                 final boolean showToast, final ArrayList<String> recipients) {
3060             final SendConfirmDialogFragment frag = new SendConfirmDialogFragment();
3061             final Bundle args = new Bundle(3);
3062             args.putInt(MESSAGE_ID, messageId);
3063             args.putBoolean(SHOW_TOAST, showToast);
3064             args.putStringArrayList(RECIPIENTS, recipients);
3065             frag.setArguments(args);
3066             return frag;
3067         }
3068 
3069         @Override
3070         public Dialog onCreateDialog(Bundle savedInstanceState) {
3071             final int messageId = getArguments().getInt(MESSAGE_ID);
3072             mShowToast = getArguments().getBoolean(SHOW_TOAST);
3073             mRecipients = getArguments().getStringArrayList(RECIPIENTS);
3074 
3075             final int confirmTextId = (messageId == R.string.confirm_send_message) ?
3076                     R.string.ok : R.string.send;
3077 
3078             return new AlertDialog.Builder(getActivity())
3079                     .setMessage(messageId)
3080                     .setPositiveButton(confirmTextId, this)
3081                     .setNegativeButton(R.string.cancel, null)
3082                     .create();
3083         }
3084 
3085         @Override
3086         public void onClick(DialogInterface dialog, int which) {
3087             if (which == DialogInterface.BUTTON_POSITIVE) {
3088                 ((ComposeActivity) getActivity()).finishSendConfirmDialog(mShowToast, mRecipients);
3089             }
3090         }
3091     }
3092 
3093     private void finishSendConfirmDialog(
3094             final boolean showToast, final ArrayList<String> recipients) {
3095         performAdditionalSendOrSaveSanityChecks(false /* save */, showToast, recipients);
3096     }
3097 
3098     // The list of recipients are used by the additional sendOrSave checks.
3099     // However, the send confirm dialog may be shown before performing
3100     // the additional checks. As a result, we need to plumb the recipient
3101     // list through the send confirm dialog so that
3102     // performAdditionalSendOrSaveChecks can be performed properly.
3103     private void showSendConfirmDialog(final int messageId,
3104             final boolean showToast, final ArrayList<String> recipients) {
3105         final DialogFragment frag = SendConfirmDialogFragment.newInstance(
3106                 messageId, showToast, recipients);
3107         frag.show(getFragmentManager(), "send confirm");
3108     }
3109 
3110     /**
3111      * Returns whether the ComposeArea believes there is any text in the body of
3112      * the composition. TODO: When ComposeArea controls the Body as well, add
3113      * that here.
3114      */
3115     public boolean isBodyEmpty() {
3116         return !mQuotedTextView.isTextIncluded();
3117     }
3118 
3119     /**
3120      * Test to see if the subject is empty.
3121      *
3122      * @return boolean.
3123      */
3124     // TODO: this will likely go away when composeArea.focus() is implemented
3125     // after all the widget control is moved over.
3126     public boolean isSubjectEmpty() {
3127         return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
3128     }
3129 
3130     @VisibleForTesting
3131     public String getSubject() {
3132         return mSubject.getText().toString();
3133     }
3134 
3135     private void sendOrSaveInternal(Context context, int requestId,
3136             ReplyFromAccount currReplyFromAccount, ReplyFromAccount originalReplyFromAccount,
3137             Message message, Message refMessage, CharSequence quotedText,
3138             SendOrSaveCallback callback, boolean save, int composeMode, ContentValues extraValues,
3139             Bundle optionalAttachmentFds) {
3140         final ContentValues values = new ContentValues();
3141 
3142         final String refMessageId = refMessage != null ? refMessage.uri.toString() : "";
3143 
3144         MessageModification.putToAddresses(values, message.getToAddresses());
3145         MessageModification.putCcAddresses(values, message.getCcAddresses());
3146         MessageModification.putBccAddresses(values, message.getBccAddresses());
3147         MessageModification.putCustomFromAddress(values, message.getFrom());
3148 
3149         MessageModification.putSubject(values, message.subject);
3150 
3151         // bodyHtml already have the composing spans removed.
3152         final String htmlBody = message.bodyHtml;
3153         final String textBody = message.bodyText;
3154         // fullbodyhtml/fullbodytext will contain the actual body plus the quoted text.
3155         String fullBodyHtml = htmlBody;
3156         String fullBodyText = textBody;
3157         String quotedString = null;
3158         final boolean hasQuotedText = !TextUtils.isEmpty(quotedText);
3159         if (hasQuotedText) {
3160             // The quoted text is HTML at this point.
3161             quotedString = quotedText.toString();
3162             fullBodyHtml = htmlBody + quotedString;
3163             fullBodyText = textBody + Utils.convertHtmlToPlainText(quotedString);
3164             MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
3165             MessageModification.putAppendRefMessageContent(values, true /* include quoted */);
3166         }
3167 
3168         // Only take refMessage into account if either one of its html/text is not empty.
3169         int quotedTextPos = -1;
3170         if (refMessage != null && !(TextUtils.isEmpty(refMessage.bodyHtml) &&
3171                 TextUtils.isEmpty(refMessage.bodyText))) {
3172             // The code below might need to be revisited. The quoted text position is different
3173             // between text/html and text/plain parts and they should be stored seperately and
3174             // the right version should be used in the UI. text/html should have preference
3175             // if both exist.  Issues like this made me file b/14256940 to make sure that we
3176             // properly handle the existing of both text/html and text/plain parts and to verify
3177             // that we are not making some assumptions that break if there is no text/html part.
3178             if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
3179                 MessageModification.putBodyHtml(values, fullBodyHtml);
3180                 if (hasQuotedText) {
3181                     quotedTextPos = htmlBody.length() +
3182                             QuotedTextView.getQuotedTextOffset(quotedString);
3183                 }
3184             }
3185             if (!TextUtils.isEmpty(refMessage.bodyText)) {
3186                 MessageModification.putBody(values, fullBodyText);
3187                 if (hasQuotedText && (quotedTextPos == -1)) {
3188                     quotedTextPos = textBody.length();
3189                 }
3190             }
3191             if (quotedTextPos != -1) {
3192                 // The quoted text pos is the text/html version first and the text/plan version
3193                 // if there is no text/html part. The reason for this is because preference
3194                 // is given to text/html in the compose window if it exists. In the future, we
3195                 // should calculate the index for both since the user could choose to compose
3196                 // explicitly in text/plain.
3197                 MessageModification.putQuoteStartPos(values, quotedTextPos);
3198             }
3199         } else {
3200             MessageModification.putBodyHtml(values, fullBodyHtml);
3201             MessageModification.putBody(values, fullBodyText);
3202         }
3203         int draftType = getDraftType(composeMode);
3204         MessageModification.putDraftType(values, draftType);
3205         MessageModification.putAttachments(values, message.getAttachments());
3206         if (!TextUtils.isEmpty(refMessageId)) {
3207             MessageModification.putRefMessageId(values, refMessageId);
3208         }
3209         if (extraValues != null) {
3210             values.putAll(extraValues);
3211         }
3212 
3213         SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, requestId,
3214                 values, refMessageId, message.getAttachments(), optionalAttachmentFds, save);
3215         runSendOrSaveProviderCalls(sendOrSaveMessage, callback, currReplyFromAccount,
3216                 originalReplyFromAccount);
3217 
3218         LogUtils.i(LOG_TAG, "[compose] SendOrSaveMessage [%s] posted (isSave: %s) - " +
3219                 "bodyHtml length: %d, bodyText length: %d, quoted text pos: %d, attach count: %d",
3220                 requestId, save, message.bodyHtml.length(), message.bodyText.length(),
3221                 quotedTextPos, message.getAttachmentCount(true));
3222     }
3223 
3224     /**
3225      * Removes any composing spans from the specified string.  This will create a new
3226      * SpannableString instance, as to not modify the behavior of the EditText view.
3227      */
3228     private static SpannableString removeComposingSpans(Spanned body) {
3229         final SpannableString messageBody = new SpannableString(body);
3230         BaseInputConnection.removeComposingSpans(messageBody);
3231 
3232         // Remove watcher spans while we're at it, so any off-UI thread manipulation of these
3233         // spans doesn't trigger unexpected side-effects. This copy is essentially 100% detached
3234         // from the EditText.
3235         //
3236         // (must remove SpanWatchers first to avoid triggering them as we remove other spans)
3237         removeSpansOfType(messageBody, SpanWatcher.class);
3238         removeSpansOfType(messageBody, TextWatcher.class);
3239 
3240         return messageBody;
3241     }
3242 
3243     private static void removeSpansOfType(SpannableString str, Class<?> cls) {
3244         for (Object span : str.getSpans(0, str.length(), cls)) {
3245             str.removeSpan(span);
3246         }
3247     }
3248 
3249     private static int getDraftType(int mode) {
3250         int draftType = -1;
3251         switch (mode) {
3252             case ComposeActivity.COMPOSE:
3253                 draftType = DraftType.COMPOSE;
3254                 break;
3255             case ComposeActivity.REPLY:
3256                 draftType = DraftType.REPLY;
3257                 break;
3258             case ComposeActivity.REPLY_ALL:
3259                 draftType = DraftType.REPLY_ALL;
3260                 break;
3261             case ComposeActivity.FORWARD:
3262                 draftType = DraftType.FORWARD;
3263                 break;
3264         }
3265         return draftType;
3266     }
3267 
3268     /**
3269      * Derived classes should override this step to perform additional checks before
3270      * send or save. The default implementation simply calls {@link #sendOrSave(boolean, boolean)}.
3271      */
3272     protected void performAdditionalSendOrSaveSanityChecks(
3273             final boolean save, final boolean showToast, ArrayList<String> recipients) {
3274         sendOrSave(save, showToast);
3275     }
3276 
3277     protected void sendOrSave(final boolean save, final boolean showToast) {
3278         // Check if user is a monkey. Monkeys can compose and hit send
3279         // button but are not allowed to send anything off the device.
3280         if (ActivityManager.isUserAMonkey()) {
3281             return;
3282         }
3283 
3284         final SendOrSaveCallback callback = new SendOrSaveCallback() {
3285             @Override
3286             public void initializeSendOrSave() {
3287                 final Intent i = new Intent(ComposeActivity.this, EmptyService.class);
3288 
3289                 // API 16+ allows for setClipData. For pre-16 we are going to open the fds
3290                 // on the main thread.
3291                 if (Utils.isRunningJellybeanOrLater()) {
3292                     // Grant the READ permission for the attachments to the service so that
3293                     // as long as the service stays alive we won't hit PermissionExceptions.
3294                     final ClipDescription desc = new ClipDescription("attachment_uris",
3295                             new String[]{ClipDescription.MIMETYPE_TEXT_URILIST});
3296                     ClipData clipData = null;
3297                     for (Attachment a : mAttachmentsView.getAttachments()) {
3298                         if (a != null && !Utils.isEmpty(a.contentUri)) {
3299                             final ClipData.Item uriItem = new ClipData.Item(a.contentUri);
3300                             if (clipData == null) {
3301                                 clipData = new ClipData(desc, uriItem);
3302                             } else {
3303                                 clipData.addItem(uriItem);
3304                             }
3305                         }
3306                     }
3307                     i.setClipData(clipData);
3308                     i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
3309                 }
3310 
3311                 synchronized (PENDING_SEND_OR_SAVE_TASKS_NUM) {
3312                     if (PENDING_SEND_OR_SAVE_TASKS_NUM.getAndAdd(1) == 0) {
3313                         // Start service so we won't be killed if this app is
3314                         // put in the background.
3315                         startService(i);
3316                     }
3317                 }
3318                 if (sTestSendOrSaveCallback != null) {
3319                     sTestSendOrSaveCallback.initializeSendOrSave();
3320                 }
3321             }
3322 
3323             @Override
3324             public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
3325                     Message message) {
3326                 synchronized (mDraftLock) {
3327                     mDraftId = message.id;
3328                     mDraft = message;
3329                     if (sRequestMessageIdMap != null) {
3330                         sRequestMessageIdMap.put(sendOrSaveMessage.mRequestId, mDraftId);
3331                     }
3332                     // Cache request message map, in case the process is killed
3333                     saveRequestMap();
3334                 }
3335                 if (sTestSendOrSaveCallback != null) {
3336                     sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
3337                 }
3338             }
3339 
3340             @Override
3341             public long getMessageId() {
3342                 synchronized (mDraftLock) {
3343                     return mDraftId;
3344                 }
3345             }
3346 
3347             @Override
3348             public void sendOrSaveFinished(SendOrSaveMessage message, boolean success) {
3349                 // Update the last sent from account.
3350                 if (mAccount != null) {
3351                     MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString());
3352                 }
3353                 if (success) {
3354                     // Successfully sent or saved so reset change markers
3355                     discardChanges();
3356                 } else {
3357                     // A failure happened with saving/sending the draft
3358                     // TODO(pwestbro): add a better string that should be used
3359                     // when failing to send or save
3360                     Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
3361                             .show();
3362                 }
3363 
3364                 synchronized (PENDING_SEND_OR_SAVE_TASKS_NUM) {
3365                     if (PENDING_SEND_OR_SAVE_TASKS_NUM.addAndGet(-1) == 0) {
3366                         // Stop service so we can be killed.
3367                         stopService(new Intent(ComposeActivity.this, EmptyService.class));
3368                     }
3369                 }
3370                 if (sTestSendOrSaveCallback != null) {
3371                     sTestSendOrSaveCallback.sendOrSaveFinished(message, success);
3372                 }
3373             }
3374         };
3375         setAccount(mReplyFromAccount.account);
3376 
3377         final Spanned body = removeComposingSpans(mBodyView.getText());
3378         callback.initializeSendOrSave();
3379 
3380         // For pre-JB we need to open the fds on the main thread
3381         final Bundle attachmentFds;
3382         if (!Utils.isRunningJellybeanOrLater()) {
3383             attachmentFds = initializeAttachmentFds(this, mAttachmentsView.getAttachments());
3384         } else {
3385             attachmentFds = null;
3386         }
3387 
3388         // Generate a unique message id for this request
3389         mRequestId = sRandom.nextInt();
3390         SEND_SAVE_TASK_HANDLER.post(new Runnable() {
3391             @Override
3392             public void run() {
3393                 final Message msg = createMessage(mReplyFromAccount, mRefMessage, getMode(), body);
3394                 sendOrSaveInternal(ComposeActivity.this, mRequestId, mReplyFromAccount,
3395                         mDraftAccount, msg, mRefMessage, mQuotedTextView.getQuotedTextIfIncluded(),
3396                         callback, save, mComposeMode, mExtraValues, attachmentFds);
3397             }
3398         });
3399 
3400         // Don't display the toast if the user is just changing the orientation,
3401         // but we still need to save the draft to the cursor because this is how we restore
3402         // the attachments when the configuration change completes.
3403         if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
3404             Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
3405                     Toast.LENGTH_LONG).show();
3406         }
3407 
3408         // Need to update variables here because the send or save completes
3409         // asynchronously even though the toast shows right away.
3410         discardChanges();
3411         updateSaveUi();
3412 
3413         // If we are sending, finish the activity
3414         if (!save) {
3415             finish();
3416         }
3417     }
3418 
3419     /**
3420      * Save the state of the request messageid map. This allows for the Gmail
3421      * process to be killed, but and still allow for ComposeActivity instances
3422      * to be recreated correctly.
3423      */
3424     private void saveRequestMap() {
3425         // TODO: store the request map in user preferences.
3426     }
3427 
3428     @SuppressLint("NewApi")
3429     private void doAttach(String type) {
3430         Intent i = new Intent(Intent.ACTION_GET_CONTENT);
3431         i.addCategory(Intent.CATEGORY_OPENABLE);
3432         i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
3433         i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
3434         i.setType(type);
3435         mAddingAttachment = true;
3436         startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
3437                 RESULT_PICK_ATTACHMENT);
3438     }
3439 
3440     private void showCcBccViews() {
3441         mCcBccView.show(true, true, true);
3442         if (mCcBccButton != null) {
3443             mCcBccButton.setVisibility(View.GONE);
3444         }
3445     }
3446 
3447     private static String getActionString(int action) {
3448         final String msgType;
3449         switch (action) {
3450             case COMPOSE:
3451                 msgType = "new_message";
3452                 break;
3453             case REPLY:
3454                 msgType = "reply";
3455                 break;
3456             case REPLY_ALL:
3457                 msgType = "reply_all";
3458                 break;
3459             case FORWARD:
3460                 msgType = "forward";
3461                 break;
3462             default:
3463                 msgType = "unknown";
3464                 break;
3465         }
3466         return msgType;
3467     }
3468 
3469     private void logSendOrSave(boolean save) {
3470         if (!Analytics.isLoggable() || mAttachmentsView == null) {
3471             return;
3472         }
3473 
3474         final String category = (save) ? "message_save" : "message_send";
3475         final int attachmentCount = getAttachments().size();
3476         final String msgType = getActionString(mComposeMode);
3477         final String label;
3478         final long value;
3479         if (mComposeMode == COMPOSE) {
3480             label = Integer.toString(attachmentCount);
3481             value = attachmentCount;
3482         } else {
3483             label = null;
3484             value = 0;
3485         }
3486         Analytics.getInstance().sendEvent(category, msgType, label, value);
3487     }
3488 
3489     @Override
3490     public boolean onNavigationItemSelected(int position, long itemId) {
3491         int initialComposeMode = mComposeMode;
3492         if (position == ComposeActivity.REPLY) {
3493             mComposeMode = ComposeActivity.REPLY;
3494         } else if (position == ComposeActivity.REPLY_ALL) {
3495             mComposeMode = ComposeActivity.REPLY_ALL;
3496         } else if (position == ComposeActivity.FORWARD) {
3497             mComposeMode = ComposeActivity.FORWARD;
3498         }
3499         clearChangeListeners();
3500         if (initialComposeMode != mComposeMode) {
3501             resetMessageForModeChange();
3502             if (mRefMessage != null) {
3503                 setFieldsFromRefMessage(mComposeMode);
3504             }
3505             boolean showCc = false;
3506             boolean showBcc = false;
3507             if (mDraft != null) {
3508                 // Following desktop behavior, if the user has added a BCC
3509                 // field to a draft, we show it regardless of compose mode.
3510                 showBcc = !TextUtils.isEmpty(mDraft.getBcc());
3511                 // Use the draft to determine what to populate.
3512                 // If the Bcc field is showing, show the Cc field whether it is populated or not.
3513                 showCc = showBcc
3514                         || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL);
3515             }
3516             if (mRefMessage != null) {
3517                 showCc = !TextUtils.isEmpty(mCc.getText());
3518                 showBcc = !TextUtils.isEmpty(mBcc.getText());
3519             }
3520             mCcBccView.show(false /* animate */, showCc, showBcc);
3521         }
3522         updateHideOrShowCcBcc();
3523         initChangeListeners();
3524         return true;
3525     }
3526 
3527     @VisibleForTesting
3528     protected void resetMessageForModeChange() {
3529         // When switching between reply, reply all, forward,
3530         // follow the behavior of webview.
3531         // The contents of the following fields are cleared
3532         // so that they can be populated directly from the
3533         // ref message:
3534         // 1) Any recipient fields
3535         // 2) The subject
3536         mTo.setText("");
3537         mCc.setText("");
3538         mBcc.setText("");
3539         // Any edits to the subject are replaced with the original subject.
3540         mSubject.setText("");
3541 
3542         // Any changes to the contents of the following fields are kept:
3543         // 1) Body
3544         // 2) Attachments
3545         // If the user made changes to attachments, keep their changes.
3546         if (!mAttachmentsChanged) {
3547             mAttachmentsView.deleteAllAttachments();
3548         }
3549     }
3550 
3551     private class ComposeModeAdapter extends ArrayAdapter<String> {
3552 
3553         private Context mContext;
3554         private LayoutInflater mInflater;
3555 
3556         public ComposeModeAdapter(Context context) {
3557             super(context, R.layout.compose_mode_item, R.id.mode, getResources()
3558                     .getStringArray(R.array.compose_modes));
3559             mContext = context;
3560         }
3561 
3562         private LayoutInflater getInflater() {
3563             if (mInflater == null) {
3564                 mInflater = LayoutInflater.from(mContext);
3565             }
3566             return mInflater;
3567         }
3568 
3569         @Override
3570         public View getView(int position, View convertView, ViewGroup parent) {
3571             if (convertView == null) {
3572                 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
3573             }
3574             ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
3575             return super.getView(position, convertView, parent);
3576         }
3577     }
3578 
3579     @Override
3580     public void onRespondInline(String text) {
3581         appendToBody(text, false);
3582         mQuotedTextView.setUpperDividerVisible(false);
3583         mRespondedInline = true;
3584         if (!mBodyView.hasFocus()) {
3585             mBodyView.requestFocus();
3586         }
3587     }
3588 
3589     /**
3590      * Append text to the body of the message. If there is no existing body
3591      * text, just sets the body to text.
3592      *
3593      * @param text Text to append
3594      * @param withSignature True to append a signature.
3595      */
3596     public void appendToBody(CharSequence text, boolean withSignature) {
3597         Editable bodyText = mBodyView.getEditableText();
3598         if (bodyText != null && bodyText.length() > 0) {
3599             bodyText.append(text);
3600         } else {
3601             setBody(text, withSignature);
3602         }
3603     }
3604 
3605     /**
3606      * Set the body of the message.
3607      * Please try to exclusively use this method instead of calling mBodyView.setText(..) directly.
3608      *
3609      * @param text text to set
3610      * @param withSignature True to append a signature.
3611      */
3612     public void setBody(CharSequence text, boolean withSignature) {
3613         LogUtils.i(LOG_TAG, "Body populated, len: %d, sig: %b", text.length(), withSignature);
3614         mBodyView.setText(text);
3615         if (withSignature) {
3616             appendSignature();
3617         }
3618     }
3619 
3620     private void appendSignature() {
3621         final String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
3622         final int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString());
3623         if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) {
3624             mSignature = newSignature;
3625             if (!TextUtils.isEmpty(mSignature)) {
3626                 // Appending a signature does not count as changing text.
3627                 mBodyView.removeTextChangedListener(this);
3628                 mBodyView.append(convertToPrintableSignature(mSignature));
3629                 mBodyView.addTextChangedListener(this);
3630             }
3631             resetBodySelection();
3632         }
3633     }
3634 
3635     private String convertToPrintableSignature(String signature) {
3636         String signatureResource = getResources().getString(R.string.signature);
3637         if (signature == null) {
3638             signature = "";
3639         }
3640         return String.format(signatureResource, signature);
3641     }
3642 
3643     @Override
3644     public void onAccountChanged() {
3645         mReplyFromAccount = mFromSpinner.getCurrentAccount();
3646         if (!mAccount.equals(mReplyFromAccount.account)) {
3647             // Clear a signature, if there was one.
3648             mBodyView.removeTextChangedListener(this);
3649             String oldSignature = mSignature;
3650             String bodyText = getBody().getText().toString();
3651             if (!TextUtils.isEmpty(oldSignature)) {
3652                 int pos = getSignatureStartPosition(oldSignature, bodyText);
3653                 if (pos > -1) {
3654                     setBody(bodyText.substring(0, pos), false);
3655                 }
3656             }
3657             setAccount(mReplyFromAccount.account);
3658             mBodyView.addTextChangedListener(this);
3659             // TODO: handle discarding attachments when switching accounts.
3660             // Only enable save for this draft if there is any other content
3661             // in the message.
3662             if (!isBlank()) {
3663                 enableSave(true);
3664             }
3665             mReplyFromChanged = true;
3666             initRecipients();
3667 
3668             invalidateOptionsMenu();
3669         }
3670     }
3671 
3672     public void enableSave(boolean enabled) {
3673         if (mSave != null) {
3674             mSave.setEnabled(enabled);
3675         }
3676     }
3677 
3678     public static class DiscardConfirmDialogFragment extends DialogFragment {
3679         // Public no-args constructor needed for fragment re-instantiation
3680         public DiscardConfirmDialogFragment() {}
3681 
3682         @Override
3683         public Dialog onCreateDialog(Bundle savedInstanceState) {
3684             return new AlertDialog.Builder(getActivity())
3685                     .setMessage(R.string.confirm_discard_text)
3686                     .setPositiveButton(R.string.discard,
3687                             new DialogInterface.OnClickListener() {
3688                                 @Override
3689                                 public void onClick(DialogInterface dialog, int which) {
3690                                     ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation();
3691                                 }
3692                             })
3693                     .setNegativeButton(R.string.cancel, null)
3694                     .create();
3695         }
3696     }
3697 
3698     private void doDiscard() {
3699         // Only need to ask for confirmation if the draft is in a dirty state.
3700         if (isDraftDirty()) {
3701             final DialogFragment frag = new DiscardConfirmDialogFragment();
3702             frag.show(getFragmentManager(), "discard confirm");
3703         } else {
3704             doDiscardWithoutConfirmation();
3705         }
3706     }
3707 
3708     /**
3709      * Effectively discard the current message.
3710      *
3711      * This method is either invoked from the menu or from the dialog
3712      * once the user has confirmed that they want to discard the message.
3713      */
3714     private void doDiscardWithoutConfirmation() {
3715         synchronized (mDraftLock) {
3716             if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
3717                 ContentValues values = new ContentValues();
3718                 values.put(BaseColumns._ID, mDraftId);
3719                 if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) {
3720                     getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
3721                 } else {
3722                     getContentResolver().delete(mDraft.uri, null, null);
3723                 }
3724                 // This is not strictly necessary (since we should not try to
3725                 // save the draft after calling this) but it ensures that if we
3726                 // do save again for some reason we make a new draft rather than
3727                 // trying to resave an expunged draft.
3728                 mDraftId = UIProvider.INVALID_MESSAGE_ID;
3729             }
3730         }
3731 
3732         // Display a toast to let the user know
3733         Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
3734 
3735         // This prevents the draft from being saved in onPause().
3736         discardChanges();
3737         mPerformedSendOrDiscard = true;
3738         finish();
3739     }
3740 
3741     private void saveIfNeeded() {
3742         if (mAccount == null) {
3743             // We have not chosen an account yet so there's no way that we can save. This is ok,
3744             // though, since we are saving our state before AccountsActivity is activated. Thus, the
3745             // user has not interacted with us yet and there is no real state to save.
3746             return;
3747         }
3748 
3749         if (isDraftDirty()) {
3750             doSave(!mAddingAttachment /* show toast */);
3751         }
3752     }
3753 
3754     @Override
3755     public void onAttachmentDeleted() {
3756         mAttachmentsChanged = true;
3757         // If we are showing any attachments, make sure we have an upper
3758         // divider.
3759         mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3760         updateSaveUi();
3761     }
3762 
3763     @Override
3764     public void onAttachmentAdded() {
3765         mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3766         mAttachmentsView.focusLastAttachment();
3767     }
3768 
3769     /**
3770      * This is called any time one of our text fields changes.
3771      */
3772     @Override
3773     public void afterTextChanged(Editable s) {
3774         mTextChanged = true;
3775         updateSaveUi();
3776     }
3777 
3778     @Override
3779     public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3780         // Do nothing.
3781     }
3782 
3783     @Override
3784     public void onTextChanged(CharSequence s, int start, int before, int count) {
3785         // Do nothing.
3786     }
3787 
3788 
3789     // There is a big difference between the text associated with an address changing
3790     // to add the display name or to format properly and a recipient being added or deleted.
3791     // Make sure we only notify of changes when a recipient has been added or deleted.
3792     private class RecipientTextWatcher implements TextWatcher {
3793         private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
3794 
3795         private RecipientEditTextView mView;
3796 
3797         private TextWatcher mListener;
3798 
3799         public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
3800             mView = view;
3801             mListener = listener;
3802         }
3803 
3804         @Override
3805         public void afterTextChanged(Editable s) {
3806             if (hasChanged()) {
3807                 mListener.afterTextChanged(s);
3808             }
3809         }
3810 
3811         private boolean hasChanged() {
3812             final ArrayList<String> currRecips = buildEmailAddressList(getAddressesFromList(mView));
3813             int totalCount = currRecips.size();
3814             int totalPrevCount = 0;
3815             for (Entry<String, Integer> entry : mContent.entrySet()) {
3816                 totalPrevCount += entry.getValue();
3817             }
3818             if (totalCount != totalPrevCount) {
3819                 return true;
3820             }
3821 
3822             for (String recip : currRecips) {
3823                 if (!mContent.containsKey(recip)) {
3824                     return true;
3825                 } else {
3826                     int count = mContent.get(recip) - 1;
3827                     if (count < 0) {
3828                         return true;
3829                     } else {
3830                         mContent.put(recip, count);
3831                     }
3832                 }
3833             }
3834             return false;
3835         }
3836 
3837         @Override
3838         public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3839             final ArrayList<String> recips = buildEmailAddressList(getAddressesFromList(mView));
3840             for (String recip : recips) {
3841                 if (!mContent.containsKey(recip)) {
3842                     mContent.put(recip, 1);
3843                 } else {
3844                     mContent.put(recip, (mContent.get(recip)) + 1);
3845                 }
3846             }
3847         }
3848 
3849         @Override
3850         public void onTextChanged(CharSequence s, int start, int before, int count) {
3851             // Do nothing.
3852         }
3853     }
3854 
3855     /**
3856      * Returns a list of email addresses from the recipients. List only contains
3857      * email addresses strips additional info like the recipient's name.
3858      */
3859     private static ArrayList<String> buildEmailAddressList(String[] recips) {
3860         // Tokenize them all and put them in the list.
3861         final ArrayList<String> recipAddresses = Lists.newArrayListWithCapacity(recips.length);
3862         for (int i = 0; i < recips.length; i++) {
3863             recipAddresses.add(Rfc822Tokenizer.tokenize(recips[i])[0].getAddress());
3864         }
3865         return recipAddresses;
3866     }
3867 
3868     public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) {
3869         if (sTestSendOrSaveCallback != null && testCallback != null) {
3870             throw new IllegalStateException("Attempting to register more than one test callback");
3871         }
3872         sTestSendOrSaveCallback = testCallback;
3873     }
3874 
3875     @VisibleForTesting
3876     protected ArrayList<Attachment> getAttachments() {
3877         return mAttachmentsView.getAttachments();
3878     }
3879 
3880     @Override
3881     public Loader<Cursor> onCreateLoader(int id, Bundle args) {
3882         switch (id) {
3883             case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3884                 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3885                         null, null);
3886             case REFERENCE_MESSAGE_LOADER:
3887                 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3888                         null, null);
3889             case LOADER_ACCOUNT_CURSOR:
3890                 return new CursorLoader(this, MailAppProvider.getAccountsUri(),
3891                         UIProvider.ACCOUNTS_PROJECTION, null, null, null);
3892         }
3893         return null;
3894     }
3895 
3896     @Override
3897     public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
3898         int id = loader.getId();
3899         switch (id) {
3900             case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3901                 if (data != null && data.moveToFirst()) {
3902                     mRefMessage = new Message(data);
3903                     Intent intent = getIntent();
3904                     initFromRefMessage(mComposeMode);
3905                     finishSetup(mComposeMode, intent, null);
3906                     if (mComposeMode != FORWARD) {
3907                         String to = intent.getStringExtra(EXTRA_TO);
3908                         if (!TextUtils.isEmpty(to)) {
3909                             mRefMessage.setTo(null);
3910                             mRefMessage.setFrom(null);
3911                             clearChangeListeners();
3912                             mTo.append(to);
3913                             initChangeListeners();
3914                         }
3915                     }
3916                 } else {
3917                     finish();
3918                 }
3919                 break;
3920             case REFERENCE_MESSAGE_LOADER:
3921                 // Only populate mRefMessage and leave other fields untouched.
3922                 if (data != null && data.moveToFirst()) {
3923                     mRefMessage = new Message(data);
3924                 }
3925                 finishSetup(mComposeMode, getIntent(), mInnerSavedState);
3926                 break;
3927             case LOADER_ACCOUNT_CURSOR:
3928                 if (data != null && data.moveToFirst()) {
3929                     // there are accounts now!
3930                     Account account;
3931                     final ArrayList<Account> accounts = new ArrayList<Account>();
3932                     final ArrayList<Account> initializedAccounts = new ArrayList<Account>();
3933                     do {
3934                         account = Account.builder().buildFrom(data);
3935                         if (account.isAccountReady()) {
3936                             initializedAccounts.add(account);
3937                         }
3938                         accounts.add(account);
3939                     } while (data.moveToNext());
3940                     if (initializedAccounts.size() > 0) {
3941                         findViewById(R.id.wait).setVisibility(View.GONE);
3942                         getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR);
3943                         findViewById(R.id.compose).setVisibility(View.VISIBLE);
3944                         mAccounts = initializedAccounts.toArray(
3945                                 new Account[initializedAccounts.size()]);
3946 
3947                         finishCreate();
3948                         invalidateOptionsMenu();
3949                     } else {
3950                         // Show "waiting"
3951                         account = accounts.size() > 0 ? accounts.get(0) : null;
3952                         showWaitFragment(account);
3953                     }
3954                 }
3955                 break;
3956         }
3957     }
3958 
3959     private void showWaitFragment(Account account) {
3960         WaitFragment fragment = getWaitFragment();
3961         if (fragment != null) {
3962             fragment.updateAccount(account);
3963         } else {
3964             findViewById(R.id.wait).setVisibility(View.VISIBLE);
3965             replaceFragment(WaitFragment.newInstance(account, false /* expectingMessages */),
3966                     FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT);
3967         }
3968     }
3969 
3970     private WaitFragment getWaitFragment() {
3971         return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT);
3972     }
3973 
3974     private int replaceFragment(Fragment fragment, int transition, String tag) {
3975         FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction();
3976         fragmentTransaction.setTransition(transition);
3977         fragmentTransaction.replace(R.id.wait, fragment, tag);
3978         final int transactionId = fragmentTransaction.commitAllowingStateLoss();
3979         return transactionId;
3980     }
3981 
3982     @Override
3983     public void onLoaderReset(Loader<Cursor> arg0) {
3984         // Do nothing.
3985     }
3986 
3987     /**
3988      * Background task to convert the message's html to Spanned.
3989      */
3990     private class HtmlToSpannedTask extends AsyncTask<String, Void, Spanned> {
3991 
3992         @Override
3993         protected Spanned doInBackground(String... input) {
3994             return HtmlUtils.htmlToSpan(input[0], mSpanConverterFactory);
3995         }
3996 
3997         @Override
3998         protected void onPostExecute(Spanned spanned) {
3999             mBodyView.removeTextChangedListener(ComposeActivity.this);
4000             setBody(spanned, false);
4001             mTextChanged = false;
4002             mBodyView.addTextChangedListener(ComposeActivity.this);
4003         }
4004     }
4005 
4006     @Override
4007     public void onSupportActionModeStarted(ActionMode mode) {
4008         super.onSupportActionModeStarted(mode);
4009         ViewUtils.setStatusBarColor(this, R.color.action_mode_statusbar_color);
4010     }
4011 
4012     @Override
4013     public void onSupportActionModeFinished(ActionMode mode) {
4014         super.onSupportActionModeFinished(mode);
4015         ViewUtils.setStatusBarColor(this, R.color.primary_dark_color);
4016     }
4017 }
4018