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