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