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