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