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