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