Home | History | Annotate | Download | only in activity
      1 /*
      2  * Copyright (C) 2008 The Android Open Source Project
      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.email.activity;
     18 
     19 import android.app.ActionBar;
     20 import android.app.ActionBar.OnNavigationListener;
     21 import android.app.ActionBar.Tab;
     22 import android.app.ActionBar.TabListener;
     23 import android.app.Activity;
     24 import android.app.ActivityManager;
     25 import android.app.FragmentTransaction;
     26 import android.content.ActivityNotFoundException;
     27 import android.content.ContentResolver;
     28 import android.content.ContentUris;
     29 import android.content.ContentValues;
     30 import android.content.Context;
     31 import android.content.Intent;
     32 import android.database.Cursor;
     33 import android.net.Uri;
     34 import android.os.Bundle;
     35 import android.os.Parcelable;
     36 import android.provider.OpenableColumns;
     37 import android.text.InputFilter;
     38 import android.text.SpannableStringBuilder;
     39 import android.text.Spanned;
     40 import android.text.TextUtils;
     41 import android.text.TextWatcher;
     42 import android.text.util.Rfc822Tokenizer;
     43 import android.util.Log;
     44 import android.view.Menu;
     45 import android.view.MenuItem;
     46 import android.view.View;
     47 import android.view.View.OnClickListener;
     48 import android.view.View.OnFocusChangeListener;
     49 import android.view.ViewGroup;
     50 import android.webkit.WebView;
     51 import android.widget.ArrayAdapter;
     52 import android.widget.CheckBox;
     53 import android.widget.EditText;
     54 import android.widget.ImageView;
     55 import android.widget.MultiAutoCompleteTextView;
     56 import android.widget.TextView;
     57 import android.widget.Toast;
     58 
     59 import com.android.common.contacts.DataUsageStatUpdater;
     60 import com.android.email.Controller;
     61 import com.android.email.Email;
     62 import com.android.email.EmailAddressAdapter;
     63 import com.android.email.EmailAddressValidator;
     64 import com.android.email.R;
     65 import com.android.email.RecipientAdapter;
     66 import com.android.email.activity.setup.AccountSettings;
     67 import com.android.email.mail.internet.EmailHtmlUtil;
     68 import com.android.emailcommon.Logging;
     69 import com.android.emailcommon.internet.MimeUtility;
     70 import com.android.emailcommon.mail.Address;
     71 import com.android.emailcommon.provider.Account;
     72 import com.android.emailcommon.provider.EmailContent;
     73 import com.android.emailcommon.provider.EmailContent.Attachment;
     74 import com.android.emailcommon.provider.EmailContent.Body;
     75 import com.android.emailcommon.provider.EmailContent.BodyColumns;
     76 import com.android.emailcommon.provider.EmailContent.Message;
     77 import com.android.emailcommon.provider.EmailContent.MessageColumns;
     78 import com.android.emailcommon.provider.EmailContent.QuickResponseColumns;
     79 import com.android.emailcommon.provider.Mailbox;
     80 import com.android.emailcommon.provider.QuickResponse;
     81 import com.android.emailcommon.utility.AttachmentUtilities;
     82 import com.android.emailcommon.utility.EmailAsyncTask;
     83 import com.android.emailcommon.utility.Utility;
     84 import com.android.ex.chips.AccountSpecifier;
     85 import com.android.ex.chips.ChipsUtil;
     86 import com.android.ex.chips.RecipientEditTextView;
     87 import com.google.common.annotations.VisibleForTesting;
     88 import com.google.common.base.Objects;
     89 import com.google.common.collect.Lists;
     90 
     91 import java.io.File;
     92 import java.io.UnsupportedEncodingException;
     93 import java.net.URLDecoder;
     94 import java.util.ArrayList;
     95 import java.util.HashMap;
     96 import java.util.HashSet;
     97 import java.util.List;
     98 import java.util.concurrent.ConcurrentHashMap;
     99 import java.util.concurrent.ExecutionException;
    100 
    101 
    102 /**
    103  * Activity to compose a message.
    104  *
    105  * TODO Revive shortcuts command for removed menu options.
    106  * C: add cc/bcc
    107  * N: add attachment
    108  */
    109 public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener,
    110         DeleteMessageConfirmationDialog.Callback, InsertQuickResponseDialog.Callback {
    111 
    112     private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY";
    113     private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL";
    114     private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD";
    115     private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT";
    116 
    117     private static final String EXTRA_ACCOUNT_ID = "account_id";
    118     private static final String EXTRA_MESSAGE_ID = "message_id";
    119     /** If the intent is sent from the email app itself, it should have this boolean extra. */
    120     public static final String EXTRA_FROM_WITHIN_APP = "from_within_app";
    121     /** If the intent is sent from thw widget. */
    122     public static final String EXTRA_FROM_WIDGET = "from_widget";
    123 
    124     private static final String STATE_KEY_CC_SHOWN =
    125         "com.android.email.activity.MessageCompose.ccShown";
    126     private static final String STATE_KEY_QUOTED_TEXT_SHOWN =
    127         "com.android.email.activity.MessageCompose.quotedTextShown";
    128     private static final String STATE_KEY_DRAFT_ID =
    129         "com.android.email.activity.MessageCompose.draftId";
    130     private static final String STATE_KEY_LAST_SAVE_TASK_ID =
    131         "com.android.email.activity.MessageCompose.requestId";
    132     private static final String STATE_KEY_ACTION =
    133         "com.android.email.activity.MessageCompose.action";
    134 
    135     private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1;
    136 
    137     private static final String[] ATTACHMENT_META_SIZE_PROJECTION = {
    138         OpenableColumns.SIZE
    139     };
    140     private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0;
    141 
    142     /**
    143      * A registry of the active tasks used to save messages.
    144      */
    145     private static final ConcurrentHashMap<Long, SendOrSaveMessageTask> sActiveSaveTasks =
    146             new ConcurrentHashMap<Long, SendOrSaveMessageTask>();
    147 
    148     private static long sNextSaveTaskId = 1;
    149 
    150     /**
    151      * The ID of the latest save or send task requested by this Activity.
    152      */
    153     private long mLastSaveTaskId = -1;
    154 
    155     private Account mAccount;
    156 
    157     /**
    158      * The contents of the current message being edited. This is not always in sync with what's
    159      * on the UI. {@link #updateMessage(Message, Account, boolean, boolean)} must be called to sync
    160      * the UI values into this object.
    161      */
    162     private Message mDraft = new Message();
    163 
    164     /**
    165      * A collection of attachments the user is currently wanting to attach to this message.
    166      */
    167     private final ArrayList<Attachment> mAttachments = new ArrayList<Attachment>();
    168 
    169     /**
    170      * The source message for a reply, reply all, or forward. This is asynchronously loaded.
    171      */
    172     private Message mSource;
    173 
    174     /**
    175      * The attachments associated with the source attachments. Usually included in a forward.
    176      */
    177     private ArrayList<Attachment> mSourceAttachments = new ArrayList<Attachment>();
    178 
    179     /**
    180      * The action being handled by this activity. This is initially populated from the
    181      * {@link Intent}, but can switch between reply/reply all/forward where appropriate.
    182      * This value is nullable (a null value indicating a regular "compose").
    183      */
    184     private String mAction;
    185 
    186     private TextView mFromView;
    187     private MultiAutoCompleteTextView mToView;
    188     private MultiAutoCompleteTextView mCcView;
    189     private MultiAutoCompleteTextView mBccView;
    190     private View mCcBccContainer;
    191     private EditText mSubjectView;
    192     private EditText mMessageContentView;
    193     private View mAttachmentContainer;
    194     private ViewGroup mAttachmentContentView;
    195     private View mQuotedTextArea;
    196     private CheckBox mIncludeQuotedTextCheckBox;
    197     private WebView mQuotedText;
    198     private ActionSpinnerAdapter mActionSpinnerAdapter;
    199 
    200     private Controller mController;
    201     private boolean mDraftNeedsSaving;
    202     private boolean mMessageLoaded;
    203     private boolean mInitiallyEmpty;
    204     private boolean mPickingAttachment = false;
    205     private Boolean mQuickResponsesAvailable = true;
    206     private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker();
    207 
    208     private AccountSpecifier mAddressAdapterTo;
    209     private AccountSpecifier mAddressAdapterCc;
    210     private AccountSpecifier mAddressAdapterBcc;
    211 
    212     /**
    213      * Watches the to, cc, bcc, subject, and message body fields.
    214      */
    215     private final TextWatcher mWatcher = new TextWatcher() {
    216         @Override
    217         public void beforeTextChanged(CharSequence s, int start,
    218                                       int before, int after) { }
    219 
    220         @Override
    221         public void onTextChanged(CharSequence s, int start,
    222                                       int before, int count) {
    223             setMessageChanged(true);
    224         }
    225 
    226         @Override
    227         public void afterTextChanged(android.text.Editable s) { }
    228     };
    229 
    230     private static Intent getBaseIntent(Context context) {
    231         return new Intent(context, MessageCompose.class);
    232     }
    233 
    234     /**
    235      * Create an {@link Intent} that can start the message compose activity. If accountId -1,
    236      * the default account will be used; otherwise, the specified account is used.
    237      */
    238     public static Intent getMessageComposeIntent(Context context, long accountId) {
    239         Intent i = getBaseIntent(context);
    240         i.putExtra(EXTRA_ACCOUNT_ID, accountId);
    241         return i;
    242     }
    243 
    244     /**
    245      * Creates an {@link Intent} that can start the message compose activity from the main Email
    246      * activity. This should not be used for Intents to be fired from outside of the main Email
    247      * activity, such as from widgets, as the behavior of the compose screen differs subtly from
    248      * those cases.
    249      */
    250     private static Intent getMainAppIntent(Context context, long accountId) {
    251         Intent result = getMessageComposeIntent(context, accountId);
    252         result.putExtra(EXTRA_FROM_WITHIN_APP, true);
    253         return result;
    254     }
    255 
    256     /**
    257      * Compose a new message using the given account. If account is {@link Account#NO_ACCOUNT}
    258      * the default account will be used.
    259      * This should only be called from the main Email application.
    260      * @param context
    261      * @param accountId
    262      */
    263     public static void actionCompose(Context context, long accountId) {
    264        try {
    265            Intent i = getMainAppIntent(context, accountId);
    266            context.startActivity(i);
    267        } catch (ActivityNotFoundException anfe) {
    268            // Swallow it - this is usually a race condition, especially under automated test.
    269            // (The message composer might have been disabled)
    270            Email.log(anfe.toString());
    271        }
    272     }
    273 
    274     /**
    275      * Compose a new message using a uri (mailto:) and a given account.  If account is -1 the
    276      * default account will be used.
    277      * This should only be called from the main Email application.
    278      * @param context
    279      * @param uriString
    280      * @param accountId
    281      * @return true if startActivity() succeeded
    282      */
    283     public static boolean actionCompose(Context context, String uriString, long accountId) {
    284         try {
    285             Intent i = getMainAppIntent(context, accountId);
    286             i.setAction(Intent.ACTION_SEND);
    287             i.setData(Uri.parse(uriString));
    288             context.startActivity(i);
    289             return true;
    290         } catch (ActivityNotFoundException anfe) {
    291             // Swallow it - this is usually a race condition, especially under automated test.
    292             // (The message composer might have been disabled)
    293             Email.log(anfe.toString());
    294             return false;
    295         }
    296     }
    297 
    298     /**
    299      * Compose a new message as a reply to the given message. If replyAll is true the function
    300      * is reply all instead of simply reply.
    301      * @param context
    302      * @param messageId
    303      * @param replyAll
    304      */
    305     public static void actionReply(Context context, long messageId, boolean replyAll) {
    306         startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId);
    307     }
    308 
    309     /**
    310      * Compose a new message as a forward of the given message.
    311      * @param context
    312      * @param messageId
    313      */
    314     public static void actionForward(Context context, long messageId) {
    315         startActivityWithMessage(context, ACTION_FORWARD, messageId);
    316     }
    317 
    318     /**
    319      * Continue composition of the given message. This action modifies the way this Activity
    320      * handles certain actions.
    321      * Save will attempt to replace the message in the given folder with the updated version.
    322      * Discard will delete the message from the given folder.
    323      * @param context
    324      * @param messageId the message id.
    325      */
    326     public static void actionEditDraft(Context context, long messageId) {
    327         startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId);
    328     }
    329 
    330     /**
    331      * Starts a compose activity with a message as a reference message (e.g. for reply or forward).
    332      */
    333     private static void startActivityWithMessage(Context context, String action, long messageId) {
    334         Intent i = getBaseIntent(context);
    335         i.putExtra(EXTRA_MESSAGE_ID, messageId);
    336         i.setAction(action);
    337         context.startActivity(i);
    338     }
    339 
    340     private void setAccount(Intent intent) {
    341         long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1);
    342         if (accountId == Account.NO_ACCOUNT) {
    343             accountId = Account.getDefaultAccountId(this);
    344         }
    345         if (accountId == Account.NO_ACCOUNT) {
    346             // There are no accounts set up. This should not have happened. Prompt the
    347             // user to set up an account as an acceptable bailout.
    348             Welcome.actionStart(this);
    349             finish();
    350         } else {
    351             setAccount(Account.restoreAccountWithId(this, accountId));
    352         }
    353     }
    354 
    355     private void setAccount(Account account) {
    356         if (account == null) {
    357             throw new IllegalArgumentException();
    358         }
    359         mAccount = account;
    360         mFromView.setText(account.mEmailAddress);
    361         mAddressAdapterTo
    362                 .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
    363         mAddressAdapterCc
    364                 .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
    365         mAddressAdapterBcc
    366                 .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown"));
    367 
    368         new QuickResponseChecker(mTaskTracker).executeParallel((Void) null);
    369     }
    370 
    371     @Override
    372     public void onCreate(Bundle savedInstanceState) {
    373         super.onCreate(savedInstanceState);
    374         ActivityHelper.debugSetWindowFlags(this);
    375         setContentView(R.layout.message_compose);
    376 
    377         mController = Controller.getInstance(getApplication());
    378         initViews();
    379 
    380         // Show the back arrow on the action bar.
    381         getActionBar().setDisplayOptions(
    382                 ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
    383 
    384         if (savedInstanceState != null) {
    385             long draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, Message.NOT_SAVED);
    386             long existingSaveTaskId = savedInstanceState.getLong(STATE_KEY_LAST_SAVE_TASK_ID, -1);
    387             setAction(savedInstanceState.getString(STATE_KEY_ACTION));
    388             SendOrSaveMessageTask existingSaveTask = sActiveSaveTasks.get(existingSaveTaskId);
    389 
    390             if ((draftId != Message.NOT_SAVED) || (existingSaveTask != null)) {
    391                 // Restoring state and there was an existing message saved or in the process of
    392                 // being saved.
    393                 resumeDraft(draftId, existingSaveTask, false /* don't restore views */);
    394             } else {
    395                 // Restoring state but there was nothing saved - probably means the user rotated
    396                 // the device immediately - just use the Intent.
    397                 resolveIntent(getIntent());
    398             }
    399         } else {
    400             Intent intent = getIntent();
    401             setAction(intent.getAction());
    402             resolveIntent(intent);
    403         }
    404     }
    405 
    406     private void resolveIntent(Intent intent) {
    407         if (Intent.ACTION_VIEW.equals(mAction)
    408                 || Intent.ACTION_SENDTO.equals(mAction)
    409                 || Intent.ACTION_SEND.equals(mAction)
    410                 || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) {
    411             initFromIntent(intent);
    412             setMessageChanged(true);
    413             setMessageLoaded(true);
    414         } else if (ACTION_REPLY.equals(mAction)
    415                 || ACTION_REPLY_ALL.equals(mAction)
    416                 || ACTION_FORWARD.equals(mAction)) {
    417             long sourceMessageId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED);
    418             loadSourceMessage(sourceMessageId, true);
    419 
    420         } else if (ACTION_EDIT_DRAFT.equals(mAction)) {
    421             // Assert getIntent.hasExtra(EXTRA_MESSAGE_ID)
    422             long draftId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED);
    423             resumeDraft(draftId, null, true /* restore views */);
    424 
    425         } else {
    426             // Normal compose flow for a new message.
    427             setAccount(intent);
    428             setInitialComposeText(null, getAccountSignature(mAccount));
    429             setMessageLoaded(true);
    430         }
    431     }
    432 
    433     @Override
    434     protected void onRestoreInstanceState(Bundle savedInstanceState) {
    435         // Temporarily disable onTextChanged listeners while restoring the fields
    436         removeListeners();
    437         super.onRestoreInstanceState(savedInstanceState);
    438         if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) {
    439             showCcBccFields();
    440         }
    441         mQuotedTextArea.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
    442                 ? View.VISIBLE : View.GONE);
    443         mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN)
    444                 ? View.VISIBLE : View.GONE);
    445         addListeners();
    446     }
    447 
    448     // needed for unit tests
    449     @Override
    450     public void setIntent(Intent intent) {
    451         super.setIntent(intent);
    452         setAction(intent.getAction());
    453     }
    454 
    455     private void setQuickResponsesAvailable(boolean quickResponsesAvailable) {
    456         if (mQuickResponsesAvailable != quickResponsesAvailable) {
    457             mQuickResponsesAvailable = quickResponsesAvailable;
    458             invalidateOptionsMenu();
    459         }
    460     }
    461 
    462     /**
    463      * Given an accountId and context, finds if the database has any QuickResponse
    464      * entries and returns the result to the Callback.
    465      */
    466     private class QuickResponseChecker extends EmailAsyncTask<Void, Void, Boolean> {
    467         public QuickResponseChecker(EmailAsyncTask.Tracker tracker) {
    468             super(tracker);
    469         }
    470 
    471         @Override
    472         protected Boolean doInBackground(Void... params) {
    473             return EmailContent.count(MessageCompose.this, QuickResponse.CONTENT_URI,
    474                     QuickResponseColumns.ACCOUNT_KEY + "=?",
    475                     new String[] {Long.toString(mAccount.mId)}) > 0;
    476         }
    477 
    478         @Override
    479         protected void onSuccess(Boolean quickResponsesAvailable) {
    480             setQuickResponsesAvailable(quickResponsesAvailable);
    481         }
    482     }
    483 
    484     @Override
    485     public void onResume() {
    486         super.onResume();
    487 
    488         // Exit immediately if the accounts list has changed (e.g. externally deleted)
    489         if (Email.getNotifyUiAccountsChanged()) {
    490             Welcome.actionStart(this);
    491             finish();
    492             return;
    493         }
    494 
    495         // If activity paused and quick responses are removed/added, possibly update options menu
    496         if (mAccount != null) {
    497             new QuickResponseChecker(mTaskTracker).executeParallel((Void) null);
    498         }
    499     }
    500 
    501     @Override
    502     public void onPause() {
    503         super.onPause();
    504         saveIfNeeded();
    505     }
    506 
    507     /**
    508      * We override onDestroy to make sure that the WebView gets explicitly destroyed.
    509      * Otherwise it can leak native references.
    510      */
    511     @Override
    512     public void onDestroy() {
    513         super.onDestroy();
    514         mQuotedText.destroy();
    515         mQuotedText = null;
    516 
    517         mTaskTracker.cancellAllInterrupt();
    518 
    519         if (mAddressAdapterTo != null && mAddressAdapterTo instanceof EmailAddressAdapter) {
    520             ((EmailAddressAdapter) mAddressAdapterTo).close();
    521         }
    522         if (mAddressAdapterCc != null && mAddressAdapterCc instanceof EmailAddressAdapter) {
    523             ((EmailAddressAdapter) mAddressAdapterCc).close();
    524         }
    525         if (mAddressAdapterBcc != null && mAddressAdapterBcc instanceof EmailAddressAdapter) {
    526             ((EmailAddressAdapter) mAddressAdapterBcc).close();
    527         }
    528     }
    529 
    530     /**
    531      * The framework handles most of the fields, but we need to handle stuff that we
    532      * dynamically show and hide:
    533      * Cc field,
    534      * Bcc field,
    535      * Quoted text,
    536      */
    537     @Override
    538     protected void onSaveInstanceState(Bundle outState) {
    539         super.onSaveInstanceState(outState);
    540 
    541         long draftId = mDraft.mId;
    542         if (draftId != Message.NOT_SAVED) {
    543             outState.putLong(STATE_KEY_DRAFT_ID, draftId);
    544         }
    545         outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE);
    546         outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN,
    547                 mQuotedTextArea.getVisibility() == View.VISIBLE);
    548         outState.putString(STATE_KEY_ACTION, mAction);
    549 
    550         // If there are any outstanding save requests, ensure that it's noted in case it hasn't
    551         // finished by the time the activity is restored.
    552         outState.putLong(STATE_KEY_LAST_SAVE_TASK_ID, mLastSaveTaskId);
    553     }
    554 
    555     @Override
    556     public void onBackPressed() {
    557         onBack(true /* systemKey */);
    558     }
    559 
    560     /**
    561      * Whether or not the current message being edited has a source message (i.e. is a reply,
    562      * or forward) that is loaded.
    563      */
    564     private boolean hasSourceMessage() {
    565         return mSource != null;
    566     }
    567 
    568     /**
    569      * @return true if the activity was opened by the email app itself.
    570      */
    571     private boolean isOpenedFromWithinApp() {
    572         Intent i = getIntent();
    573         return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false));
    574     }
    575 
    576     private boolean isOpenedFromWidget() {
    577         Intent i = getIntent();
    578         return (i != null && i.getBooleanExtra(EXTRA_FROM_WIDGET, false));
    579     }
    580 
    581     /**
    582      * Sets message as loaded and then initializes the TextWatchers.
    583      * @param isLoaded - value to which to set mMessageLoaded
    584      */
    585     private void setMessageLoaded(boolean isLoaded) {
    586         if (mMessageLoaded != isLoaded) {
    587             mMessageLoaded = isLoaded;
    588             addListeners();
    589             mInitiallyEmpty = areViewsEmpty();
    590         }
    591     }
    592 
    593     private void setMessageChanged(boolean messageChanged) {
    594         boolean needsSaving = messageChanged && !(mInitiallyEmpty && areViewsEmpty());
    595 
    596         if (mDraftNeedsSaving != needsSaving) {
    597             mDraftNeedsSaving = needsSaving;
    598             invalidateOptionsMenu();
    599         }
    600     }
    601 
    602     /**
    603      * @return whether or not all text fields are empty (i.e. the entire compose message is empty)
    604      */
    605     private boolean areViewsEmpty() {
    606         return (mToView.length() == 0)
    607                 && (mCcView.length() == 0)
    608                 && (mBccView.length() == 0)
    609                 && (mSubjectView.length() == 0)
    610                 && isBodyEmpty()
    611                 && mAttachments.isEmpty();
    612     }
    613 
    614     private boolean isBodyEmpty() {
    615         return (mMessageContentView.length() == 0)
    616                 || mMessageContentView.getText()
    617                         .toString().equals("\n" + getAccountSignature(mAccount));
    618     }
    619 
    620     public void setFocusShifter(int fromViewId, final int targetViewId) {
    621         View label = findViewById(fromViewId); // xlarge only
    622         if (label != null) {
    623             final View target = UiUtilities.getView(this, targetViewId);
    624             label.setOnClickListener(new View.OnClickListener() {
    625                 @Override
    626                 public void onClick(View v) {
    627                     target.requestFocus();
    628                 }
    629             });
    630         }
    631     }
    632 
    633     /**
    634      * An {@link InputFilter} that implements special address cleanup rules.
    635      * The first space key entry following an "@" symbol that is followed by any combination
    636      * of letters and symbols, including one+ dots and zero commas, should insert an extra
    637      * comma (followed by the space).
    638      */
    639     @VisibleForTesting
    640     static final InputFilter RECIPIENT_FILTER = new InputFilter() {
    641         @Override
    642         public CharSequence filter(CharSequence source, int start, int end, Spanned dest,
    643                 int dstart, int dend) {
    644 
    645             // Quick check - did they enter a single space?
    646             if (end-start != 1 || source.charAt(start) != ' ') {
    647                 return null;
    648             }
    649 
    650             // determine if the characters before the new space fit the pattern
    651             // follow backwards and see if we find a comma, dot, or @
    652             int scanBack = dstart;
    653             boolean dotFound = false;
    654             while (scanBack > 0) {
    655                 char c = dest.charAt(--scanBack);
    656                 switch (c) {
    657                     case '.':
    658                         dotFound = true;    // one or more dots are req'd
    659                         break;
    660                     case ',':
    661                         return null;
    662                     case '@':
    663                         if (!dotFound) {
    664                             return null;
    665                         }
    666 
    667                         // we have found a comma-insert case.  now just do it
    668                         // in the least expensive way we can.
    669                         if (source instanceof Spanned) {
    670                             SpannableStringBuilder sb = new SpannableStringBuilder(",");
    671                             sb.append(source);
    672                             return sb;
    673                         } else {
    674                             return ", ";
    675                         }
    676                     default:
    677                         // just keep going
    678                 }
    679             }
    680 
    681             // no termination cases were found, so don't edit the input
    682             return null;
    683         }
    684     };
    685 
    686     private void initViews() {
    687         ViewGroup toParent = UiUtilities.getViewOrNull(this, R.id.to_content);
    688         if (toParent != null) {
    689             mToView = (MultiAutoCompleteTextView) toParent.findViewById(R.id.to);
    690             ViewGroup ccParent, bccParent;
    691             ccParent = (ViewGroup) findViewById(R.id.cc_content);
    692             mCcView = (MultiAutoCompleteTextView) ccParent.findViewById(R.id.cc);
    693             bccParent = (ViewGroup) findViewById(R.id.bcc_content);
    694             mBccView = (MultiAutoCompleteTextView) bccParent.findViewById(R.id.bcc);
    695         } else {
    696             mToView = UiUtilities.getView(this, R.id.to);
    697             mCcView = UiUtilities.getView(this, R.id.cc);
    698             mBccView = UiUtilities.getView(this, R.id.bcc);
    699         }
    700 
    701         mFromView = UiUtilities.getView(this, R.id.from);
    702         mCcBccContainer = UiUtilities.getView(this, R.id.cc_bcc_wrapper);
    703         mSubjectView = UiUtilities.getView(this, R.id.subject);
    704         mMessageContentView = UiUtilities.getView(this, R.id.body_text);
    705         mAttachmentContentView = UiUtilities.getView(this, R.id.attachments);
    706         mAttachmentContainer = UiUtilities.getView(this, R.id.attachment_container);
    707         mQuotedTextArea = UiUtilities.getView(this, R.id.quoted_text_area);
    708         mIncludeQuotedTextCheckBox = UiUtilities.getView(this, R.id.include_quoted_text);
    709         mQuotedText = UiUtilities.getView(this, R.id.quoted_text);
    710 
    711         InputFilter[] recipientFilters = new InputFilter[] { RECIPIENT_FILTER };
    712 
    713         // NOTE: assumes no other filters are set
    714         mToView.setFilters(recipientFilters);
    715         mCcView.setFilters(recipientFilters);
    716         mBccView.setFilters(recipientFilters);
    717 
    718         /*
    719          * We set this to invisible by default. Other methods will turn it back on if it's
    720          * needed.
    721          */
    722         mQuotedTextArea.setVisibility(View.GONE);
    723         setIncludeQuotedText(false, false);
    724 
    725         mIncludeQuotedTextCheckBox.setOnClickListener(this);
    726 
    727         EmailAddressValidator addressValidator = new EmailAddressValidator();
    728 
    729         setupAddressAdapters();
    730         mToView.setTokenizer(new Rfc822Tokenizer());
    731         mToView.setValidator(addressValidator);
    732 
    733         mCcView.setTokenizer(new Rfc822Tokenizer());
    734         mCcView.setValidator(addressValidator);
    735 
    736         mBccView.setTokenizer(new Rfc822Tokenizer());
    737         mBccView.setValidator(addressValidator);
    738 
    739         final View addCcBccView = UiUtilities.getViewOrNull(this, R.id.add_cc_bcc);
    740         if (addCcBccView != null) {
    741             // Tablet view.
    742             addCcBccView.setOnClickListener(this);
    743         }
    744 
    745         final View addAttachmentView = UiUtilities.getViewOrNull(this, R.id.add_attachment);
    746         if (addAttachmentView != null) {
    747             // Tablet view.
    748             addAttachmentView.setOnClickListener(this);
    749         }
    750 
    751         setFocusShifter(R.id.to_label, R.id.to);
    752         setFocusShifter(R.id.cc_label, R.id.cc);
    753         setFocusShifter(R.id.bcc_label, R.id.bcc);
    754         setFocusShifter(R.id.composearea_tap_trap_bottom, R.id.body_text);
    755 
    756         mMessageContentView.setOnFocusChangeListener(this);
    757 
    758         updateAttachmentContainer();
    759         mToView.requestFocus();
    760     }
    761 
    762     /**
    763      * Initializes listeners. Should only be called once initializing of views is complete to
    764      * avoid unnecessary draft saving.
    765      */
    766     private void addListeners() {
    767         mToView.addTextChangedListener(mWatcher);
    768         mCcView.addTextChangedListener(mWatcher);
    769         mBccView.addTextChangedListener(mWatcher);
    770         mSubjectView.addTextChangedListener(mWatcher);
    771         mMessageContentView.addTextChangedListener(mWatcher);
    772     }
    773 
    774     /**
    775      * Removes listeners from the user-editable fields. Can be used to temporarily disable them
    776      * while resetting fields (such as when changing from reply to reply all) to avoid
    777      * unnecessary saving.
    778      */
    779     private void removeListeners() {
    780         mToView.removeTextChangedListener(mWatcher);
    781         mCcView.removeTextChangedListener(mWatcher);
    782         mBccView.removeTextChangedListener(mWatcher);
    783         mSubjectView.removeTextChangedListener(mWatcher);
    784         mMessageContentView.removeTextChangedListener(mWatcher);
    785     }
    786 
    787     /**
    788      * Set up address auto-completion adapters.
    789      */
    790     private void setupAddressAdapters() {
    791         boolean supportsChips = ChipsUtil.supportsChipsUi();
    792 
    793         if (supportsChips && mToView instanceof RecipientEditTextView) {
    794             mAddressAdapterTo = new RecipientAdapter(this, (RecipientEditTextView) mToView);
    795             mToView.setAdapter((RecipientAdapter) mAddressAdapterTo);
    796         } else {
    797             mAddressAdapterTo = new EmailAddressAdapter(this);
    798             mToView.setAdapter((EmailAddressAdapter) mAddressAdapterTo);
    799         }
    800         if (supportsChips && mCcView instanceof RecipientEditTextView) {
    801             mAddressAdapterCc = new RecipientAdapter(this, (RecipientEditTextView) mCcView);
    802             mCcView.setAdapter((RecipientAdapter) mAddressAdapterCc);
    803         } else {
    804             mAddressAdapterCc = new EmailAddressAdapter(this);
    805             mCcView.setAdapter((EmailAddressAdapter) mAddressAdapterCc);
    806         }
    807         if (supportsChips && mBccView instanceof RecipientEditTextView) {
    808             mAddressAdapterBcc = new RecipientAdapter(this, (RecipientEditTextView) mBccView);
    809             mBccView.setAdapter((RecipientAdapter) mAddressAdapterBcc);
    810         } else {
    811             mAddressAdapterBcc = new EmailAddressAdapter(this);
    812             mBccView.setAdapter((EmailAddressAdapter) mAddressAdapterBcc);
    813         }
    814     }
    815 
    816     /**
    817      * Asynchronously loads a draft message for editing.
    818      * This may or may not restore the view contents, depending on whether or not callers want,
    819      * since in the case of screen rotation, those are restored automatically.
    820      */
    821     private void resumeDraft(
    822             long draftId,
    823             SendOrSaveMessageTask existingSaveTask,
    824             final boolean restoreViews) {
    825         // Note - this can be Message.NOT_SAVED if there is an existing save task in progress
    826         // for the draft we need to load.
    827         mDraft.mId = draftId;
    828 
    829         new LoadMessageTask(draftId, existingSaveTask, new OnMessageLoadHandler() {
    830             @Override
    831             public void onMessageLoaded(Message message, Body body) {
    832                 message.mHtml = body.mHtmlContent;
    833                 message.mText = body.mTextContent;
    834                 message.mHtmlReply = body.mHtmlReply;
    835                 message.mTextReply = body.mTextReply;
    836                 message.mIntroText = body.mIntroText;
    837                 message.mSourceKey = body.mSourceKey;
    838 
    839                 mDraft = message;
    840                 processDraftMessage(message, restoreViews);
    841 
    842                 // Load attachments related to the draft.
    843                 loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() {
    844                     @Override
    845                     public void onAttachmentLoaded(Attachment[] attachments) {
    846                         for (Attachment attachment: attachments) {
    847                             addAttachment(attachment);
    848                         }
    849                     }
    850                 });
    851 
    852                 // If we're resuming an edit of a reply, reply-all, or forward, re-load the
    853                 // source message if available so that we get more information.
    854                 if (message.mSourceKey != Message.NOT_SAVED) {
    855                     loadSourceMessage(message.mSourceKey, false /* restore views */);
    856                 }
    857             }
    858 
    859             @Override
    860             public void onLoadFailed() {
    861                 Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
    862                 finish();
    863             }
    864         }).executeSerial((Void[]) null);
    865     }
    866 
    867     @VisibleForTesting
    868     void processDraftMessage(Message message, boolean restoreViews) {
    869         if (restoreViews) {
    870             mSubjectView.setText(message.mSubject);
    871             addAddresses(mToView, Address.unpack(message.mTo));
    872             Address[] cc = Address.unpack(message.mCc);
    873             if (cc.length > 0) {
    874                 addAddresses(mCcView, cc);
    875             }
    876             Address[] bcc = Address.unpack(message.mBcc);
    877             if (bcc.length > 0) {
    878                 addAddresses(mBccView, bcc);
    879             }
    880 
    881             mMessageContentView.setText(message.mText);
    882 
    883             showCcBccFieldsIfFilled();
    884             setNewMessageFocus();
    885         }
    886         setMessageChanged(false);
    887 
    888         // The quoted text must always be restored.
    889         displayQuotedText(message.mTextReply, message.mHtmlReply);
    890         setIncludeQuotedText(
    891                 (mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0, false);
    892     }
    893 
    894     /**
    895      * Asynchronously loads a source message (to be replied or forwarded in this current view),
    896      * populating text fields and quoted text fields when the load finishes, if requested.
    897      */
    898     private void loadSourceMessage(long sourceMessageId, final boolean restoreViews) {
    899         new LoadMessageTask(sourceMessageId, null, new OnMessageLoadHandler() {
    900             @Override
    901             public void onMessageLoaded(Message message, Body body) {
    902                 message.mHtml = body.mHtmlContent;
    903                 message.mText = body.mTextContent;
    904                 message.mHtmlReply = null;
    905                 message.mTextReply = null;
    906                 message.mIntroText = null;
    907                 mSource = message;
    908                 mSourceAttachments = new ArrayList<Attachment>();
    909 
    910                 if (restoreViews) {
    911                     processSourceMessage(mSource, mAccount);
    912                     setInitialComposeText(null, getAccountSignature(mAccount));
    913                 }
    914 
    915                 loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() {
    916                     @Override
    917                     public void onAttachmentLoaded(Attachment[] attachments) {
    918                         final boolean supportsSmartForward =
    919                             (mAccount.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0;
    920 
    921                         // Process the attachments to have the appropriate smart forward flags.
    922                         for (Attachment attachment : attachments) {
    923                             if (supportsSmartForward) {
    924                                 attachment.mFlags |= Attachment.FLAG_SMART_FORWARD;
    925                             }
    926                             mSourceAttachments.add(attachment);
    927                         }
    928                         if (isForward() && restoreViews) {
    929                             if (processSourceMessageAttachments(
    930                                     mAttachments, mSourceAttachments, true)) {
    931                                 updateAttachmentUi();
    932                                 setMessageChanged(true);
    933                             }
    934                         }
    935                     }
    936                 });
    937 
    938                 if (mAction.equals(ACTION_EDIT_DRAFT)) {
    939                     // Resuming a draft may in fact be resuming a reply/reply all/forward.
    940                     // Use a best guess and infer the action here.
    941                     String inferredAction = inferAction();
    942                     if (inferredAction != null) {
    943                         setAction(inferredAction);
    944                         // No need to update the action selector as switching actions should do it.
    945                         return;
    946                     }
    947                 }
    948 
    949                 updateActionSelector();
    950             }
    951 
    952             @Override
    953             public void onLoadFailed() {
    954                 // The loading of the source message is only really required if it is needed
    955                 // immediately to restore the view contents. In the case of resuming draft, it
    956                 // is only needed to gather additional information.
    957                 if (restoreViews) {
    958                     Utility.showToast(MessageCompose.this, R.string.error_loading_message_body);
    959                     finish();
    960                 }
    961             }
    962         }).executeSerial((Void[]) null);
    963     }
    964 
    965     /**
    966      * Infers whether or not the current state of the message best reflects either a reply,
    967      * reply-all, or forward.
    968      */
    969     @VisibleForTesting
    970     String inferAction() {
    971         String subject = mSubjectView.getText().toString();
    972         if (subject == null) {
    973             return null;
    974         }
    975         if (subject.toLowerCase().startsWith("fwd:")) {
    976             return ACTION_FORWARD;
    977         } else if (subject.toLowerCase().startsWith("re:")) {
    978             int numRecipients = getAddresses(mToView).length
    979                     + getAddresses(mCcView).length
    980                     + getAddresses(mBccView).length;
    981             if (numRecipients > 1) {
    982                 return ACTION_REPLY_ALL;
    983             } else {
    984                 return ACTION_REPLY;
    985             }
    986         } else {
    987             // Unsure.
    988             return null;
    989         }
    990     }
    991 
    992     private interface OnMessageLoadHandler {
    993         /**
    994          * Handles a load to a message (e.g. a draft message or a source message).
    995          */
    996         void onMessageLoaded(Message message, Body body);
    997 
    998         /**
    999          * Handles a failure to load a message.
   1000          */
   1001         void onLoadFailed();
   1002     }
   1003 
   1004     /**
   1005      * Asynchronously loads a message and the account information.
   1006      * This can be used to load a reference message (when replying) or when restoring a draft.
   1007      */
   1008     private class LoadMessageTask extends EmailAsyncTask<Void, Void, Object[]> {
   1009         /**
   1010          * The message ID to load, if available.
   1011          */
   1012         private long mMessageId;
   1013 
   1014         /**
   1015          * A future-like reference to the save task which must complete prior to this load.
   1016          */
   1017         private final SendOrSaveMessageTask mSaveTask;
   1018 
   1019         /**
   1020          * A callback to pass the results of the load to.
   1021          */
   1022         private final OnMessageLoadHandler mCallback;
   1023 
   1024         public LoadMessageTask(
   1025                 long messageId, SendOrSaveMessageTask saveTask, OnMessageLoadHandler callback) {
   1026             super(mTaskTracker);
   1027             mMessageId = messageId;
   1028             mSaveTask = saveTask;
   1029             mCallback = callback;
   1030         }
   1031 
   1032         private long getIdToLoad() throws InterruptedException, ExecutionException {
   1033             if (mMessageId == -1) {
   1034                 mMessageId = mSaveTask.get();
   1035             }
   1036             return mMessageId;
   1037         }
   1038 
   1039         @Override
   1040         protected Object[] doInBackground(Void... params) {
   1041             long messageId;
   1042             try {
   1043                 messageId = getIdToLoad();
   1044             } catch (InterruptedException e) {
   1045                 // Don't have a good message ID to load - bail.
   1046                 Log.e(Logging.LOG_TAG,
   1047                         "Unable to load draft message since existing save task failed: " + e);
   1048                 return null;
   1049             } catch (ExecutionException e) {
   1050                 // Don't have a good message ID to load - bail.
   1051                 Log.e(Logging.LOG_TAG,
   1052                         "Unable to load draft message since existing save task failed: " + e);
   1053                 return null;
   1054             }
   1055             Message message = Message.restoreMessageWithId(MessageCompose.this, messageId);
   1056             if (message == null) {
   1057                 return null;
   1058             }
   1059             long accountId = message.mAccountKey;
   1060             Account account = Account.restoreAccountWithId(MessageCompose.this, accountId);
   1061             Body body;
   1062             try {
   1063                 body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId);
   1064             } catch (RuntimeException e) {
   1065                 Log.d(Logging.LOG_TAG, "Exception while loading message body: " + e);
   1066                 return null;
   1067             }
   1068             return new Object[] {message, body, account};
   1069         }
   1070 
   1071         @Override
   1072         protected void onSuccess(Object[] results) {
   1073             if ((results == null) || (results.length != 3)) {
   1074                 mCallback.onLoadFailed();
   1075                 return;
   1076             }
   1077 
   1078             final Message message = (Message) results[0];
   1079             final Body body = (Body) results[1];
   1080             final Account account = (Account) results[2];
   1081             if ((message == null) || (body == null) || (account == null)) {
   1082                 mCallback.onLoadFailed();
   1083                 return;
   1084             }
   1085 
   1086             setAccount(account);
   1087             mCallback.onMessageLoaded(message, body);
   1088             setMessageLoaded(true);
   1089         }
   1090     }
   1091 
   1092     private interface AttachmentLoadedCallback {
   1093         /**
   1094          * Handles completion of the loading of a set of attachments.
   1095          * Callback will always happen on the main thread.
   1096          */
   1097         void onAttachmentLoaded(Attachment[] attachment);
   1098     }
   1099 
   1100     private void loadAttachments(
   1101             final long messageId,
   1102             final Account account,
   1103             final AttachmentLoadedCallback callback) {
   1104         new EmailAsyncTask<Void, Void, Attachment[]>(mTaskTracker) {
   1105             @Override
   1106             protected Attachment[] doInBackground(Void... params) {
   1107                 return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this, messageId);
   1108             }
   1109 
   1110             @Override
   1111             protected void onSuccess(Attachment[] attachments) {
   1112                 if (attachments == null) {
   1113                     attachments = new Attachment[0];
   1114                 }
   1115                 callback.onAttachmentLoaded(attachments);
   1116             }
   1117         }.executeSerial((Void[]) null);
   1118     }
   1119 
   1120     @Override
   1121     public void onFocusChange(View view, boolean focused) {
   1122         if (focused) {
   1123             switch (view.getId()) {
   1124                 case R.id.body_text:
   1125                     // When focusing on the message content via tabbing to it, or other means of
   1126                     // auto focusing, move the cursor to the end of the body (before the signature).
   1127                     if (mMessageContentView.getSelectionStart() == 0
   1128                             && mMessageContentView.getSelectionEnd() == 0) {
   1129                         // There is no way to determine if the focus change was programmatic or due
   1130                         // to keyboard event, or if it was due to a tap/restore. Use a best-guess
   1131                         // by using the fact that auto-focus/keyboard tabs set the selection to 0.
   1132                         setMessageContentSelection(getAccountSignature(mAccount));
   1133                     }
   1134             }
   1135         }
   1136     }
   1137 
   1138     private static void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) {
   1139         if (addresses == null) {
   1140             return;
   1141         }
   1142         for (Address address : addresses) {
   1143             addAddress(view, address.toString());
   1144         }
   1145     }
   1146 
   1147     private static void addAddresses(MultiAutoCompleteTextView view, String[] addresses) {
   1148         if (addresses == null) {
   1149             return;
   1150         }
   1151         for (String oneAddress : addresses) {
   1152             addAddress(view, oneAddress);
   1153         }
   1154     }
   1155 
   1156     private static void addAddresses(MultiAutoCompleteTextView view, String addresses) {
   1157         if (addresses == null) {
   1158             return;
   1159         }
   1160         Address[] unpackedAddresses = Address.unpack(addresses);
   1161         for (Address address : unpackedAddresses) {
   1162             addAddress(view, address.toString());
   1163         }
   1164     }
   1165 
   1166     private static void addAddress(MultiAutoCompleteTextView view, String address) {
   1167         view.append(address + ", ");
   1168     }
   1169 
   1170     private static String getPackedAddresses(TextView view) {
   1171         Address[] addresses = Address.parse(view.getText().toString().trim());
   1172         return Address.pack(addresses);
   1173     }
   1174 
   1175     private static Address[] getAddresses(TextView view) {
   1176         Address[] addresses = Address.parse(view.getText().toString().trim());
   1177         return addresses;
   1178     }
   1179 
   1180     /*
   1181      * Computes a short string indicating the destination of the message based on To, Cc, Bcc.
   1182      * If only one address appears, returns the friendly form of that address.
   1183      * Otherwise returns the friendly form of the first address appended with "and N others".
   1184      */
   1185     private String makeDisplayName(String packedTo, String packedCc, String packedBcc) {
   1186         Address first = null;
   1187         int nRecipients = 0;
   1188         for (String packed: new String[] {packedTo, packedCc, packedBcc}) {
   1189             Address[] addresses = Address.unpack(packed);
   1190             nRecipients += addresses.length;
   1191             if (first == null && addresses.length > 0) {
   1192                 first = addresses[0];
   1193             }
   1194         }
   1195         if (nRecipients == 0) {
   1196             return "";
   1197         }
   1198         String friendly = first.toFriendly();
   1199         if (nRecipients == 1) {
   1200             return friendly;
   1201         }
   1202         return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1);
   1203     }
   1204 
   1205     private ContentValues getUpdateContentValues(Message message) {
   1206         ContentValues values = new ContentValues();
   1207         values.put(MessageColumns.TIMESTAMP, message.mTimeStamp);
   1208         values.put(MessageColumns.FROM_LIST, message.mFrom);
   1209         values.put(MessageColumns.TO_LIST, message.mTo);
   1210         values.put(MessageColumns.CC_LIST, message.mCc);
   1211         values.put(MessageColumns.BCC_LIST, message.mBcc);
   1212         values.put(MessageColumns.SUBJECT, message.mSubject);
   1213         values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName);
   1214         values.put(MessageColumns.FLAG_READ, message.mFlagRead);
   1215         values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded);
   1216         values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment);
   1217         values.put(MessageColumns.FLAGS, message.mFlags);
   1218         return values;
   1219     }
   1220 
   1221     /**
   1222      * Updates the given message using values from the compose UI.
   1223      *
   1224      * @param message The message to be updated.
   1225      * @param account the account (used to obtain From: address).
   1226      * @param hasAttachments true if it has one or more attachment.
   1227      * @param sending set true if the message is about to sent, in which case we perform final
   1228      *        clean up;
   1229      */
   1230     private void updateMessage(Message message, Account account, boolean hasAttachments,
   1231             boolean sending) {
   1232         if (message.mMessageId == null || message.mMessageId.length() == 0) {
   1233             message.mMessageId = Utility.generateMessageId();
   1234         }
   1235         message.mTimeStamp = System.currentTimeMillis();
   1236         message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack();
   1237         message.mTo = getPackedAddresses(mToView);
   1238         message.mCc = getPackedAddresses(mCcView);
   1239         message.mBcc = getPackedAddresses(mBccView);
   1240         message.mSubject = mSubjectView.getText().toString();
   1241         message.mText = mMessageContentView.getText().toString();
   1242         message.mAccountKey = account.mId;
   1243         message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc);
   1244         message.mFlagRead = true;
   1245         message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE;
   1246         message.mFlagAttachment = hasAttachments;
   1247         // Use the Intent to set flags saying this message is a reply or a forward and save the
   1248         // unique id of the source message
   1249         if (mSource != null && mQuotedTextArea.getVisibility() == View.VISIBLE) {
   1250             message.mSourceKey = mSource.mId;
   1251             // If the quote bar is visible; this must either be a reply or forward
   1252             // Get the body of the source message here
   1253             message.mHtmlReply = mSource.mHtml;
   1254             message.mTextReply = mSource.mText;
   1255             String fromAsString = Address.unpackToString(mSource.mFrom);
   1256             if (isForward()) {
   1257                 message.mFlags |= Message.FLAG_TYPE_FORWARD;
   1258                 String subject = mSource.mSubject;
   1259                 String to = Address.unpackToString(mSource.mTo);
   1260                 String cc = Address.unpackToString(mSource.mCc);
   1261                 message.mIntroText =
   1262                     getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString,
   1263                             to != null ? to : "", cc != null ? cc : "");
   1264             } else {
   1265                 message.mFlags |= Message.FLAG_TYPE_REPLY;
   1266                 message.mIntroText =
   1267                     getString(R.string.message_compose_reply_header_fmt, fromAsString);
   1268             }
   1269         }
   1270 
   1271         if (includeQuotedText()) {
   1272             message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
   1273         } else {
   1274             message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT;
   1275             if (sending) {
   1276                 // If we are about to send a message, and not including the original message,
   1277                 // clear the related field.
   1278                 // We can't do this until the last minutes, so that the user can change their
   1279                 // mind later and want to include it again.
   1280                 mDraft.mIntroText = null;
   1281                 mDraft.mTextReply = null;
   1282                 mDraft.mHtmlReply = null;
   1283 
   1284                 // Note that mSourceKey is not cleared out as this is still considered a
   1285                 // reply/forward.
   1286             }
   1287         }
   1288     }
   1289 
   1290     private class SendOrSaveMessageTask extends EmailAsyncTask<Void, Void, Long> {
   1291         private final boolean mSend;
   1292         private final long mTaskId;
   1293 
   1294         /** A context that will survive even past activity destruction. */
   1295         private final Context mContext;
   1296 
   1297         public SendOrSaveMessageTask(long taskId, boolean send) {
   1298             super(null /* DO NOT cancel in onDestroy */);
   1299             if (send && ActivityManager.isUserAMonkey()) {
   1300                 Log.d(Logging.LOG_TAG, "Inhibiting send while monkey is in charge.");
   1301                 send = false;
   1302             }
   1303             mTaskId = taskId;
   1304             mSend = send;
   1305             mContext = getApplicationContext();
   1306 
   1307             sActiveSaveTasks.put(mTaskId, this);
   1308         }
   1309 
   1310         @Override
   1311         protected Long doInBackground(Void... params) {
   1312             synchronized (mDraft) {
   1313                 updateMessage(mDraft, mAccount, mAttachments.size() > 0, mSend);
   1314                 ContentResolver resolver = getContentResolver();
   1315                 if (mDraft.isSaved()) {
   1316                     // Update the message
   1317                     Uri draftUri =
   1318                         ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId);
   1319                     resolver.update(draftUri, getUpdateContentValues(mDraft), null, null);
   1320                     // Update the body
   1321                     ContentValues values = new ContentValues();
   1322                     values.put(BodyColumns.TEXT_CONTENT, mDraft.mText);
   1323                     values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply);
   1324                     values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply);
   1325                     values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText);
   1326                     values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey);
   1327                     Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values);
   1328                 } else {
   1329                     // mDraft.mId is set upon return of saveToMailbox()
   1330                     mController.saveToMailbox(mDraft, Mailbox.TYPE_DRAFTS);
   1331                 }
   1332                 // For any unloaded attachment, set the flag saying we need it loaded
   1333                 boolean hasUnloadedAttachments = false;
   1334                 for (Attachment attachment : mAttachments) {
   1335                     if (attachment.mContentUri == null &&
   1336                             ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0)) {
   1337                         attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD;
   1338                         hasUnloadedAttachments = true;
   1339                         if (Email.DEBUG) {
   1340                             Log.d(Logging.LOG_TAG,
   1341                                     "Requesting download of attachment #" + attachment.mId);
   1342                         }
   1343                     }
   1344                     // Make sure the UI version of the attachment has the now-correct id; we will
   1345                     // use the id again when coming back from picking new attachments
   1346                     if (!attachment.isSaved()) {
   1347                         // this attachment is new so save it to DB.
   1348                         attachment.mMessageKey = mDraft.mId;
   1349                         attachment.save(MessageCompose.this);
   1350                     } else if (attachment.mMessageKey != mDraft.mId) {
   1351                         // We clone the attachment and save it again; otherwise, it will
   1352                         // continue to point to the source message.  From this point forward,
   1353                         // the attachments will be independent of the original message in the
   1354                         // database; however, we still need the message on the server in order
   1355                         // to retrieve unloaded attachments
   1356                         attachment.mMessageKey = mDraft.mId;
   1357                         ContentValues cv = attachment.toContentValues();
   1358                         cv.put(Attachment.FLAGS, attachment.mFlags);
   1359                         cv.put(Attachment.MESSAGE_KEY, mDraft.mId);
   1360                         getContentResolver().insert(Attachment.CONTENT_URI, cv);
   1361                     }
   1362                 }
   1363 
   1364                 if (mSend) {
   1365                     // Let the user know if message sending might be delayed by background
   1366                     // downlading of unloaded attachments
   1367                     if (hasUnloadedAttachments) {
   1368                         Utility.showToast(MessageCompose.this,
   1369                                 R.string.message_view_attachment_background_load);
   1370                     }
   1371                     mController.sendMessage(mDraft);
   1372 
   1373                     ArrayList<CharSequence> addressTexts = new ArrayList<CharSequence>();
   1374                     addressTexts.add(mToView.getText());
   1375                     addressTexts.add(mCcView.getText());
   1376                     addressTexts.add(mBccView.getText());
   1377                     DataUsageStatUpdater updater = new DataUsageStatUpdater(mContext);
   1378                     updater.updateWithRfc822Address(addressTexts);
   1379                 }
   1380                 return mDraft.mId;
   1381             }
   1382         }
   1383 
   1384         private boolean shouldShowSaveToast() {
   1385             // Don't show the toast when rotating, or when opening an Activity on top of this one.
   1386             return !isChangingConfigurations() && !mPickingAttachment;
   1387         }
   1388 
   1389         @Override
   1390         protected void onSuccess(Long draftId) {
   1391             // Note that send or save tasks are always completed, even if the activity
   1392             // finishes earlier.
   1393             sActiveSaveTasks.remove(mTaskId);
   1394             // Don't display the toast if the user is just changing the orientation
   1395             if (!mSend && shouldShowSaveToast()) {
   1396                 Toast.makeText(mContext, R.string.message_saved_toast, Toast.LENGTH_LONG).show();
   1397             }
   1398         }
   1399     }
   1400 
   1401     /**
   1402      * Send or save a message:
   1403      * - out of the UI thread
   1404      * - write to Drafts
   1405      * - if send, invoke Controller.sendMessage()
   1406      * - when operation is complete, display toast
   1407      */
   1408     private void sendOrSaveMessage(boolean send) {
   1409         if (!mMessageLoaded) {
   1410             Log.w(Logging.LOG_TAG,
   1411                     "Attempted to save draft message prior to the state being fully loaded");
   1412             return;
   1413         }
   1414         synchronized (sActiveSaveTasks) {
   1415             mLastSaveTaskId = sNextSaveTaskId++;
   1416 
   1417             SendOrSaveMessageTask task = new SendOrSaveMessageTask(mLastSaveTaskId, send);
   1418 
   1419             // Ensure the tasks are executed serially so that rapid scheduling doesn't result
   1420             // in inconsistent data.
   1421             task.executeSerial();
   1422         }
   1423    }
   1424 
   1425     private void saveIfNeeded() {
   1426         if (!mDraftNeedsSaving) {
   1427             return;
   1428         }
   1429         setMessageChanged(false);
   1430         sendOrSaveMessage(false);
   1431     }
   1432 
   1433     /**
   1434      * Checks whether all the email addresses listed in TO, CC, BCC are valid.
   1435      */
   1436     @VisibleForTesting
   1437     boolean isAddressAllValid() {
   1438         boolean supportsChips = ChipsUtil.supportsChipsUi();
   1439         for (TextView view : new TextView[]{mToView, mCcView, mBccView}) {
   1440             String addresses = view.getText().toString().trim();
   1441             if (!Address.isAllValid(addresses)) {
   1442                 // Don't show an error message if we're using chips as the chips have
   1443                 // their own error state.
   1444                 if (!supportsChips || !(view instanceof RecipientEditTextView)) {
   1445                     view.setError(getString(R.string.message_compose_error_invalid_email));
   1446                 }
   1447                 return false;
   1448             }
   1449         }
   1450         return true;
   1451     }
   1452 
   1453     private void onSend() {
   1454         if (!isAddressAllValid()) {
   1455             Toast.makeText(this, getString(R.string.message_compose_error_invalid_email),
   1456                            Toast.LENGTH_LONG).show();
   1457         } else if (getAddresses(mToView).length == 0 &&
   1458                 getAddresses(mCcView).length == 0 &&
   1459                 getAddresses(mBccView).length == 0) {
   1460             mToView.setError(getString(R.string.message_compose_error_no_recipients));
   1461             Toast.makeText(this, getString(R.string.message_compose_error_no_recipients),
   1462                     Toast.LENGTH_LONG).show();
   1463         } else {
   1464             sendOrSaveMessage(true);
   1465             setMessageChanged(false);
   1466             finish();
   1467         }
   1468     }
   1469 
   1470     private void showQuickResponseDialog() {
   1471         if (mAccount == null) {
   1472             // Load not finished, bail.
   1473             return;
   1474         }
   1475         InsertQuickResponseDialog.newInstance(null, mAccount)
   1476                 .show(getFragmentManager(), null);
   1477     }
   1478 
   1479     /**
   1480      * Inserts the selected QuickResponse into the message body at the current cursor position.
   1481      */
   1482     @Override
   1483     public void onQuickResponseSelected(CharSequence text) {
   1484         int start = mMessageContentView.getSelectionStart();
   1485         int end = mMessageContentView.getSelectionEnd();
   1486         mMessageContentView.getEditableText().replace(start, end, text);
   1487     }
   1488 
   1489     private void onDiscard() {
   1490         DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog");
   1491     }
   1492 
   1493     /**
   1494      * Called when ok on the "discard draft" dialog is pressed.  Actually delete the draft.
   1495      */
   1496     @Override
   1497     public void onDeleteMessageConfirmationDialogOkPressed() {
   1498         if (mDraft.mId > 0) {
   1499             // By the way, we can't pass the message ID from onDiscard() to here (using a
   1500             // dialog argument or whatever), because you can rotate the screen when the dialog is
   1501             // shown, and during rotation we save & restore the draft.  If it's the
   1502             // first save, we give it an ID at this point for the first time (and last time).
   1503             // Which means it's possible for a draft to not have an ID in onDiscard(),
   1504             // but here.
   1505             mController.deleteMessage(mDraft.mId);
   1506         }
   1507         Utility.showToast(MessageCompose.this, R.string.message_discarded_toast);
   1508         setMessageChanged(false);
   1509         finish();
   1510     }
   1511 
   1512     /**
   1513      * Handles an explicit user-initiated action to save a draft.
   1514      */
   1515     private void onSave() {
   1516         saveIfNeeded();
   1517     }
   1518 
   1519     private void showCcBccFieldsIfFilled() {
   1520         if ((mCcView.length() > 0) || (mBccView.length() > 0)) {
   1521             showCcBccFields();
   1522         }
   1523     }
   1524 
   1525     private void showCcBccFields() {
   1526         if (mCcBccContainer.getVisibility() != View.VISIBLE) {
   1527             mCcBccContainer.setVisibility(View.VISIBLE);
   1528             mCcView.requestFocus();
   1529             UiUtilities.setVisibilitySafe(this, R.id.add_cc_bcc, View.INVISIBLE);
   1530             invalidateOptionsMenu();
   1531         }
   1532     }
   1533 
   1534     /**
   1535      * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over.
   1536      */
   1537     private void onAddAttachment() {
   1538         Intent i = new Intent(Intent.ACTION_GET_CONTENT);
   1539         i.addCategory(Intent.CATEGORY_OPENABLE);
   1540         i.setType(AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]);
   1541         mPickingAttachment = true;
   1542         startActivityForResult(
   1543                 Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)),
   1544                 ACTIVITY_REQUEST_PICK_ATTACHMENT);
   1545     }
   1546 
   1547     private Attachment loadAttachmentInfo(Uri uri) {
   1548         long size = -1;
   1549         ContentResolver contentResolver = getContentResolver();
   1550 
   1551         // Load name & size independently, because not all providers support both
   1552         final String name = Utility.getContentFileName(this, uri);
   1553 
   1554         Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION,
   1555                 null, null, null);
   1556         if (metadataCursor != null) {
   1557             try {
   1558                 if (metadataCursor.moveToFirst()) {
   1559                     size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE);
   1560                 }
   1561             } finally {
   1562                 metadataCursor.close();
   1563             }
   1564         }
   1565 
   1566         // When the size is not provided, we need to determine it locally.
   1567         if (size < 0) {
   1568             // if the URI is a file: URI, ask file system for its size
   1569             if ("file".equalsIgnoreCase(uri.getScheme())) {
   1570                 String path = uri.getPath();
   1571                 if (path != null) {
   1572                     File file = new File(path);
   1573                     size = file.length();  // Returns 0 for file not found
   1574                 }
   1575             }
   1576 
   1577             if (size <= 0) {
   1578                 // The size was not measurable;  This attachment is not safe to use.
   1579                 // Quick hack to force a relevant error into the UI
   1580                 // TODO: A proper announcement of the problem
   1581                 size = AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE + 1;
   1582             }
   1583         }
   1584 
   1585         Attachment attachment = new Attachment();
   1586         attachment.mFileName = name;
   1587         attachment.mContentUri = uri.toString();
   1588         attachment.mSize = size;
   1589         attachment.mMimeType = AttachmentUtilities.inferMimeTypeForUri(this, uri);
   1590         return attachment;
   1591     }
   1592 
   1593     private void addAttachment(Attachment attachment) {
   1594         // Before attaching the attachment, make sure it meets any other pre-attach criteria
   1595         if (attachment.mSize > AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE) {
   1596             Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG)
   1597                     .show();
   1598             return;
   1599         }
   1600 
   1601         mAttachments.add(attachment);
   1602         updateAttachmentUi();
   1603     }
   1604 
   1605     private void updateAttachmentUi() {
   1606         mAttachmentContentView.removeAllViews();
   1607 
   1608         for (Attachment attachment : mAttachments) {
   1609             // Note: allowDelete is set in two cases:
   1610             // 1. First time a message (w/ attachments) is forwarded,
   1611             //    where action == ACTION_FORWARD
   1612             // 2. 1 -> Save -> Reopen
   1613             //    but FLAG_SMART_FORWARD is already set at 1.
   1614             // Even if the account supports smart-forward, attachments added
   1615             // manually are still removable.
   1616             final boolean allowDelete = (attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0;
   1617 
   1618             View view = getLayoutInflater().inflate(R.layout.attachment, mAttachmentContentView,
   1619                     false);
   1620             TextView nameView = UiUtilities.getView(view, R.id.attachment_name);
   1621             ImageView delete = UiUtilities.getView(view, R.id.remove_attachment);
   1622             TextView sizeView = UiUtilities.getView(view, R.id.attachment_size);
   1623 
   1624             nameView.setText(attachment.mFileName);
   1625             if (attachment.mSize > 0) {
   1626                 sizeView.setText(UiUtilities.formatSize(this, attachment.mSize));
   1627             } else {
   1628                 sizeView.setVisibility(View.GONE);
   1629             }
   1630             if (allowDelete) {
   1631                 delete.setOnClickListener(this);
   1632                 delete.setTag(view);
   1633             } else {
   1634                 delete.setVisibility(View.INVISIBLE);
   1635             }
   1636             view.setTag(attachment);
   1637             mAttachmentContentView.addView(view);
   1638         }
   1639         updateAttachmentContainer();
   1640     }
   1641 
   1642     private void updateAttachmentContainer() {
   1643         mAttachmentContainer.setVisibility(mAttachmentContentView.getChildCount() == 0
   1644                 ? View.GONE : View.VISIBLE);
   1645     }
   1646 
   1647     private void addAttachmentFromUri(Uri uri) {
   1648         addAttachment(loadAttachmentInfo(uri));
   1649     }
   1650 
   1651     /**
   1652      * Same as {@link #addAttachmentFromUri}, but does the mime-type check against
   1653      * {@link AttachmentUtilities#ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES}.
   1654      */
   1655     private void addAttachmentFromSendIntent(Uri uri) {
   1656         final Attachment attachment = loadAttachmentInfo(uri);
   1657         final String mimeType = attachment.mMimeType;
   1658         if (!TextUtils.isEmpty(mimeType) && MimeUtility.mimeTypeMatches(mimeType,
   1659                 AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) {
   1660             addAttachment(attachment);
   1661         }
   1662     }
   1663 
   1664     @Override
   1665     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
   1666         mPickingAttachment = false;
   1667         if (data == null) {
   1668             return;
   1669         }
   1670         addAttachmentFromUri(data.getData());
   1671         setMessageChanged(true);
   1672     }
   1673 
   1674     private boolean includeQuotedText() {
   1675         return mIncludeQuotedTextCheckBox.isChecked();
   1676     }
   1677 
   1678     public void onClick(View view) {
   1679         if (handleCommand(view.getId())) {
   1680             return;
   1681         }
   1682         switch (view.getId()) {
   1683             case R.id.remove_attachment:
   1684                 onDeleteAttachmentIconClicked(view);
   1685                 break;
   1686         }
   1687     }
   1688 
   1689     private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) {
   1690         mIncludeQuotedTextCheckBox.setChecked(include);
   1691         mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked()
   1692                 ? View.VISIBLE : View.GONE);
   1693         if (updateNeedsSaving) {
   1694             setMessageChanged(true);
   1695         }
   1696     }
   1697 
   1698     private void onDeleteAttachmentIconClicked(View delButtonView) {
   1699         View attachmentView = (View) delButtonView.getTag();
   1700         Attachment attachment = (Attachment) attachmentView.getTag();
   1701         deleteAttachment(mAttachments, attachment);
   1702         updateAttachmentUi();
   1703         setMessageChanged(true);
   1704     }
   1705 
   1706     /**
   1707      * Removes an attachment from the current message.
   1708      * If the attachment has previous been saved in the db (i.e. this is a draft message which
   1709      * has previously been saved), then the draft is deleted from the db.
   1710      *
   1711      * This does not update the UI to remove the attachment view.
   1712      * @param attachments the list of attachments to delete from. Injected for tests.
   1713      * @param attachment the attachment to delete
   1714      */
   1715     private void deleteAttachment(List<Attachment> attachments, Attachment attachment) {
   1716         attachments.remove(attachment);
   1717         if ((attachment.mMessageKey == mDraft.mId) && attachment.isSaved()) {
   1718             final long attachmentId = attachment.mId;
   1719             EmailAsyncTask.runAsyncParallel(new Runnable() {
   1720                 @Override
   1721                 public void run() {
   1722                     mController.deleteAttachment(attachmentId);
   1723                 }
   1724             });
   1725         }
   1726     }
   1727 
   1728     @Override
   1729     public boolean onOptionsItemSelected(MenuItem item) {
   1730         if (handleCommand(item.getItemId())) {
   1731             return true;
   1732         }
   1733         return super.onOptionsItemSelected(item);
   1734     }
   1735 
   1736     private boolean handleCommand(int viewId) {
   1737         switch (viewId) {
   1738         case android.R.id.home:
   1739             onBack(false /* systemKey */);
   1740             return true;
   1741         case R.id.send:
   1742             onSend();
   1743             return true;
   1744         case R.id.save:
   1745             onSave();
   1746             return true;
   1747         case R.id.show_quick_text_list_dialog:
   1748             showQuickResponseDialog();
   1749             return true;
   1750         case R.id.discard:
   1751             onDiscard();
   1752             return true;
   1753         case R.id.include_quoted_text:
   1754             // The checkbox is already toggled at this point.
   1755             setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true);
   1756             return true;
   1757         case R.id.add_cc_bcc:
   1758             showCcBccFields();
   1759             return true;
   1760         case R.id.add_attachment:
   1761             onAddAttachment();
   1762             return true;
   1763         case R.id.settings:
   1764             AccountSettings.actionSettings(this, mAccount.mId);
   1765             return true;
   1766         }
   1767         return false;
   1768     }
   1769 
   1770     /**
   1771      * Handle a tap to the system back key, or the "app up" button in the action bar.
   1772      * @param systemKey whether or not the system key was pressed
   1773      */
   1774     private void onBack(boolean systemKey) {
   1775         finish();
   1776         if (isOpenedFromWithinApp()) {
   1777             // If opened from within the app, we just close it.
   1778             return;
   1779         }
   1780 
   1781         if (isOpenedFromWidget() || !systemKey) {
   1782             // Otherwise, need to open the main screen for the appropriate account.
   1783             // Note that mAccount should always be set by the time the action bar is set up.
   1784             startActivity(Welcome.createOpenAccountInboxIntent(this, mAccount.mId));
   1785         }
   1786     }
   1787 
   1788     private void setAction(String action) {
   1789         if (Objects.equal(action, mAction)) {
   1790             return;
   1791         }
   1792 
   1793         mAction = action;
   1794         onActionChanged();
   1795     }
   1796 
   1797     /**
   1798      * Handles changing from reply/reply all/forward states. Note: this activity cannot transition
   1799      * from a standard compose state to any of the other three states.
   1800      */
   1801     private void onActionChanged() {
   1802         if (!hasSourceMessage()) {
   1803             return;
   1804         }
   1805         // Temporarily remove listeners so that changing action does not invalidate and save message
   1806         removeListeners();
   1807 
   1808         processSourceMessage(mSource, mAccount);
   1809 
   1810         // Note that the attachments might not be loaded yet, but this will safely noop
   1811         // if that's the case, and the attachments will be processed when they load.
   1812         if (processSourceMessageAttachments(mAttachments, mSourceAttachments, isForward())) {
   1813             updateAttachmentUi();
   1814             setMessageChanged(true);
   1815         }
   1816 
   1817         updateActionSelector();
   1818         addListeners();
   1819     }
   1820 
   1821     /**
   1822      * Updates UI components that allows the user to switch between reply/reply all/forward.
   1823      */
   1824     private void updateActionSelector() {
   1825         ActionBar actionBar = getActionBar();
   1826         if (shouldUseActionTabs()) {
   1827             // Tab-based mode switching.
   1828             if (actionBar.getTabCount() > 0) {
   1829                 selectActionTab(mAction);
   1830             } else {
   1831                 createAndAddTab(R.string.reply_action, ACTION_REPLY);
   1832                 createAndAddTab(R.string.reply_all_action, ACTION_REPLY_ALL);
   1833                 createAndAddTab(R.string.forward_action, ACTION_FORWARD);
   1834             }
   1835 
   1836             actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_TABS);
   1837         } else {
   1838             // Spinner based mode switching.
   1839             if (mActionSpinnerAdapter == null) {
   1840                 mActionSpinnerAdapter = new ActionSpinnerAdapter(this);
   1841                 actionBar.setListNavigationCallbacks(
   1842                         mActionSpinnerAdapter, ACTION_SPINNER_LISTENER);
   1843             }
   1844             actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
   1845             actionBar.setSelectedNavigationItem(
   1846                     ActionSpinnerAdapter.getActionPosition(mAction));
   1847         }
   1848         actionBar.setDisplayShowTitleEnabled(false);
   1849     }
   1850 
   1851     private final TabListener ACTION_TAB_LISTENER = new TabListener() {
   1852         @Override public void onTabReselected(Tab tab, FragmentTransaction ft) {}
   1853         @Override public void onTabUnselected(Tab tab, FragmentTransaction ft) {}
   1854 
   1855         @Override
   1856         public void onTabSelected(Tab tab, FragmentTransaction ft) {
   1857             String action = (String) tab.getTag();
   1858             setAction(action);
   1859         }
   1860     };
   1861 
   1862     private final OnNavigationListener ACTION_SPINNER_LISTENER = new OnNavigationListener() {
   1863         @Override
   1864         public boolean onNavigationItemSelected(int itemPosition, long itemId) {
   1865             setAction(ActionSpinnerAdapter.getAction(itemPosition));
   1866             return true;
   1867         }
   1868     };
   1869 
   1870     private static class ActionSpinnerAdapter extends ArrayAdapter<String> {
   1871         public ActionSpinnerAdapter(final Context context) {
   1872             super(context,
   1873                     android.R.layout.simple_spinner_dropdown_item,
   1874                     android.R.id.text1,
   1875                     Lists.newArrayList(ACTION_REPLY, ACTION_REPLY_ALL, ACTION_FORWARD));
   1876         }
   1877 
   1878         @Override
   1879         public View getDropDownView(int position, View convertView, ViewGroup parent) {
   1880             View result = super.getDropDownView(position, convertView, parent);
   1881             ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position));
   1882             return result;
   1883         }
   1884 
   1885         @Override
   1886         public View getView(int position, View convertView, ViewGroup parent) {
   1887             View result = super.getView(position, convertView, parent);
   1888             ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position));
   1889             return result;
   1890         }
   1891 
   1892         private String getDisplayValue(int position) {
   1893             switch (position) {
   1894                 case 0:
   1895                     return getContext().getString(R.string.reply_action);
   1896                 case 1:
   1897                     return getContext().getString(R.string.reply_all_action);
   1898                 case 2:
   1899                     return getContext().getString(R.string.forward_action);
   1900                 default:
   1901                     throw new IllegalArgumentException("Invalid action type for spinner");
   1902             }
   1903         }
   1904 
   1905         public static String getAction(int position) {
   1906             switch (position) {
   1907                 case 0:
   1908                     return ACTION_REPLY;
   1909                 case 1:
   1910                     return ACTION_REPLY_ALL;
   1911                 case 2:
   1912                     return ACTION_FORWARD;
   1913                 default:
   1914                     throw new IllegalArgumentException("Invalid action type for spinner");
   1915             }
   1916         }
   1917 
   1918         public static int getActionPosition(String action) {
   1919             if (ACTION_REPLY.equals(action)) {
   1920                 return 0;
   1921             } else if (ACTION_REPLY_ALL.equals(action)) {
   1922                 return 1;
   1923             } else if (ACTION_FORWARD.equals(action)) {
   1924                 return 2;
   1925             }
   1926             throw new IllegalArgumentException("Invalid action type for spinner");
   1927         }
   1928 
   1929     }
   1930 
   1931     private Tab createAndAddTab(int labelResource, final String action) {
   1932         ActionBar.Tab tab = getActionBar().newTab();
   1933         boolean selected = mAction.equals(action);
   1934         tab.setTag(action);
   1935         tab.setText(getString(labelResource));
   1936         tab.setTabListener(ACTION_TAB_LISTENER);
   1937         getActionBar().addTab(tab, selected);
   1938         return tab;
   1939     }
   1940 
   1941     private void selectActionTab(final String action) {
   1942         final ActionBar actionBar = getActionBar();
   1943         for (int i = 0, n = actionBar.getTabCount(); i < n; i++) {
   1944             ActionBar.Tab tab = actionBar.getTabAt(i);
   1945             if (action.equals(tab.getTag())) {
   1946                 actionBar.selectTab(tab);
   1947                 return;
   1948             }
   1949         }
   1950     }
   1951 
   1952     private boolean shouldUseActionTabs() {
   1953         return getResources().getBoolean(R.bool.message_compose_action_tabs);
   1954     }
   1955 
   1956     @Override
   1957     public boolean onCreateOptionsMenu(Menu menu) {
   1958         super.onCreateOptionsMenu(menu);
   1959         getMenuInflater().inflate(R.menu.message_compose_option, menu);
   1960         return true;
   1961     }
   1962 
   1963     @Override
   1964     public boolean onPrepareOptionsMenu(Menu menu) {
   1965         menu.findItem(R.id.save).setEnabled(mDraftNeedsSaving);
   1966         MenuItem addCcBcc = menu.findItem(R.id.add_cc_bcc);
   1967         if (addCcBcc != null) {
   1968             // Only available on phones.
   1969             addCcBcc.setVisible(
   1970                     (mCcBccContainer == null) || (mCcBccContainer.getVisibility() != View.VISIBLE));
   1971         }
   1972         MenuItem insertQuickResponse = menu.findItem(R.id.show_quick_text_list_dialog);
   1973         insertQuickResponse.setVisible(mQuickResponsesAvailable);
   1974         insertQuickResponse.setEnabled(mQuickResponsesAvailable);
   1975         return true;
   1976     }
   1977 
   1978     /**
   1979      * Set a message body and a signature when the Activity is launched.
   1980      *
   1981      * @param text the message body
   1982      */
   1983     @VisibleForTesting
   1984     void setInitialComposeText(CharSequence text, String signature) {
   1985         mMessageContentView.setText("");
   1986         int textLength = 0;
   1987         if (text != null) {
   1988             mMessageContentView.append(text);
   1989             textLength = text.length();
   1990         }
   1991         if (!TextUtils.isEmpty(signature)) {
   1992             if (textLength == 0 || text.charAt(textLength - 1) != '\n') {
   1993                 mMessageContentView.append("\n");
   1994             }
   1995             mMessageContentView.append(signature);
   1996 
   1997             // Reset cursor to right before the signature.
   1998             mMessageContentView.setSelection(textLength);
   1999         }
   2000     }
   2001 
   2002     /**
   2003      * Fill all the widgets with the content found in the Intent Extra, if any.
   2004      *
   2005      * Note that we don't actually check the intent action  (typically VIEW, SENDTO, or SEND).
   2006      * There is enough overlap in the definitions that it makes more sense to simply check for
   2007      * all available data and use as much of it as possible.
   2008      *
   2009      * With one exception:  EXTRA_STREAM is defined as only valid for ACTION_SEND.
   2010      *
   2011      * @param intent the launch intent
   2012      */
   2013     @VisibleForTesting
   2014     void initFromIntent(Intent intent) {
   2015 
   2016         setAccount(intent);
   2017 
   2018         // First, add values stored in top-level extras
   2019         String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
   2020         if (extraStrings != null) {
   2021             addAddresses(mToView, extraStrings);
   2022         }
   2023         extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
   2024         if (extraStrings != null) {
   2025             addAddresses(mCcView, extraStrings);
   2026         }
   2027         extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
   2028         if (extraStrings != null) {
   2029             addAddresses(mBccView, extraStrings);
   2030         }
   2031         String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
   2032         if (extraString != null) {
   2033             mSubjectView.setText(extraString);
   2034         }
   2035 
   2036         // Next, if we were invoked with a URI, try to interpret it
   2037         // We'll take two courses here.  If it's mailto:, there is a specific set of rules
   2038         // that define various optional fields.  However, for any other scheme, we'll simply
   2039         // take the entire scheme-specific part and interpret it as a possible list of addresses.
   2040         final Uri dataUri = intent.getData();
   2041         if (dataUri != null) {
   2042             if ("mailto".equals(dataUri.getScheme())) {
   2043                 initializeFromMailTo(dataUri.toString());
   2044             } else {
   2045                 String toText = dataUri.getSchemeSpecificPart();
   2046                 if (toText != null) {
   2047                     addAddresses(mToView, toText.split(","));
   2048                 }
   2049             }
   2050         }
   2051 
   2052         // Next, fill in the plaintext (note, this will override mailto:?body=)
   2053         CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT);
   2054         setInitialComposeText(text, getAccountSignature(mAccount));
   2055 
   2056         // Next, convert EXTRA_STREAM into an attachment
   2057         if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) {
   2058             Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM);
   2059             if (uri != null) {
   2060                 addAttachmentFromSendIntent(uri);
   2061             }
   2062         }
   2063 
   2064         if (Intent.ACTION_SEND_MULTIPLE.equals(mAction)
   2065                 && intent.hasExtra(Intent.EXTRA_STREAM)) {
   2066             ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
   2067             if (list != null) {
   2068                 for (Parcelable parcelable : list) {
   2069                     Uri uri = (Uri) parcelable;
   2070                     if (uri != null) {
   2071                         addAttachmentFromSendIntent(uri);
   2072                     }
   2073                 }
   2074             }
   2075         }
   2076 
   2077         // Finally - expose fields that were filled in but are normally hidden, and set focus
   2078         showCcBccFieldsIfFilled();
   2079         setNewMessageFocus();
   2080     }
   2081 
   2082     /**
   2083      * When we are launched with an intent that includes a mailto: URI, we can actually
   2084      * gather quite a few of our message fields from it.
   2085      *
   2086      * @param mailToString the href (which must start with "mailto:").
   2087      */
   2088     private void initializeFromMailTo(String mailToString) {
   2089 
   2090         // Chop up everything between mailto: and ? to find recipients
   2091         int index = mailToString.indexOf("?");
   2092         int length = "mailto".length() + 1;
   2093         String to;
   2094         try {
   2095             // Extract the recipient after mailto:
   2096             if (index == -1) {
   2097                 to = decode(mailToString.substring(length));
   2098             } else {
   2099                 to = decode(mailToString.substring(length, index));
   2100             }
   2101             addAddresses(mToView, to.split(" ,"));
   2102         } catch (UnsupportedEncodingException e) {
   2103             Log.e(Logging.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'");
   2104         }
   2105 
   2106         // Extract the other parameters
   2107 
   2108         // We need to disguise this string as a URI in order to parse it
   2109         Uri uri = Uri.parse("foo://" + mailToString);
   2110 
   2111         List<String> cc = uri.getQueryParameters("cc");
   2112         addAddresses(mCcView, cc.toArray(new String[cc.size()]));
   2113 
   2114         List<String> otherTo = uri.getQueryParameters("to");
   2115         addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()]));
   2116 
   2117         List<String> bcc = uri.getQueryParameters("bcc");
   2118         addAddresses(mBccView, bcc.toArray(new String[bcc.size()]));
   2119 
   2120         List<String> subject = uri.getQueryParameters("subject");
   2121         if (subject.size() > 0) {
   2122             mSubjectView.setText(subject.get(0));
   2123         }
   2124 
   2125         List<String> body = uri.getQueryParameters("body");
   2126         if (body.size() > 0) {
   2127             setInitialComposeText(body.get(0), getAccountSignature(mAccount));
   2128         }
   2129     }
   2130 
   2131     private String decode(String s) throws UnsupportedEncodingException {
   2132         return URLDecoder.decode(s, "UTF-8");
   2133     }
   2134 
   2135     /**
   2136      * Displays quoted text from the original email
   2137      */
   2138     private void displayQuotedText(String textBody, String htmlBody) {
   2139         // Only use plain text if there is no HTML body
   2140         boolean plainTextFlag = TextUtils.isEmpty(htmlBody);
   2141         String text = plainTextFlag ? textBody : htmlBody;
   2142         if (text != null) {
   2143             text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text;
   2144             // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML
   2145             //    EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount,
   2146             //                                     text, message, 0);
   2147             mQuotedTextArea.setVisibility(View.VISIBLE);
   2148             if (mQuotedText != null) {
   2149                 mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null);
   2150             }
   2151         }
   2152     }
   2153 
   2154     /**
   2155      * Given a packed address String, the address of our sending account, a view, and a list of
   2156      * addressees already added to other addressing views, adds unique addressees that don't
   2157      * match our address to the passed in view
   2158      */
   2159     private static boolean safeAddAddresses(String addrs, String ourAddress,
   2160             MultiAutoCompleteTextView view, ArrayList<Address> addrList) {
   2161         boolean added = false;
   2162         for (Address address : Address.unpack(addrs)) {
   2163             // Don't send to ourselves or already-included addresses
   2164             if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) {
   2165                 addrList.add(address);
   2166                 addAddress(view, address.toString());
   2167                 added = true;
   2168             }
   2169         }
   2170         return added;
   2171     }
   2172 
   2173     /**
   2174      * Set up the to and cc views properly for the "reply" and "replyAll" cases.  What's important
   2175      * is that we not 1) send to ourselves, and 2) duplicate addressees.
   2176      * @param message the message we're replying to
   2177      * @param account the account we're sending from
   2178      * @param replyAll whether this is a replyAll (vs a reply)
   2179      */
   2180     @VisibleForTesting
   2181     void setupAddressViews(Message message, Account account, boolean replyAll) {
   2182         // Start clean.
   2183         clearAddressViews();
   2184 
   2185         // If Reply-to: addresses are included, use those; otherwise, use the From: address.
   2186         Address[] replyToAddresses = Address.unpack(message.mReplyTo);
   2187         if (replyToAddresses.length == 0) {
   2188             replyToAddresses = Address.unpack(message.mFrom);
   2189         }
   2190 
   2191         // Check if ourAddress is one of the replyToAddresses to decide how to populate To: field
   2192         String ourAddress = account.mEmailAddress;
   2193         boolean containsOurAddress = false;
   2194         for (Address address : replyToAddresses) {
   2195             if (ourAddress.equalsIgnoreCase(address.getAddress())) {
   2196                 containsOurAddress = true;
   2197                 break;
   2198             }
   2199         }
   2200 
   2201         if (containsOurAddress) {
   2202             addAddresses(mToView, message.mTo);
   2203         } else {
   2204             addAddresses(mToView, replyToAddresses);
   2205         }
   2206 
   2207         if (replyAll) {
   2208             // Keep a running list of addresses we're sending to
   2209             ArrayList<Address> allAddresses = new ArrayList<Address>();
   2210             for (Address address: replyToAddresses) {
   2211                 allAddresses.add(address);
   2212             }
   2213 
   2214             if (!containsOurAddress) {
   2215                 safeAddAddresses(message.mTo, ourAddress, mCcView, allAddresses);
   2216             }
   2217 
   2218             safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses);
   2219         }
   2220         showCcBccFieldsIfFilled();
   2221     }
   2222 
   2223     private void clearAddressViews() {
   2224         mToView.setText("");
   2225         mCcView.setText("");
   2226         mBccView.setText("");
   2227     }
   2228 
   2229     /**
   2230      * Pull out the parts of the now loaded source message and apply them to the new message
   2231      * depending on the type of message being composed.
   2232      */
   2233     @VisibleForTesting
   2234     void processSourceMessage(Message message, Account account) {
   2235         String subject = message.mSubject;
   2236         if (subject == null) {
   2237             subject = "";
   2238         }
   2239         if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) {
   2240             setupAddressViews(message, account, ACTION_REPLY_ALL.equals(mAction));
   2241             if (!subject.toLowerCase().startsWith("re:")) {
   2242                 mSubjectView.setText("Re: " + subject);
   2243             } else {
   2244                 mSubjectView.setText(subject);
   2245             }
   2246             displayQuotedText(message.mText, message.mHtml);
   2247             setIncludeQuotedText(true, false);
   2248         } else if (ACTION_FORWARD.equals(mAction)) {
   2249             clearAddressViews();
   2250             mSubjectView.setText(!subject.toLowerCase().startsWith("fwd:")
   2251                     ? "Fwd: " + subject : subject);
   2252             displayQuotedText(message.mText, message.mHtml);
   2253             setIncludeQuotedText(true, false);
   2254         } else {
   2255             Log.w(Logging.LOG_TAG, "Unexpected action for a call to processSourceMessage "
   2256                     + mAction);
   2257         }
   2258         showCcBccFieldsIfFilled();
   2259         setNewMessageFocus();
   2260     }
   2261 
   2262     /**
   2263      * Processes the source attachments and ensures they're either included or excluded from
   2264      * a list of active attachments. This can be used to add attachments for a forwarded message, or
   2265      * to remove them if going from a "Forward" to a "Reply"
   2266      * Uniqueness is based on filename.
   2267      *
   2268      * @param current the list of active attachments on the current message. Injected for tests.
   2269      * @param sourceAttachments the list of attachments related with the source message. Injected
   2270      *     for tests.
   2271      * @param include whether or not the sourceMessages should be included or excluded from the
   2272      *     current list of active attachments
   2273      * @return whether or not the current attachments were modified
   2274      */
   2275     @VisibleForTesting
   2276     boolean processSourceMessageAttachments(
   2277             List<Attachment> current, List<Attachment> sourceAttachments, boolean include) {
   2278 
   2279         // Build a map of filename to the active attachments.
   2280         HashMap<String, Attachment> currentNames = new HashMap<String, Attachment>();
   2281         for (Attachment attachment : current) {
   2282             currentNames.put(attachment.mFileName, attachment);
   2283         }
   2284 
   2285         boolean dirty = false;
   2286         if (include) {
   2287             // Needs to make sure it's in the list.
   2288             for (Attachment attachment : sourceAttachments) {
   2289                 if (!currentNames.containsKey(attachment.mFileName)) {
   2290                     current.add(attachment);
   2291                     dirty = true;
   2292                 }
   2293             }
   2294         } else {
   2295             // Need to remove the source attachments.
   2296             HashSet<String> sourceNames = new HashSet<String>();
   2297             for (Attachment attachment : sourceAttachments) {
   2298                 if (currentNames.containsKey(attachment.mFileName)) {
   2299                     deleteAttachment(current, currentNames.get(attachment.mFileName));
   2300                     dirty = true;
   2301                 }
   2302             }
   2303         }
   2304 
   2305         return dirty;
   2306     }
   2307 
   2308     /**
   2309      * Set a cursor to the end of a body except a signature.
   2310      */
   2311     @VisibleForTesting
   2312     void setMessageContentSelection(String signature) {
   2313         int selection = mMessageContentView.length();
   2314         if (!TextUtils.isEmpty(signature)) {
   2315             int signatureLength = signature.length();
   2316             int estimatedSelection = selection - signatureLength;
   2317             if (estimatedSelection >= 0) {
   2318                 CharSequence text = mMessageContentView.getText();
   2319                 int i = 0;
   2320                 while (i < signatureLength
   2321                        && text.charAt(estimatedSelection + i) == signature.charAt(i)) {
   2322                     ++i;
   2323                 }
   2324                 if (i == signatureLength) {
   2325                     selection = estimatedSelection;
   2326                     while (selection > 0 && text.charAt(selection - 1) == '\n') {
   2327                         --selection;
   2328                     }
   2329                 }
   2330             }
   2331         }
   2332         mMessageContentView.setSelection(selection, selection);
   2333     }
   2334 
   2335     /**
   2336      * In order to accelerate typing, position the cursor in the first empty field,
   2337      * or at the end of the body composition field if none are empty.  Typically, this will
   2338      * play out as follows:
   2339      *   Reply / Reply All - put cursor in the empty message body
   2340      *   Forward - put cursor in the empty To field
   2341      *   Edit Draft - put cursor in whatever field still needs entry
   2342      */
   2343     private void setNewMessageFocus() {
   2344         if (mToView.length() == 0) {
   2345             mToView.requestFocus();
   2346         } else if (mSubjectView.length() == 0) {
   2347             mSubjectView.requestFocus();
   2348         } else {
   2349             mMessageContentView.requestFocus();
   2350         }
   2351     }
   2352 
   2353     private boolean isForward() {
   2354         return ACTION_FORWARD.equals(mAction);
   2355     }
   2356 
   2357     /**
   2358      * @return the signature for the specified account, if non-null. If the account specified is
   2359      *     null or has no signature, {@code null} is returned.
   2360      */
   2361     private static String getAccountSignature(Account account) {
   2362         return (account == null) ? null : account.mSignature;
   2363     }
   2364 }
   2365