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