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