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