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