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