• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
getBaseIntent(Context context)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      */
getMessageComposeIntent(Context context, long accountId)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      */
getMainAppIntent(Context context, long accountId)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      */
actionCompose(Context context, long accountId)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      */
actionCompose(Context context, String uriString, long accountId)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      */
actionReply(Context context, long messageId, boolean replyAll)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      */
actionForward(Context context, long messageId)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      */
actionEditDraft(Context context, long messageId)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      */
startActivityWithMessage(Context context, String action, long messageId)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 
setAccount(Intent intent)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 
setAccount(Account account)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
onCreate(Bundle savedInstanceState)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 
resolveIntent(Intent intent)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
onRestoreInstanceState(Bundle savedInstanceState)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
setIntent(Intent intent)469     public void setIntent(Intent intent) {
470         super.setIntent(intent);
471         setAction(intent.getAction());
472     }
473 
setQuickResponsesAvailable(boolean quickResponsesAvailable)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> {
QuickResponseChecker(EmailAsyncTask.Tracker tracker)486         public QuickResponseChecker(EmailAsyncTask.Tracker tracker) {
487             super(tracker);
488         }
489 
490         @Override
doInBackground(Void... params)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
onSuccess(Boolean quickResponsesAvailable)498         protected void onSuccess(Boolean quickResponsesAvailable) {
499             setQuickResponsesAvailable(quickResponsesAvailable);
500         }
501     }
502 
503     @Override
onResume()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
onPause()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
onDestroy()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
onSaveInstanceState(Bundle outState)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
onBackPressed()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      */
hasSourceMessage()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      */
isOpenedFromWithinApp()590     private boolean isOpenedFromWithinApp() {
591         Intent i = getIntent();
592         return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false));
593     }
594 
isOpenedFromWidget()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      */
setMessageLoaded(boolean isLoaded)604     private void setMessageLoaded(boolean isLoaded) {
605         if (mMessageLoaded != isLoaded) {
606             mMessageLoaded = isLoaded;
607             addListeners();
608             mInitiallyEmpty = areViewsEmpty();
609         }
610     }
611 
setMessageChanged(boolean messageChanged)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      */
areViewsEmpty()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 
isBodyEmpty()633     private boolean isBodyEmpty() {
634         return (mMessageContentView.length() == 0)
635                 || mMessageContentView.getText()
636                         .toString().equals("\n" + getAccountSignature(mAccount));
637     }
638 
setFocusShifter(int fromViewId, final int targetViewId)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 
initViews()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      */
addListeners()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      */
removeListeners()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      */
setupAddressAdapters()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      */
resumeDraft( long draftId, SendOrSaveMessageTask existingSaveTask, final boolean restoreViews)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
processDraftMessage(Message message, boolean restoreViews)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      */
loadSourceMessage(long sourceMessageId, final boolean restoreViews)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
inferAction()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          */
onMessageLoaded(Message message, Body body)1015         void onMessageLoaded(Message message, Body body);
1016 
1017         /**
1018          * Handles a failure to load a message.
1019          */
onLoadFailed()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 
LoadMessageTask( long messageId, SendOrSaveMessageTask saveTask, OnMessageLoadHandler callback)1043         public LoadMessageTask(
1044                 long messageId, SendOrSaveMessageTask saveTask, OnMessageLoadHandler callback) {
1045             super(mTaskTracker);
1046             mMessageId = messageId;
1047             mSaveTask = saveTask;
1048             mCallback = callback;
1049         }
1050 
getIdToLoad()1051         private long getIdToLoad() throws InterruptedException, ExecutionException {
1052             if (mMessageId == -1) {
1053                 mMessageId = mSaveTask.get();
1054             }
1055             return mMessageId;
1056         }
1057 
1058         @Override
doInBackground(Void... params)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
onSuccess(Object[] results)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          */
onAttachmentLoaded(Attachment[] attachment)1116         void onAttachmentLoaded(Attachment[] attachment);
1117     }
1118 
loadAttachments( final long messageId, final Account account, final AttachmentLoadedCallback callback)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
onFocusChange(View view, boolean focused)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 
addAddresses(MultiAutoCompleteTextView view, Address[] addresses)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 
addAddresses(MultiAutoCompleteTextView view, String[] addresses)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 
addAddresses(MultiAutoCompleteTextView view, String addresses)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 
addAddress(MultiAutoCompleteTextView view, String address)1185     private static void addAddress(MultiAutoCompleteTextView view, String address) {
1186         view.append(address + ", ");
1187     }
1188 
getPackedAddresses(TextView view)1189     private static String getPackedAddresses(TextView view) {
1190         Address[] addresses = Address.parse(view.getText().toString().trim());
1191         return Address.pack(addresses);
1192     }
1193 
getAddresses(TextView view)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      */
makeDisplayName(String packedTo, String packedCc, String packedBcc)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 
getUpdateContentValues(Message message)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      */
updateMessage(Message message, Account account, boolean hasAttachments, boolean sending)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 
SendOrSaveMessageTask(long taskId, boolean send)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
doInBackground(Void... params)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 
shouldShowSaveToast()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
onSuccess(Long draftId)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      */
sendOrSaveMessage(boolean send)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 
saveIfNeeded()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
isAddressAllValid()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 
onSend()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 
showQuickResponseDialog()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
onQuickResponseSelected(CharSequence text)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 
onDiscard()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
onDeleteMessageConfirmationDialogOkPressed()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      */
onSave()1534     private void onSave() {
1535         saveIfNeeded();
1536     }
1537 
showCcBccFieldsIfFilled()1538     private void showCcBccFieldsIfFilled() {
1539         if ((mCcView.length() > 0) || (mBccView.length() > 0)) {
1540             showCcBccFields();
1541         }
1542     }
1543 
showCcBccFields()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      */
onAddAttachment()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 
loadAttachmentInfo(Uri uri)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 
addAttachment(Attachment attachment)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 
updateAttachmentUi()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 
updateAttachmentContainer()1662     private void updateAttachmentContainer() {
1663         mAttachmentContainer.setVisibility(mAttachmentContentView.getChildCount() == 0
1664                 ? View.GONE : View.VISIBLE);
1665     }
1666 
addAttachmentFromUri(Uri uri)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      */
addAttachmentFromSendIntent(Uri uri)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
onActivityResult(int requestCode, int resultCode, Intent data)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 
includeQuotedText()1694     private boolean includeQuotedText() {
1695         return mIncludeQuotedTextCheckBox.isChecked();
1696     }
1697 
1698     @Override
onClick(View view)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 
setIncludeQuotedText(boolean include, boolean updateNeedsSaving)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 
onDeleteAttachmentIconClicked(View delButtonView)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      */
deleteAttachment(List<Attachment> attachments, Attachment attachment)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
onOptionsItemSelected(MenuItem item)1750     public boolean onOptionsItemSelected(MenuItem item) {
1751         if (handleCommand(item.getItemId())) {
1752             return true;
1753         }
1754         return super.onOptionsItemSelected(item);
1755     }
1756 
handleCommand(int viewId)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      */
onBack(boolean systemKey)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 
setAction(String action)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      */
onActionChanged()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      */
updateActionSelector()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> {
ActionSpinnerAdapter(final Context context)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
getDropDownView(int position, View convertView, ViewGroup parent)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
getView(int position, View convertView, ViewGroup parent)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 
getDisplayValue(int position)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 
getAction(int position)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 
getActionPosition(String action)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
onCreateOptionsMenu(Menu menu)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
onPrepareOptionsMenu(Menu menu)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
setInitialComposeText(CharSequence text, String signature)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
initFromIntent(Intent intent)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      */
initializeFromMailTo(String mailToString)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 
decode(String s)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      */
displayQuotedText(String textBody, String htmlBody)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      */
safeAddAddresses(String addrs, String ourAddress, MultiAutoCompleteTextView view, ArrayList<Address> addrList)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
setupAddressViews(Message message, Account account, boolean replyAll)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 
clearAddressViews()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
processSourceMessage(Message message, Account account)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
processSourceMessageAttachments( List<Attachment> current, List<Attachment> sourceAttachments, boolean include)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
setMessageContentSelection(String signature)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      */
setNewMessageFocus()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 
isForward()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      */
getAccountSignature(Account account)2334     private static String getAccountSignature(Account account) {
2335         return (account == null) ? null : account.mSignature;
2336     }
2337 }
2338