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