ComposeActivity.java revision b18e5a9341c73541d7201830c513cb0ed247e935
1/**
2 * Copyright (c) 2011, Google Inc.
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.mail.compose;
18
19import android.app.ActionBar;
20import android.app.ActionBar.OnNavigationListener;
21import android.app.Activity;
22import android.app.ActivityManager;
23import android.app.AlertDialog;
24import android.app.Dialog;
25import android.content.ContentResolver;
26import android.content.ContentValues;
27import android.content.Context;
28import android.content.DialogInterface;
29import android.content.Intent;
30import android.content.pm.ActivityInfo;
31import android.database.Cursor;
32import android.net.Uri;
33import android.os.Bundle;
34import android.os.Handler;
35import android.os.HandlerThread;
36import android.os.Parcelable;
37import android.provider.BaseColumns;
38import android.text.Editable;
39import android.text.Html;
40import android.text.Spanned;
41import android.text.TextUtils;
42import android.text.TextWatcher;
43import android.text.util.Rfc822Token;
44import android.text.util.Rfc822Tokenizer;
45import android.view.LayoutInflater;
46import android.view.Menu;
47import android.view.MenuInflater;
48import android.view.MenuItem;
49import android.view.View;
50import android.view.View.OnClickListener;
51import android.view.View.OnFocusChangeListener;
52import android.view.ViewGroup;
53import android.view.inputmethod.BaseInputConnection;
54import android.widget.ArrayAdapter;
55import android.widget.Button;
56import android.widget.EditText;
57import android.widget.ImageView;
58import android.widget.TextView;
59import android.widget.Toast;
60
61import com.android.common.Rfc822Validator;
62import com.android.ex.chips.RecipientEditTextView;
63import com.android.mail.R;
64import com.android.mail.compose.AttachmentsView.AttachmentDeletedListener;
65import com.android.mail.compose.AttachmentsView.AttachmentFailureException;
66import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener;
67import com.android.mail.compose.QuotedTextView.RespondInlineListener;
68import com.android.mail.providers.Account;
69import com.android.mail.providers.Address;
70import com.android.mail.providers.Attachment;
71import com.android.mail.providers.Folder;
72import com.android.mail.providers.Message;
73import com.android.mail.providers.MessageModification;
74import com.android.mail.providers.ReplyFromAccount;
75import com.android.mail.providers.Settings;
76import com.android.mail.providers.UIProvider;
77import com.android.mail.providers.UIProvider.AccountCapabilities;
78import com.android.mail.providers.UIProvider.DraftType;
79import com.android.mail.utils.AccountUtils;
80import com.android.mail.utils.LogTag;
81import com.android.mail.utils.LogUtils;
82import com.android.mail.utils.Utils;
83import com.google.common.annotations.VisibleForTesting;
84import com.google.common.collect.Lists;
85import com.google.common.collect.Sets;
86
87import org.json.JSONException;
88
89import java.io.UnsupportedEncodingException;
90import java.net.URLDecoder;
91import java.util.ArrayList;
92import java.util.Arrays;
93import java.util.Collection;
94import java.util.HashMap;
95import java.util.HashSet;
96import java.util.List;
97import java.util.Map.Entry;
98import java.util.Set;
99import java.util.concurrent.ConcurrentHashMap;
100
101public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
102        RespondInlineListener, DialogInterface.OnClickListener, TextWatcher,
103        AttachmentDeletedListener, OnAccountChangedListener {
104    // Identifiers for which type of composition this is
105    static final int COMPOSE = -1;
106    static final int REPLY = 0;
107    static final int REPLY_ALL = 1;
108    static final int FORWARD = 2;
109    static final int EDIT_DRAFT = 3;
110
111    // Integer extra holding one of the above compose action
112    private static final String EXTRA_ACTION = "action";
113
114    private static final String EXTRA_SHOW_CC = "showCc";
115    private static final String EXTRA_SHOW_BCC = "showBcc";
116
117    private static final String UTF8_ENCODING_NAME = "UTF-8";
118
119    private static final String MAIL_TO = "mailto";
120
121    private static final String EXTRA_SUBJECT = "subject";
122
123    private static final String EXTRA_BODY = "body";
124
125    private static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString";
126
127    // Extra that we can get passed from other activities
128    private static final String EXTRA_TO = "to";
129    private static final String EXTRA_CC = "cc";
130    private static final String EXTRA_BCC = "bcc";
131
132    // List of all the fields
133    static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC };
134
135    private static SendOrSaveCallback sTestSendOrSaveCallback = null;
136    // Map containing information about requests to create new messages, and the id of the
137    // messages that were the result of those requests.
138    //
139    // This map is used when the activity that initiated the save a of a new message, is killed
140    // before the save has completed (and when we know the id of the newly created message).  When
141    // a save is completed, the service that is running in the background, will update the map
142    //
143    // When a new ComposeActivity instance is created, it will attempt to use the information in
144    // the previously instantiated map.  If ComposeActivity.onCreate() is called, with a bundle
145    // (restoring data from a previous instance), and the map hasn't been created, we will attempt
146    // to populate the map with data stored in shared preferences.
147    private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null;
148    // Key used to store the above map
149    private static final String CACHED_MESSAGE_REQUEST_IDS_KEY = "cache-message-request-ids";
150    /**
151     * Notifies the {@code Activity} that the caller is an Email
152     * {@code Activity}, so that the back behavior may be modified accordingly.
153     *
154     * @see #onAppUpPressed
155     */
156    public static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
157
158    public static final String EXTRA_ATTACHMENTS = "attachments";
159
160    //  If this is a reply/forward then this extra will hold the original message
161    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message";
162    // If this is a reply/forward then this extra will hold a uri we must query
163    // to get the original message.
164    protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri";
165    // If this is an action to edit an existing draft messagge, this extra will hold the
166    // draft message
167    private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message";
168    private static final String END_TOKEN = ", ";
169    private static final String LOG_TAG = LogTag.getLogTag();
170    // Request numbers for activities we start
171    private static final int RESULT_PICK_ATTACHMENT = 1;
172    private static final int RESULT_CREATE_ACCOUNT = 2;
173    // TODO(mindyp) set mime-type for auto send?
174    public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND";
175
176    // Max size for attachments (5 megs). Will be overridden by account settings if found.
177    // TODO(mindyp): read this from account settings?
178    private static final int DEFAULT_MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024;
179    private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount";
180    private static final String EXTRA_REQUEST_ID = "requestId";
181    private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart";
182    private static final String EXTRA_FOCUS_SELECTION_END = null;
183    private static final String EXTRA_MESSAGE = "extraMessage";
184
185    /**
186     * A single thread for running tasks in the background.
187     */
188    private Handler mSendSaveTaskHandler = null;
189    private RecipientEditTextView mTo;
190    private RecipientEditTextView mCc;
191    private RecipientEditTextView mBcc;
192    private Button mCcBccButton;
193    private CcBccView mCcBccView;
194    private AttachmentsView mAttachmentsView;
195    private Account mAccount;
196    private ReplyFromAccount mReplyFromAccount;
197    private Settings mCachedSettings;
198    private Rfc822Validator mValidator;
199    private TextView mSubject;
200
201    private ComposeModeAdapter mComposeModeAdapter;
202    private int mComposeMode = -1;
203    private boolean mForward;
204    private String mRecipient;
205    private QuotedTextView mQuotedTextView;
206    private EditText mBodyView;
207    private View mFromStatic;
208    private TextView mFromStaticText;
209    private View mFromSpinnerWrapper;
210    @VisibleForTesting
211    protected FromAddressSpinner mFromSpinner;
212    private boolean mAddingAttachment;
213    private boolean mAttachmentsChanged;
214    private boolean mTextChanged;
215    private boolean mReplyFromChanged;
216    private MenuItem mSave;
217    private MenuItem mSend;
218    private AlertDialog mRecipientErrorDialog;
219    private AlertDialog mSendConfirmDialog;
220    @VisibleForTesting
221    protected Message mRefMessage;
222    private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
223    private Message mDraft;
224    private Object mDraftLock = new Object();
225    private ImageView mAttachmentsButton;
226
227    /**
228     * Boolean indicating whether ComposeActivity was launched from a Gmail controlled view.
229     */
230    private boolean mLaunchedFromEmail = false;
231    private RecipientTextWatcher mToListener;
232    private RecipientTextWatcher mCcListener;
233    private RecipientTextWatcher mBccListener;
234    private Uri mRefMessageUri;
235
236
237    /**
238     * Can be called from a non-UI thread.
239     */
240    public static void editDraft(Context launcher, Account account, Message message) {
241        launch(launcher, account, message, EDIT_DRAFT);
242    }
243
244    /**
245     * Can be called from a non-UI thread.
246     */
247    public static void compose(Context launcher, Account account) {
248        launch(launcher, account, null, COMPOSE);
249    }
250
251    /**
252     * Can be called from a non-UI thread.
253     */
254    public static void reply(Context launcher, Account account, Message message) {
255        launch(launcher, account, message, REPLY);
256    }
257
258    /**
259     * Can be called from a non-UI thread.
260     */
261    public static void replyAll(Context launcher, Account account, Message message) {
262        launch(launcher, account, message, REPLY_ALL);
263    }
264
265    /**
266     * Can be called from a non-UI thread.
267     */
268    public static void forward(Context launcher, Account account, Message message) {
269        launch(launcher, account, message, FORWARD);
270    }
271
272    private static void launch(Context launcher, Account account, Message message, int action) {
273        Intent intent = new Intent(launcher, ComposeActivity.class);
274        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
275        intent.putExtra(EXTRA_ACTION, action);
276        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
277        if (action == EDIT_DRAFT) {
278            intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message);
279        } else {
280            intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message);
281        }
282        launcher.startActivity(intent);
283    }
284
285    @Override
286    public void onCreate(Bundle savedInstanceState) {
287        super.onCreate(savedInstanceState);
288        setContentView(R.layout.compose);
289        findViews();
290        Intent intent = getIntent();
291        Account account = null;
292        Message message;
293        boolean showQuotedText = false;
294        int action;
295        Object accountExtra = intent != null && intent.getExtras() != null ? intent.getExtras()
296                .get(Utils.EXTRA_ACCOUNT) : null;
297        final Account[] syncingAccounts = AccountUtils.getSyncingAccounts(this);
298        if (hadSavedInstanceStateMessage(savedInstanceState)) {
299            action = savedInstanceState.getInt(EXTRA_ACTION, COMPOSE);
300            account = savedInstanceState.getParcelable(Utils.EXTRA_ACCOUNT);
301            message = (Message) savedInstanceState.getParcelable(EXTRA_MESSAGE);
302            mRefMessage = (Message) savedInstanceState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE);
303        } else {
304            if (accountExtra instanceof Account) {
305                account = (Account) intent.getExtras().get(Utils.EXTRA_ACCOUNT);
306            } else if (accountExtra instanceof String) {
307                // For backwards compatibility
308                String extraAccount = intent.getStringExtra(Utils.EXTRA_ACCOUNT);
309                if (syncingAccounts.length > 0) {
310                    if (!TextUtils.isEmpty(extraAccount)) {
311                        for (Account a : syncingAccounts) {
312                            if (a.name.equals(extraAccount)) {
313                                account = a;
314                            }
315                        }
316                    }
317                }
318            }
319            action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
320            // Initialize the message from the message in the intent
321            message = (Message) intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE);
322            mRefMessage = (Message) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
323            mRefMessageUri = (Uri) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI);
324        }
325        if (account == null) {
326            if (syncingAccounts != null && syncingAccounts.length > 0) {
327                    account = syncingAccounts[0];
328            }
329        }
330
331        setAccount(account);
332        if (mAccount == null) {
333            return;
334        }
335
336        if (intent.getBooleanExtra(EXTRA_FROM_EMAIL_TASK, false)) {
337            mLaunchedFromEmail = true;
338        } else if (Intent.ACTION_SEND.equals(intent.getAction())) {
339            final Uri dataUri = intent.getData();
340            if (dataUri != null) {
341                final String dataScheme = intent.getData().getScheme();
342                final String accountScheme = mAccount.composeIntentUri.getScheme();
343                mLaunchedFromEmail = TextUtils.equals(dataScheme, accountScheme);
344            }
345        }
346
347        if (message != null && action != EDIT_DRAFT) {
348            initFromDraftMessage(message);
349            initQuotedTextFromRefMessage(mRefMessage, action);
350            showCcBcc(savedInstanceState);
351            showQuotedText = message.appendRefMessageContent;
352        } else if (action == EDIT_DRAFT) {
353            initFromDraftMessage(message);
354            boolean showBcc = !TextUtils.isEmpty(message.bcc);
355            boolean showCc = showBcc || !TextUtils.isEmpty(message.cc);
356            mCcBccView.show(false, showCc, showBcc);
357            // Update the action to the draft type of the previous draft
358            switch (message.draftType) {
359                case UIProvider.DraftType.REPLY:
360                    action = REPLY;
361                    break;
362                case UIProvider.DraftType.REPLY_ALL:
363                    action = REPLY_ALL;
364                    break;
365                case UIProvider.DraftType.FORWARD:
366                    action = FORWARD;
367                    break;
368                case UIProvider.DraftType.COMPOSE:
369                default:
370                    action = COMPOSE;
371                    break;
372            }
373            initQuotedTextFromRefMessage(mRefMessage, action);
374            showQuotedText = message.appendRefMessageContent;
375        } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
376            if (mRefMessage != null) {
377                initFromRefMessage(action, mAccount.name);
378                if (mRefMessage != null) {
379                    // CC field only gets populated when doing REPLY_ALL.
380                    // BCC never gets auto-populated, unless the user is editing
381                    // a draft with one.
382                    if (!TextUtils.isEmpty(mRefMessage.cc) && action == REPLY_ALL) {
383                        mCcBccView.show(false, true, false);
384                    }
385                }
386                updateHideOrShowCcBcc();
387                showQuotedText = true;
388            }
389        } else {
390            initFromExtras(intent);
391        }
392
393        if (action == COMPOSE) {
394            mQuotedTextView.setVisibility(View.GONE);
395        }
396        initRecipients();
397        // Don't bother with the intent if we have procured a message from the
398        // intent already.
399        if (!hadSavedInstanceStateMessage(savedInstanceState)) {
400            initAttachmentsFromIntent(intent);
401        }
402        initActionBar(action);
403        initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(),
404                action);
405        initChangeListeners();
406        setFocus(action);
407        updateHideOrShowCcBcc();
408        updateHideOrShowQuotedText(showQuotedText);
409    }
410
411    private boolean hadSavedInstanceStateMessage(Bundle savedInstanceState) {
412        return savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE);
413    }
414
415    private void updateHideOrShowQuotedText(boolean showQuotedText) {
416        mQuotedTextView.updateCheckedState(showQuotedText);
417    }
418
419    private void setFocus(int action) {
420        if (action == EDIT_DRAFT) {
421            int type = mDraft.draftType;
422            switch (type) {
423                case UIProvider.DraftType.COMPOSE:
424                case UIProvider.DraftType.FORWARD:
425                    action = COMPOSE;
426                    break;
427                case UIProvider.DraftType.REPLY:
428                case UIProvider.DraftType.REPLY_ALL:
429                default:
430                    action = REPLY;
431                    break;
432            }
433        }
434        switch (action) {
435            case FORWARD:
436            case COMPOSE:
437                mTo.requestFocus();
438                break;
439            case REPLY:
440            case REPLY_ALL:
441            default:
442                focusBody();
443                break;
444        }
445    }
446
447    /**
448     * Focus the body of the message.
449     */
450    public void focusBody() {
451        mBodyView.requestFocus();
452        int length = mBodyView.getText().length();
453
454        int signatureStartPos = getSignatureStartPosition(
455                mSignature, mBodyView.getText().toString());
456        if (signatureStartPos > -1) {
457            // In case the user deleted the newlines...
458            mBodyView.setSelection(signatureStartPos);
459        } else if (length > 0) {
460            // Move cursor to the end.
461            mBodyView.setSelection(length);
462        }
463    }
464
465    @Override
466    protected void onResume() {
467        super.onResume();
468        // Update the from spinner as other accounts
469        // may now be available.
470        if (mFromSpinner != null && mAccount != null) {
471            mFromSpinner.asyncInitFromSpinner(mComposeMode, mAccount);
472        }
473    }
474
475    @Override
476    protected void onPause() {
477        super.onPause();
478
479        if (mSendConfirmDialog != null) {
480            mSendConfirmDialog.dismiss();
481        }
482        if (mRecipientErrorDialog != null) {
483            mRecipientErrorDialog.dismiss();
484        }
485        // When the user exits the compose view, see if this draft needs saving.
486        if (isFinishing()) {
487            saveIfNeeded();
488        }
489    }
490
491    @Override
492    protected final void onActivityResult(int request, int result, Intent data) {
493        mAddingAttachment = false;
494
495        if (result == RESULT_OK && request == RESULT_PICK_ATTACHMENT) {
496            addAttachmentAndUpdateView(data);
497        }
498    }
499
500    @Override
501    public final void onRestoreInstanceState(Bundle savedInstanceState) {
502        super.onRestoreInstanceState(savedInstanceState);
503        if (savedInstanceState != null) {
504            if (savedInstanceState.containsKey(EXTRA_FOCUS_SELECTION_START)) {
505                int selectionStart = savedInstanceState.getInt(EXTRA_FOCUS_SELECTION_START);
506                int selectionEnd = savedInstanceState.getInt(EXTRA_FOCUS_SELECTION_END);
507                // There should be a focus and it should be an EditText since we
508                // only save these extras if these conditions are true.
509                EditText focusEditText = (EditText) getCurrentFocus();
510                final int length = focusEditText.getText().length();
511                if (selectionStart < length && selectionEnd < length) {
512                    focusEditText.setSelection(selectionStart, selectionEnd);
513                }
514            }
515        }
516    }
517
518    @Override
519    public final void onSaveInstanceState(Bundle state) {
520        super.onSaveInstanceState(state);
521        // The framework is happy to save and restore the selection but only if it also saves and
522        // restores the contents of the edit text. That's a lot of text to put in a bundle so we do
523        // this manually.
524        View focus = getCurrentFocus();
525        if (focus != null && focus instanceof EditText) {
526            EditText focusEditText = (EditText) focus;
527            state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart());
528            state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd());
529        }
530
531        final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
532        final int selectedPos = mFromSpinner.getSelectedItemPosition();
533        final ReplyFromAccount selectedReplyFromAccount = (replyFromAccounts != null
534                && replyFromAccounts.size() > 0 && replyFromAccounts.size() > selectedPos) ?
535                        replyFromAccounts.get(selectedPos) : null;
536        if (selectedReplyFromAccount != null) {
537            state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize()
538                    .toString());
539            state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account);
540        } else {
541            state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount);
542        }
543
544        if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId !=0) {
545            // We don't have a draft id, and we have a request id,
546            // save the request id.
547            state.putInt(EXTRA_REQUEST_ID, mRequestId);
548        }
549
550        // We want to restore the current mode after a pause
551        // or rotation.
552        int mode = getMode();
553        state.putInt(EXTRA_ACTION, mode);
554
555        Message message = createMessage(selectedReplyFromAccount, mode);
556        state.putParcelable(EXTRA_MESSAGE, message);
557
558        if (mRefMessage != null) {
559            state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage);
560        }
561        state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible());
562        state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible());
563    }
564
565    private int getMode() {
566        int mode = ComposeActivity.COMPOSE;
567        ActionBar actionBar = getActionBar();
568        if (actionBar != null
569                && actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) {
570            mode = actionBar.getSelectedNavigationIndex();
571        }
572        return mode;
573    }
574
575    private Message createMessage(ReplyFromAccount selectedReplyFromAccount, int mode) {
576        Message message = new Message();
577        message.id = UIProvider.INVALID_MESSAGE_ID;
578        message.serverId =UIProvider.INVALID_MESSAGE_ID;
579        message.uri = null;
580        message.conversationUri = null;
581        message.subject = mSubject.getText().toString();
582        message.snippet = null;
583        message.from = selectedReplyFromAccount != null ?
584                selectedReplyFromAccount.name : mAccount.name;
585        message.to = mTo.getText().toString();
586        message.cc = mCc.getText().toString();
587        message.bcc = mBcc.getText().toString();
588        message.replyTo = null;
589        message.dateReceivedMs = 0;
590        String htmlBody = Html.toHtml(mBodyView.getText());
591        StringBuilder fullBody = new StringBuilder(htmlBody);
592        message.bodyHtml = fullBody.toString();
593        message.bodyText = mBodyView.getText().toString();
594        message.embedsExternalResources = false;
595        message.refMessageId = mRefMessage != null ? mRefMessage.uri.toString() : null;
596        message.draftType = getDraftType(mode);
597        message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null;
598        ArrayList<Attachment> attachments = mAttachmentsView.getAttachments();
599        message.hasAttachments = attachments != null && attachments.size() > 0;
600        message.attachmentListUri = null;
601        message.messageFlags = 0;
602        message.saveUri = null;
603        message.sendUri = null;
604        message.alwaysShowImages = false;
605        message.attachmentsJson = Attachment.toJSONArray(attachments);
606        CharSequence quotedText = mQuotedTextView.getQuotedText();
607        message.quotedTextOffset = !TextUtils.isEmpty(quotedText) ? QuotedTextView
608                .getQuotedTextOffset(quotedText.toString()) : -1;
609        message.accountUri = null;
610        return message;
611    }
612
613    @VisibleForTesting
614    void setAccount(Account account) {
615        if (account == null) {
616            return;
617        }
618        if (!account.equals(mAccount)) {
619            mAccount = account;
620            mCachedSettings = mAccount.settings;
621            appendSignature();
622        }
623    }
624
625    private void initFromSpinner(Bundle bundle, int action) {
626        String accountString = null;
627        if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) {
628            action = COMPOSE;
629        }
630        mFromSpinner.asyncInitFromSpinner(action, mAccount);
631        if (bundle != null) {
632            if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) {
633                mReplyFromAccount = ReplyFromAccount.deserialize(mAccount,
634                        bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT));
635            } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) {
636                accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING);
637                mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString);
638            }
639        }
640        if (mReplyFromAccount == null) {
641            if (mDraft != null) {
642                mReplyFromAccount = getReplyFromAccountFromDraft(mAccount, mDraft);
643            } else if (mRefMessage != null) {
644                mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage);
645            }
646        }
647        if (mReplyFromAccount == null) {
648            mReplyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name,
649                    mAccount.name, mAccount.name, true, false);
650        }
651
652        mFromSpinner.setCurrentAccount(mReplyFromAccount);
653
654        if (mFromSpinner.getCount() > 1) {
655            // If there is only 1 account, just show that account.
656            // Otherwise, give the user the ability to choose which account to
657            // send mail from / save drafts to.
658            mFromStatic.setVisibility(View.GONE);
659            mFromStaticText.setText(mAccount.name);
660            mFromSpinnerWrapper.setVisibility(View.VISIBLE);
661        } else {
662            mFromStatic.setVisibility(View.VISIBLE);
663            mFromStaticText.setText(mAccount.name);
664            mFromSpinnerWrapper.setVisibility(View.GONE);
665        }
666    }
667
668    private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) {
669        if (refMessage.accountUri != null) {
670            // This must be from combined inbox.
671            List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
672            for (ReplyFromAccount from : replyFromAccounts) {
673                if (from.account.uri.equals(refMessage.accountUri)) {
674                    return from;
675                }
676            }
677            return null;
678        } else {
679            return getReplyFromAccount(account, refMessage);
680        }
681    }
682
683    /**
684     * Given an account and which email address the message was sent to,
685     * return who the message should be sent from.
686     * @param account Account in which the message arrived.
687     * @param sentTo Email address to which the message was sent.
688     * @return the address from which to reply.
689     */
690    public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) {
691        // First see if we are supposed to use the default address or
692        // the address it was sentTo.
693        if (mCachedSettings.forceReplyFromDefault) {
694            return getDefaultReplyFromAccount(account);
695        } else {
696            // If we aren't explicitly told which account to look for, look at
697            // all the message recipients and find one that matches
698            // a custom from or account.
699            List<String> allRecipients = new ArrayList<String>();
700            allRecipients.addAll(Arrays.asList(Utils.splitCommaSeparatedString(refMessage.to)));
701            allRecipients.addAll(Arrays.asList(Utils.splitCommaSeparatedString(refMessage.cc)));
702            return getMatchingRecipient(account, allRecipients);
703        }
704    }
705
706    /**
707     * Compare all the recipients of an email to the current account and all
708     * custom addresses associated with that account. Return the match if there
709     * is one, or the default account if there isn't.
710     */
711    protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) {
712        // Tokenize the list and place in a hashmap.
713        ReplyFromAccount matchingReplyFrom = null;
714        Rfc822Token[] tokens;
715        HashSet<String> recipientsMap = new HashSet<String>();
716        for (String address : sentTo) {
717            tokens = Rfc822Tokenizer.tokenize(address);
718            for (int i = 0; i < tokens.length; i++) {
719                recipientsMap.add(tokens[i].getAddress());
720            }
721        }
722
723        int matchingAddressCount = 0;
724        List<ReplyFromAccount> customFroms;
725        try {
726            customFroms = FromAddressSpinner.getAccountSpecificFroms(account);
727            if (customFroms != null) {
728                for (ReplyFromAccount entry : customFroms) {
729                    if (recipientsMap.contains(entry.address)) {
730                        matchingReplyFrom = entry;
731                        matchingAddressCount++;
732                    }
733                }
734            }
735        } catch (JSONException e) {
736            LogUtils.wtf(LOG_TAG, "Exception parsing from addresses for account %s",
737                    account.name);
738        }
739        if (matchingAddressCount > 1) {
740            matchingReplyFrom = getDefaultReplyFromAccount(account);
741        }
742        return matchingReplyFrom;
743    }
744
745    private ReplyFromAccount getDefaultReplyFromAccount(Account account) {
746        List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
747        for (ReplyFromAccount from : replyFromAccounts) {
748            if (from.isDefault) {
749                return from;
750            }
751        }
752        return new ReplyFromAccount(account, account.uri, account.name, account.name, account.name,
753                true, false);
754    }
755
756    private ReplyFromAccount getReplyFromAccountFromDraft(Account account, Message msg) {
757        String sender = msg.from;
758        ReplyFromAccount replyFromAccount = null;
759        List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
760        if (TextUtils.equals(account.name, sender)) {
761            replyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name,
762                    mAccount.name, mAccount.name, true, false);
763        } else {
764            for (ReplyFromAccount fromAccount : replyFromAccounts) {
765                if (TextUtils.equals(fromAccount.name, sender)) {
766                    replyFromAccount = fromAccount;
767                    break;
768                }
769            }
770        }
771        return replyFromAccount;
772    }
773
774    private void findViews() {
775        mCcBccButton = (Button) findViewById(R.id.add_cc_bcc);
776        if (mCcBccButton != null) {
777            mCcBccButton.setOnClickListener(this);
778        }
779        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
780        mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments);
781        mAttachmentsButton = (ImageView) findViewById(R.id.add_attachment);
782        if (mAttachmentsButton != null) {
783            mAttachmentsButton.setOnClickListener(this);
784        }
785        mTo = (RecipientEditTextView) findViewById(R.id.to);
786        mCc = (RecipientEditTextView) findViewById(R.id.cc);
787        mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
788        // TODO: add special chips text change watchers before adding
789        // this as a text changed watcher to the to, cc, bcc fields.
790        mSubject = (TextView) findViewById(R.id.subject);
791        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
792        mQuotedTextView.setRespondInlineListener(this);
793        mBodyView = (EditText) findViewById(R.id.body);
794        mFromStatic = findViewById(R.id.static_from_content);
795        mFromStaticText = (TextView) findViewById(R.id.from_account_name);
796        mFromSpinnerWrapper = findViewById(R.id.spinner_from_content);
797        mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker);
798    }
799
800    protected TextView getBody() {
801        return mBodyView;
802    }
803
804    @VisibleForTesting
805    public Account getFromAccount() {
806        return mReplyFromAccount != null && mReplyFromAccount.account != null ?
807                mReplyFromAccount.account : mAccount;
808    }
809
810    private void clearChangeListeners() {
811        mSubject.removeTextChangedListener(this);
812        mBodyView.removeTextChangedListener(this);
813        mTo.removeTextChangedListener(mToListener);
814        mCc.removeTextChangedListener(mCcListener);
815        mBcc.removeTextChangedListener(mBccListener);
816        mFromSpinner.setOnAccountChangedListener(null);
817        mAttachmentsView.setAttachmentChangesListener(null);
818    }
819
820    // Now that the message has been initialized from any existing draft or
821    // ref message data, set up listeners for any changes that occur to the
822    // message.
823    private void initChangeListeners() {
824        mSubject.addTextChangedListener(this);
825        mBodyView.addTextChangedListener(this);
826        if (mToListener == null) {
827            mToListener = new RecipientTextWatcher(mTo, this);
828        }
829        mTo.addTextChangedListener(mToListener);
830        if (mCcListener == null) {
831            mCcListener = new RecipientTextWatcher(mCc, this);
832        }
833        mCc.addTextChangedListener(mCcListener);
834        if (mBccListener == null) {
835            mBccListener = new RecipientTextWatcher(mBcc, this);
836        }
837        mBcc.addTextChangedListener(mBccListener);
838        mFromSpinner.setOnAccountChangedListener(this);
839        mAttachmentsView.setAttachmentChangesListener(this);
840    }
841
842    private void initActionBar(int action) {
843        mComposeMode = action;
844        ActionBar actionBar = getActionBar();
845        if (actionBar == null) {
846            return;
847        }
848        if (action == ComposeActivity.COMPOSE) {
849            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
850            actionBar.setTitle(R.string.compose);
851        } else {
852            actionBar.setTitle(null);
853            if (mComposeModeAdapter == null) {
854                mComposeModeAdapter = new ComposeModeAdapter(this);
855            }
856            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
857            actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
858            switch (action) {
859                case ComposeActivity.REPLY:
860                    actionBar.setSelectedNavigationItem(0);
861                    break;
862                case ComposeActivity.REPLY_ALL:
863                    actionBar.setSelectedNavigationItem(1);
864                    break;
865                case ComposeActivity.FORWARD:
866                    actionBar.setSelectedNavigationItem(2);
867                    break;
868            }
869        }
870        actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME,
871                ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME);
872        actionBar.setHomeButtonEnabled(true);
873    }
874
875    private void initFromRefMessage(int action, String recipientAddress) {
876        setSubject(mRefMessage, action);
877        // Setup recipients
878        if (action == FORWARD) {
879            mForward = true;
880        }
881        initRecipientsFromRefMessage(recipientAddress, mRefMessage, action);
882        initQuotedTextFromRefMessage(mRefMessage, action);
883        if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
884            initAttachments(mRefMessage);
885        }
886    }
887
888    private void initFromDraftMessage(Message message) {
889        LogUtils.d(LOG_TAG, "Intializing draft from previous draft message");
890
891        mDraft = message;
892        mDraftId = message.id;
893        mSubject.setText(message.subject);
894        mForward = message.draftType == UIProvider.DraftType.FORWARD;
895        final List<String> toAddresses = Arrays.asList(message.getToAddresses());
896        addToAddresses(toAddresses);
897        addCcAddresses(Arrays.asList(message.getCcAddresses()), toAddresses);
898        addBccAddresses(Arrays.asList(message.getBccAddresses()));
899        if (message.hasAttachments) {
900            List<Attachment> attachments = message.getAttachments();
901            for (Attachment a : attachments) {
902                addAttachmentAndUpdateView(a);
903            }
904        }
905        int quotedTextIndex = message.appendRefMessageContent ?
906                message.quotedTextOffset : -1;
907        // Set the body
908        CharSequence quotedText = null;
909        if (!TextUtils.isEmpty(message.bodyHtml)) {
910            CharSequence htmlText = Html.fromHtml(message.bodyHtml);
911            if (quotedTextIndex > -1) {
912                htmlText = htmlText.subSequence(0, quotedTextIndex);
913                quotedText = message.bodyHtml.subSequence(quotedTextIndex,
914                        message.bodyHtml.length());
915            }
916            mBodyView.setText(htmlText);
917        } else {
918            CharSequence bodyText = quotedTextIndex > -1 ?
919                    message.bodyText.substring(0, quotedTextIndex) : message.bodyText;
920            if (quotedTextIndex > -1) {
921                quotedText = message.bodyText.substring(quotedTextIndex);
922            }
923            mBodyView.setText(bodyText);
924        }
925        if (quotedTextIndex > -1 && quotedText != null) {
926            mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward);
927        }
928    }
929
930    /**
931     * Fill all the widgets with the content found in the Intent Extra, if any.
932     * Also apply the same style to all widgets. Note: if initFromExtras is
933     * called as a result of switching between reply, reply all, and forward per
934     * the latest revision of Gmail, and the user has already made changes to
935     * attachments on a previous incarnation of the message (as a reply, reply
936     * all, or forward), the original attachments from the message will not be
937     * re-instantiated. The user's changes will be respected. This follows the
938     * web gmail interaction.
939     */
940    public void initFromExtras(Intent intent) {
941        // If we were invoked with a SENDTO intent, the value
942        // should take precedence
943        final Uri dataUri = intent.getData();
944        if (dataUri != null) {
945            if (MAIL_TO.equals(dataUri.getScheme())) {
946                initFromMailTo(dataUri.toString());
947            } else {
948                if (!mAccount.composeIntentUri.equals(dataUri)) {
949                    String toText = dataUri.getSchemeSpecificPart();
950                    if (toText != null) {
951                        mTo.setText("");
952                        addToAddresses(Arrays.asList(TextUtils.split(toText, ",")));
953                    }
954                }
955            }
956        }
957
958        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
959        if (extraStrings != null) {
960            addToAddresses(Arrays.asList(extraStrings));
961        }
962        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
963        if (extraStrings != null) {
964            addCcAddresses(Arrays.asList(extraStrings), null);
965        }
966        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
967        if (extraStrings != null) {
968            addBccAddresses(Arrays.asList(extraStrings));
969        }
970
971        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
972        if (extraString != null) {
973            mSubject.setText(extraString);
974        }
975
976        for (String extra : ALL_EXTRAS) {
977            if (intent.hasExtra(extra)) {
978                String value = intent.getStringExtra(extra);
979                if (EXTRA_TO.equals(extra)) {
980                    addToAddresses(Arrays.asList(TextUtils.split(value, ",")));
981                } else if (EXTRA_CC.equals(extra)) {
982                    addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null);
983                } else if (EXTRA_BCC.equals(extra)) {
984                    addBccAddresses(Arrays.asList(TextUtils.split(value, ",")));
985                } else if (EXTRA_SUBJECT.equals(extra)) {
986                    mSubject.setText(value);
987                } else if (EXTRA_BODY.equals(extra)) {
988                    setBody(value, true /* with signature */);
989                }
990            }
991        }
992
993        Bundle extras = intent.getExtras();
994        if (extras != null) {
995            CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT);
996            if (text != null) {
997                setBody(text, true /* with signature */);
998            }
999        }
1000    }
1001
1002    @VisibleForTesting
1003    protected String decodeEmailInUri(String s) throws UnsupportedEncodingException {
1004        // TODO: handle the case where there are spaces in the display name as
1005        // well as the email such as "Guy with spaces <guy+with+spaces@gmail.com>"
1006        // as they could be encoded ambiguously.
1007        // Since URLDecode.decode changes + into ' ', and + is a valid
1008        // email character, we need to find/ replace these ourselves before
1009        // decoding.
1010        String replacePlus = s.replace("+", "%2B");
1011        try {
1012            return URLDecoder.decode(replacePlus, UTF8_ENCODING_NAME);
1013        } catch (IllegalArgumentException e) {
1014            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1015                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s);
1016            } else {
1017                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1018            }
1019            return null;
1020        }
1021    }
1022
1023    /**
1024     * Initialize the compose view from a String representing a mailTo uri.
1025     * @param mailToString The uri as a string.
1026     */
1027    public void initFromMailTo(String mailToString) {
1028        // We need to disguise this string as a URI in order to parse it
1029        // TODO:  Remove this hack when http://b/issue?id=1445295 gets fixed
1030        Uri uri = Uri.parse("foo://" + mailToString);
1031        int index = mailToString.indexOf("?");
1032        int length = "mailto".length() + 1;
1033        String to;
1034        try {
1035            // Extract the recipient after mailto:
1036            if (index == -1) {
1037                to = decodeEmailInUri(mailToString.substring(length));
1038            } else {
1039                to = decodeEmailInUri(mailToString.substring(length, index));
1040            }
1041            if (!TextUtils.isEmpty(to)) {
1042                addToAddresses(Arrays.asList(TextUtils.split(to, ",")));
1043            }
1044        } catch (UnsupportedEncodingException e) {
1045            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1046                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString);
1047            } else {
1048                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1049            }
1050        }
1051
1052        List<String> cc = uri.getQueryParameters("cc");
1053        addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null);
1054
1055        List<String> otherTo = uri.getQueryParameters("to");
1056        addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()])));
1057
1058        List<String> bcc = uri.getQueryParameters("bcc");
1059        addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()])));
1060
1061        List<String> subject = uri.getQueryParameters("subject");
1062        if (subject.size() > 0) {
1063            try {
1064                mSubject.setText(URLDecoder.decode(subject.get(0), UTF8_ENCODING_NAME));
1065            } catch (UnsupportedEncodingException e) {
1066                LogUtils.e(LOG_TAG, "%s while decoding subject '%s'",
1067                        e.getMessage(), subject);
1068            }
1069        }
1070
1071        List<String> body = uri.getQueryParameters("body");
1072        if (body.size() > 0) {
1073            try {
1074                setBody(URLDecoder.decode(body.get(0), UTF8_ENCODING_NAME),
1075                        true /* with signature */);
1076            } catch (UnsupportedEncodingException e) {
1077                LogUtils.e(LOG_TAG, "%s while decoding body '%s'", e.getMessage(), body);
1078            }
1079        }
1080    }
1081
1082    @VisibleForTesting
1083    protected void initAttachments(Message refMessage) {
1084        mAttachmentsView.addAttachments(mAccount, refMessage);
1085    }
1086
1087    private void initAttachmentsFromIntent(Intent intent) {
1088        Bundle extras = intent.getExtras();
1089        if (extras == null) {
1090            extras = Bundle.EMPTY;
1091        }
1092        final String action = intent.getAction();
1093        if (!mAttachmentsChanged) {
1094            long totalSize = 0;
1095            if (extras.containsKey(EXTRA_ATTACHMENTS)) {
1096                String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS);
1097                for (String uriString : uris) {
1098                    final Uri uri = Uri.parse(uriString);
1099                    long size = 0;
1100                    try {
1101                        size =  mAttachmentsView.addAttachment(mAccount, uri);
1102                    } catch (AttachmentFailureException e) {
1103                        // A toast has already been shown to the user,
1104                        // just break out of the loop.
1105                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1106                    }
1107                    totalSize += size;
1108                }
1109            }
1110            if (Intent.ACTION_SEND.equals(action) && extras.containsKey(Intent.EXTRA_STREAM)) {
1111                final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM);
1112                long size = 0;
1113                try {
1114                    size =  mAttachmentsView.addAttachment(mAccount, uri);
1115                } catch (AttachmentFailureException e) {
1116                    // A toast has already been shown to the user, so just
1117                    // exit.
1118                    LogUtils.e(LOG_TAG, e, "Error adding attachment");
1119                }
1120                totalSize += size;
1121            }
1122
1123            if (Intent.ACTION_SEND_MULTIPLE.equals(action)
1124                    && extras.containsKey(Intent.EXTRA_STREAM)) {
1125                ArrayList<Parcelable> uris = extras.getParcelableArrayList(Intent.EXTRA_STREAM);
1126                for (Parcelable uri : uris) {
1127                    long size = 0;
1128                    try {
1129                        size = mAttachmentsView.addAttachment(mAccount, (Uri) uri);
1130                    } catch (AttachmentFailureException e) {
1131                        // A toast has already been shown to the user,
1132                        // just break out of the loop.
1133                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1134                    }
1135                    totalSize += size;
1136                }
1137            }
1138
1139            if (totalSize > 0) {
1140                mAttachmentsChanged = true;
1141                updateSaveUi();
1142            }
1143        }
1144    }
1145
1146
1147    private void initQuotedTextFromRefMessage(Message refMessage, int action) {
1148        if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) {
1149            mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD);
1150        }
1151    }
1152
1153    private void updateHideOrShowCcBcc() {
1154        // Its possible there is a menu item OR a button.
1155        boolean ccVisible = mCcBccView.isCcVisible();
1156        boolean bccVisible = mCcBccView.isBccVisible();
1157        if (mCcBccButton != null) {
1158            if (!ccVisible || !bccVisible) {
1159                mCcBccButton.setVisibility(View.VISIBLE);
1160                mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label
1161                        : R.string.add_bcc_label));
1162            } else {
1163                mCcBccButton.setVisibility(View.GONE);
1164            }
1165        }
1166    }
1167
1168    private void showCcBcc(Bundle state) {
1169        if (state != null && state.containsKey(EXTRA_SHOW_CC)) {
1170            boolean showCc = state.getBoolean(EXTRA_SHOW_CC);
1171            boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC);
1172            if (showCc || showBcc) {
1173                mCcBccView.show(false, showCc, showBcc);
1174            }
1175        }
1176    }
1177
1178    /**
1179     * Add attachment and update the compose area appropriately.
1180     * @param data
1181     */
1182    public void addAttachmentAndUpdateView(Intent data) {
1183        addAttachmentAndUpdateView(data != null ? data.getData() : (Uri) null);
1184    }
1185
1186    public void addAttachmentAndUpdateView(Uri contentUri) {
1187        if (contentUri == null) {
1188            return;
1189        }
1190        try {
1191            addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri));
1192        } catch (AttachmentFailureException e) {
1193            // A toast has already been shown to the user, no need to do
1194            // anything.
1195            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1196        }
1197    }
1198
1199    public void addAttachmentAndUpdateView(Attachment attachment) {
1200        try {
1201            long size =  mAttachmentsView.addAttachment(mAccount, attachment);
1202            if (size > 0) {
1203                mAttachmentsChanged = true;
1204                updateSaveUi();
1205            }
1206        } catch (AttachmentFailureException e) {
1207            // A toast has already been shown to the user, no need to do
1208            // anything.
1209            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1210        }
1211    }
1212
1213    void initRecipientsFromRefMessage(String recipientAddress, Message refMessage,
1214            int action) {
1215        // Don't populate the address if this is a forward.
1216        if (action == ComposeActivity.FORWARD) {
1217            return;
1218        }
1219        initReplyRecipients(mAccount.name, refMessage, action);
1220    }
1221
1222    @VisibleForTesting
1223    void initReplyRecipients(String account, Message refMessage, int action) {
1224        // This is the email address of the current user, i.e. the one composing
1225        // the reply.
1226        final String accountEmail = Address.getEmailAddress(account).getAddress();
1227        String fromAddress = getAddress(refMessage.from);
1228        String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage.to);
1229        String replytoAddress = refMessage.replyTo;
1230        final Collection<String> toAddresses;
1231
1232        // If this is a reply, the Cc list is empty. If this is a reply-all, the
1233        // Cc list is the union of the To and Cc recipients of the original
1234        // message, excluding the current user's email address and any addresses
1235        // already on the To list.
1236        if (action == ComposeActivity.REPLY) {
1237            toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress,
1238                    sentToAddresses);
1239            addToAddresses(toAddresses);
1240        } else if (action == ComposeActivity.REPLY_ALL) {
1241            final Set<String> ccAddresses = Sets.newHashSet();
1242            toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress,
1243                    new String[0]);
1244            addToAddresses(toAddresses);
1245            addRecipients(accountEmail, ccAddresses, sentToAddresses);
1246            addRecipients(accountEmail, ccAddresses,
1247                    Utils.splitCommaSeparatedString(refMessage.cc));
1248            addCcAddresses(ccAddresses, toAddresses);
1249        }
1250    }
1251
1252    private String getAddress(String toParse) {
1253        if (!TextUtils.isEmpty(toParse)) {
1254            Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(toParse);
1255            if (tokens.length > 0) {
1256                return tokens[0].getAddress();
1257            }
1258        }
1259        return "";
1260    }
1261
1262    private void addToAddresses(Collection<String> addresses) {
1263        addAddressesToList(addresses, mTo);
1264    }
1265
1266    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
1267        addCcAddressesToList(tokenizeAddressList(addresses),
1268                toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc);
1269    }
1270
1271    private void addBccAddresses(Collection<String> addresses) {
1272        addAddressesToList(addresses, mBcc);
1273    }
1274
1275    @VisibleForTesting
1276    protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
1277            List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
1278        String address;
1279
1280        if (compareToList == null) {
1281            for (Rfc822Token[] tokens : addresses) {
1282                for (int i = 0; i < tokens.length; i++) {
1283                    address = tokens[i].toString();
1284                    list.append(address + END_TOKEN);
1285                }
1286            }
1287        } else {
1288            HashSet<String> compareTo = convertToHashSet(compareToList);
1289            for (Rfc822Token[] tokens : addresses) {
1290                for (int i = 0; i < tokens.length; i++) {
1291                    address = tokens[i].toString();
1292                    // Check if this is a duplicate:
1293                    if (!compareTo.contains(tokens[i].getAddress())) {
1294                        // Get the address here
1295                        list.append(address + END_TOKEN);
1296                    }
1297                }
1298            }
1299        }
1300    }
1301
1302    private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) {
1303        HashSet<String> hash = new HashSet<String>();
1304        for (Rfc822Token[] tokens : list) {
1305            for (int i = 0; i < tokens.length; i++) {
1306                hash.add(tokens[i].getAddress());
1307            }
1308        }
1309        return hash;
1310    }
1311
1312    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
1313        @VisibleForTesting
1314        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
1315
1316        for (String address: addresses) {
1317            tokenized.add(Rfc822Tokenizer.tokenize(address));
1318        }
1319        return tokenized;
1320    }
1321
1322    @VisibleForTesting
1323    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
1324        for (String address : addresses) {
1325            addAddressToList(address, list);
1326        }
1327    }
1328
1329    private void addAddressToList(String address, RecipientEditTextView list) {
1330        if (address == null || list == null)
1331            return;
1332
1333        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
1334
1335        for (int i = 0; i < tokens.length; i++) {
1336            list.append(tokens[i] + END_TOKEN);
1337        }
1338    }
1339
1340    @VisibleForTesting
1341    protected Collection<String> initToRecipients(String account, String accountEmail,
1342            String senderAddress, String replyToAddress, String[] inToAddresses) {
1343        // The To recipient is the reply-to address specified in the original
1344        // message, unless it is:
1345        // the current user OR a custom from of the current user, in which case
1346        // it's the To recipient list of the original message.
1347        // OR missing, in which case use the sender of the original message
1348        Set<String> toAddresses = Sets.newHashSet();
1349        if (!TextUtils.isEmpty(replyToAddress)) {
1350            toAddresses.add(replyToAddress);
1351        } else {
1352            if (!TextUtils.equals(senderAddress, accountEmail)
1353                    && !ReplyFromAccount.isCustomFrom(senderAddress,
1354                            mFromSpinner.getReplyFromAccounts())) {
1355                toAddresses.add(senderAddress);
1356            } else {
1357                // This happens if the user replies to a message they originally
1358                // wrote. In this case, "reply" really means "re-send," so we
1359                // target the original recipients. This works as expected even
1360                // if the user sent the original message to themselves.
1361                toAddresses.addAll(Arrays.asList(inToAddresses));
1362            }
1363        }
1364        return toAddresses;
1365    }
1366
1367    private static void addRecipients(String account, Set<String> recipients, String[] addresses) {
1368        for (String email : addresses) {
1369            // Do not add this account, or any of the custom froms, to the list
1370            // of recipients.
1371            final String recipientAddress = Address.getEmailAddress(email).getAddress();
1372            if (!account.equalsIgnoreCase(recipientAddress)) {
1373                recipients.add(email.replace("\"\"", ""));
1374            }
1375        }
1376    }
1377
1378    private void setSubject(Message refMessage, int action) {
1379        String subject = refMessage.subject;
1380        String prefix;
1381        String correctedSubject = null;
1382        if (action == ComposeActivity.COMPOSE) {
1383            prefix = "";
1384        } else if (action == ComposeActivity.FORWARD) {
1385            prefix = getString(R.string.forward_subject_label);
1386        } else {
1387            prefix = getString(R.string.reply_subject_label);
1388        }
1389
1390        // Don't duplicate the prefix
1391        if (subject.toLowerCase().startsWith(prefix.toLowerCase())) {
1392            correctedSubject = subject;
1393        } else {
1394            correctedSubject = String
1395                    .format(getString(R.string.formatted_subject), prefix, subject);
1396        }
1397        mSubject.setText(correctedSubject);
1398    }
1399
1400    private void initRecipients() {
1401        setupRecipients(mTo);
1402        setupRecipients(mCc);
1403        setupRecipients(mBcc);
1404    }
1405
1406    private void setupRecipients(RecipientEditTextView view) {
1407        view.setAdapter(new RecipientAdapter(this, mAccount));
1408        view.setTokenizer(new Rfc822Tokenizer());
1409        if (mValidator == null) {
1410            final String accountName = mAccount.name;
1411            int offset = accountName.indexOf("@") + 1;
1412            String account = accountName;
1413            if (offset > -1) {
1414                account = account.substring(accountName.indexOf("@") + 1);
1415            }
1416            mValidator = new Rfc822Validator(account);
1417        }
1418        view.setValidator(mValidator);
1419    }
1420
1421    @Override
1422    public void onClick(View v) {
1423        int id = v.getId();
1424        switch (id) {
1425            case R.id.add_cc_bcc:
1426                // Verify that cc/ bcc aren't showing.
1427                // Animate in cc/bcc.
1428                showCcBccViews();
1429                break;
1430            case R.id.add_attachment:
1431                openAttachmentTypeSelectionDialog();
1432                break;
1433        }
1434    }
1435
1436    @Override
1437    public boolean onCreateOptionsMenu(Menu menu) {
1438        super.onCreateOptionsMenu(menu);
1439        MenuInflater inflater = getMenuInflater();
1440        inflater.inflate(R.menu.compose_menu, menu);
1441        mSave = menu.findItem(R.id.save);
1442        mSend = menu.findItem(R.id.send);
1443        MenuItem helpItem = menu.findItem(R.id.help_info_menu_item);
1444        MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item);
1445        if (helpItem != null) {
1446            helpItem.setVisible(mAccount != null
1447                    && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT));
1448        }
1449        if (sendFeedbackItem != null) {
1450            sendFeedbackItem.setVisible(mAccount != null
1451                    && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK));
1452        }
1453        return true;
1454    }
1455
1456    @Override
1457    public boolean onPrepareOptionsMenu(Menu menu) {
1458        MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
1459        if (ccBcc != null && mCc != null) {
1460            // Its possible there is a menu item OR a button.
1461            boolean ccFieldVisible = mCc.isShown();
1462            boolean bccFieldVisible = mBcc.isShown();
1463            if (!ccFieldVisible || !bccFieldVisible) {
1464                ccBcc.setVisible(true);
1465                ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
1466                        : R.string.add_bcc_label));
1467            } else {
1468                ccBcc.setVisible(false);
1469            }
1470        }
1471        if (mSave != null) {
1472            mSave.setEnabled(shouldSave());
1473        }
1474        return true;
1475    }
1476
1477    @Override
1478    public boolean onOptionsItemSelected(MenuItem item) {
1479        int id = item.getItemId();
1480        boolean handled = true;
1481        switch (id) {
1482            case R.id.add_attachment:
1483                openAttachmentTypeSelectionDialog();
1484                break;
1485            case R.id.add_cc_bcc:
1486                showCcBccViews();
1487                break;
1488            case R.id.save:
1489                doSave(true);
1490                break;
1491            case R.id.send:
1492                doSend();
1493                break;
1494            case R.id.discard:
1495                doDiscard();
1496                break;
1497            case R.id.settings:
1498                Utils.showSettings(this, mAccount);
1499                break;
1500            case android.R.id.home:
1501                onAppUpPressed();
1502                break;
1503            case R.id.help_info_menu_item:
1504                // TODO: enable context sensitive help
1505                Utils.showHelp(this, mAccount, null);
1506                break;
1507            case R.id.feedback_menu_item:
1508                Utils.sendFeedback(this, mAccount);
1509                break;
1510            default:
1511                handled = false;
1512                break;
1513        }
1514        return !handled ? super.onOptionsItemSelected(item) : handled;
1515    }
1516
1517    private void onAppUpPressed() {
1518        if (mLaunchedFromEmail) {
1519            // If this was started from Gmail, simply treat app up as the system back button, so
1520            // that the last view is restored.
1521            onBackPressed();
1522            return;
1523        }
1524
1525        // Fire the main activity to ensure it launches the "top" screen of mail.
1526        // Since the main Activity is singleTask, it should revive that task if it was already
1527        // started.
1528        Folder defaultInbox = new Folder();
1529        defaultInbox.uri = mAccount.settings.defaultInbox;
1530        final Intent mailIntent =
1531                Utils.createViewFolderIntent(defaultInbox, mAccount, false);
1532
1533        mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
1534                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
1535        startActivity(mailIntent);
1536        finish();
1537    }
1538
1539    private void doSend() {
1540        sendOrSaveWithSanityChecks(false, true, false);
1541    }
1542
1543    private void doSave(boolean showToast) {
1544        // Clear the IME composing suggestions from the body and subject before saving.
1545        clearImeText(mBodyView);
1546        clearImeText(mSubject);
1547        sendOrSaveWithSanityChecks(true, showToast, false);
1548    }
1549
1550    private void clearImeText(TextView v) {
1551        v.clearComposingText();
1552        BaseInputConnection.removeComposingSpans(v.getEditableText());
1553    }
1554
1555    @VisibleForTesting
1556    public interface SendOrSaveCallback {
1557        public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask);
1558        public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
1559        public Message getMessage();
1560        public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success);
1561    }
1562
1563    @VisibleForTesting
1564    public static class SendOrSaveTask implements Runnable {
1565        private final Context mContext;
1566        @VisibleForTesting
1567        public final SendOrSaveCallback mSendOrSaveCallback;
1568        @VisibleForTesting
1569        public final SendOrSaveMessage mSendOrSaveMessage;
1570
1571        public SendOrSaveTask(Context context, SendOrSaveMessage message,
1572                SendOrSaveCallback callback) {
1573            mContext = context;
1574            mSendOrSaveCallback = callback;
1575            mSendOrSaveMessage = message;
1576        }
1577
1578        @Override
1579        public void run() {
1580            final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage;
1581
1582            final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount;
1583            Message message = mSendOrSaveCallback.getMessage();
1584            long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID;
1585            // If a previous draft has been saved, in an account that is different
1586            // than what the user wants to send from, remove the old draft, and treat this
1587            // as a new message
1588            if (!selectedAccount.equals(sendOrSaveMessage.mAccount)) {
1589                if (messageId != UIProvider.INVALID_MESSAGE_ID) {
1590                    ContentResolver resolver = mContext.getContentResolver();
1591                    ContentValues values = new ContentValues();
1592                    values.put(BaseColumns._ID, messageId);
1593                    if (selectedAccount.account.expungeMessageUri != null) {
1594                        resolver.update(selectedAccount.account.expungeMessageUri, values, null,
1595                                null);
1596                    } else {
1597                        // TODO(mindyp) delete the conversation.
1598                    }
1599                    // reset messageId to 0, so a new message will be created
1600                    messageId = UIProvider.INVALID_MESSAGE_ID;
1601                }
1602            }
1603
1604            final long messageIdToSave = messageId;
1605            if (messageIdToSave != UIProvider.INVALID_MESSAGE_ID) {
1606                sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
1607                mContext.getContentResolver().update(
1608                        Uri.parse(sendOrSaveMessage.mSave ? message.saveUri : message.sendUri),
1609                        sendOrSaveMessage.mValues, null, null);
1610            } else {
1611                ContentResolver resolver = mContext.getContentResolver();
1612                Uri messageUri = resolver
1613                        .insert(sendOrSaveMessage.mSave ? selectedAccount.account.saveDraftUri
1614                                : selectedAccount.account.sendMessageUri,
1615                                sendOrSaveMessage.mValues);
1616                if (sendOrSaveMessage.mSave && messageUri != null) {
1617                    Cursor messageCursor = resolver.query(messageUri,
1618                            UIProvider.MESSAGE_PROJECTION, null, null, null);
1619                    if (messageCursor != null) {
1620                        try {
1621                            if (messageCursor.moveToFirst()) {
1622                                // Broadcast notification that a new message has
1623                                // been allocated
1624                                mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
1625                                        new Message(messageCursor));
1626                            }
1627                        } finally {
1628                            messageCursor.close();
1629                        }
1630                    }
1631                }
1632            }
1633
1634            if (!sendOrSaveMessage.mSave) {
1635                UIProvider.incrementRecipientsTimesContacted(mContext,
1636                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO));
1637                UIProvider.incrementRecipientsTimesContacted(mContext,
1638                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC));
1639                UIProvider.incrementRecipientsTimesContacted(mContext,
1640                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
1641            }
1642            mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
1643        }
1644    }
1645
1646    // Array of the outstanding send or save tasks.  Access is synchronized
1647    // with the object itself
1648    /* package for testing */
1649    @VisibleForTesting
1650    public ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList();
1651    private int mRequestId;
1652    private String mSignature;
1653    private AttachmentTypeSelectorAdapter mAttachmentTypeSelectorAdapter;
1654
1655    @VisibleForTesting
1656    public static class SendOrSaveMessage {
1657        final ReplyFromAccount mAccount;
1658        final ContentValues mValues;
1659        final String mRefMessageId;
1660        @VisibleForTesting
1661        public final boolean mSave;
1662        final int mRequestId;
1663
1664        public SendOrSaveMessage(ReplyFromAccount account, ContentValues values,
1665                String refMessageId, boolean save) {
1666            mAccount = account;
1667            mValues = values;
1668            mRefMessageId = refMessageId;
1669            mSave = save;
1670            mRequestId = mValues.hashCode() ^ hashCode();
1671        }
1672
1673        int requestId() {
1674            return mRequestId;
1675        }
1676    }
1677
1678    /**
1679     * Get the to recipients.
1680     */
1681    public String[] getToAddresses() {
1682        return getAddressesFromList(mTo);
1683    }
1684
1685    /**
1686     * Get the cc recipients.
1687     */
1688    public String[] getCcAddresses() {
1689        return getAddressesFromList(mCc);
1690    }
1691
1692    /**
1693     * Get the bcc recipients.
1694     */
1695    public String[] getBccAddresses() {
1696        return getAddressesFromList(mBcc);
1697    }
1698
1699    public String[] getAddressesFromList(RecipientEditTextView list) {
1700        if (list == null) {
1701            return new String[0];
1702        }
1703        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
1704        int count = tokens.length;
1705        String[] result = new String[count];
1706        for (int i = 0; i < count; i++) {
1707            result[i] = tokens[i].toString();
1708        }
1709        return result;
1710    }
1711
1712    /**
1713     * Check for invalid email addresses.
1714     * @param to String array of email addresses to check.
1715     * @param wrongEmailsOut Emails addresses that were invalid.
1716     */
1717    public void checkInvalidEmails(String[] to, List<String> wrongEmailsOut) {
1718        if (mValidator == null) {
1719            return;
1720        }
1721        for (String email : to) {
1722            if (!mValidator.isValid(email)) {
1723                wrongEmailsOut.add(email);
1724            }
1725        }
1726    }
1727
1728    /**
1729     * Show an error because the user has entered an invalid recipient.
1730     * @param message
1731     */
1732    public void showRecipientErrorDialog(String message) {
1733        // Only 1 invalid recipients error dialog should be allowed up at a
1734        // time.
1735        if (mRecipientErrorDialog != null) {
1736            mRecipientErrorDialog.dismiss();
1737        }
1738        mRecipientErrorDialog = new AlertDialog.Builder(this).setMessage(message).setTitle(
1739                R.string.recipient_error_dialog_title)
1740                .setIconAttribute(android.R.attr.alertDialogIcon)
1741                .setPositiveButton(
1742                        R.string.ok, new Dialog.OnClickListener() {
1743                            @Override
1744                            public void onClick(DialogInterface dialog, int which) {
1745                                // after the user dismisses the recipient error
1746                                // dialog we want to make sure to refocus the
1747                                // recipient to field so they can fix the issue
1748                                // easily
1749                                if (mTo != null) {
1750                                    mTo.requestFocus();
1751                                }
1752                                mRecipientErrorDialog = null;
1753                            }
1754                        }).show();
1755    }
1756
1757    /**
1758     * Update the state of the UI based on whether or not the current draft
1759     * needs to be saved and the message is not empty.
1760     */
1761    public void updateSaveUi() {
1762        if (mSave != null) {
1763            mSave.setEnabled((shouldSave() && !isBlank()));
1764        }
1765    }
1766
1767    /**
1768     * Returns true if we need to save the current draft.
1769     */
1770    private boolean shouldSave() {
1771        synchronized (mDraftLock) {
1772            // The message should only be saved if:
1773            // It hasn't been sent AND
1774            // Some text has been added to the message OR
1775            // an attachment has been added or removed
1776            // AND there is actually something in the draft to save.
1777            return (mTextChanged || mAttachmentsChanged || mReplyFromChanged)
1778                    && !isBlank();
1779        }
1780    }
1781
1782    /**
1783     * Check if all fields are blank.
1784     * @return boolean
1785     */
1786    public boolean isBlank() {
1787        return mSubject.getText().length() == 0
1788                && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
1789                        mBodyView.getText().toString()) == 0)
1790                && mTo.length() == 0
1791                && mCc.length() == 0 && mBcc.length() == 0
1792                && mAttachmentsView.getAttachments().size() == 0;
1793    }
1794
1795    @VisibleForTesting
1796    protected int getSignatureStartPosition(String signature, String bodyText) {
1797        int startPos = -1;
1798
1799        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
1800            return startPos;
1801        }
1802
1803        int bodyLength = bodyText.length();
1804        int signatureLength = signature.length();
1805        String printableVersion = convertToPrintableSignature(signature);
1806        int printableLength = printableVersion.length();
1807
1808        if (bodyLength >= printableLength
1809                && bodyText.substring(bodyLength - printableLength)
1810                .equals(printableVersion)) {
1811            startPos = bodyLength - printableLength;
1812        } else if (bodyLength >= signatureLength
1813                && bodyText.substring(bodyLength - signatureLength)
1814                .equals(signature)) {
1815            startPos = bodyLength - signatureLength;
1816        }
1817        return startPos;
1818    }
1819
1820    /**
1821     * Allows any changes made by the user to be ignored. Called when the user
1822     * decides to discard a draft.
1823     */
1824    private void discardChanges() {
1825        mTextChanged = false;
1826        mAttachmentsChanged = false;
1827        mReplyFromChanged = false;
1828    }
1829
1830    /**
1831     * @param body
1832     * @param save
1833     * @param showToast
1834     * @return Whether the send or save succeeded.
1835     */
1836    protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
1837            final boolean orientationChanged) {
1838        String[] to, cc, bcc;
1839        Editable body = mBodyView.getEditableText();
1840        if (orientationChanged) {
1841            to = cc = bcc = new String[0];
1842        } else {
1843            to = getToAddresses();
1844            cc = getCcAddresses();
1845            bcc = getBccAddresses();
1846        }
1847
1848        // Don't let the user send to nobody (but it's okay to save a message
1849        // with no recipients)
1850        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
1851            showRecipientErrorDialog(getString(R.string.recipient_needed));
1852            return false;
1853        }
1854
1855        List<String> wrongEmails = new ArrayList<String>();
1856        if (!save) {
1857            checkInvalidEmails(to, wrongEmails);
1858            checkInvalidEmails(cc, wrongEmails);
1859            checkInvalidEmails(bcc, wrongEmails);
1860        }
1861
1862        // Don't let the user send an email with invalid recipients
1863        if (wrongEmails.size() > 0) {
1864            String errorText = String.format(getString(R.string.invalid_recipient),
1865                    wrongEmails.get(0));
1866            showRecipientErrorDialog(errorText);
1867            return false;
1868        }
1869
1870        DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() {
1871            @Override
1872            public void onClick(DialogInterface dialog, int which) {
1873                sendOrSave(mBodyView.getEditableText(), save, showToast, orientationChanged);
1874            }
1875        };
1876
1877        // Show a warning before sending only if there are no attachments.
1878        if (!save) {
1879            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
1880                boolean warnAboutEmptySubject = isSubjectEmpty();
1881                boolean emptyBody = TextUtils.getTrimmedLength(body) == 0;
1882
1883                // A warning about an empty body may not be warranted when
1884                // forwarding mails, since a common use case is to forward
1885                // quoted text and not append any more text.
1886                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
1887
1888                // When we bring up a dialog warning the user about a send,
1889                // assume that they accept sending the message. If they do not,
1890                // the dialog listener is required to enable sending again.
1891                if (warnAboutEmptySubject) {
1892                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, listener);
1893                    return true;
1894                }
1895
1896                if (warnAboutEmptyBody) {
1897                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body, listener);
1898                    return true;
1899                }
1900            }
1901            // Ask for confirmation to send (if always required)
1902            if (showSendConfirmation()) {
1903                showSendConfirmDialog(R.string.confirm_send_message, listener);
1904                return true;
1905            }
1906        }
1907
1908        sendOrSave(body, save, showToast, false);
1909        return true;
1910    }
1911
1912    /**
1913     * Returns a boolean indicating whether warnings should be shown for empty
1914     * subject and body fields
1915     *
1916     * @return True if a warning should be shown for empty text fields
1917     */
1918    protected boolean showEmptyTextWarnings() {
1919        return mAttachmentsView.getAttachments().size() == 0;
1920    }
1921
1922    /**
1923     * Returns a boolean indicating whether the user should confirm each send
1924     *
1925     * @return True if a warning should be on each send
1926     */
1927    protected boolean showSendConfirmation() {
1928        return mCachedSettings != null ? mCachedSettings.confirmSend : false;
1929    }
1930
1931    private void showSendConfirmDialog(int messageId, DialogInterface.OnClickListener listener) {
1932        if (mSendConfirmDialog != null) {
1933            mSendConfirmDialog.dismiss();
1934            mSendConfirmDialog = null;
1935        }
1936        mSendConfirmDialog = new AlertDialog.Builder(this).setMessage(messageId)
1937                .setTitle(R.string.confirm_send_title)
1938                .setIconAttribute(android.R.attr.alertDialogIcon)
1939                .setPositiveButton(R.string.send, listener)
1940                .setNegativeButton(R.string.cancel, this)
1941                .show();
1942    }
1943
1944    /**
1945     * Returns whether the ComposeArea believes there is any text in the body of
1946     * the composition. TODO: When ComposeArea controls the Body as well, add
1947     * that here.
1948     */
1949    public boolean isBodyEmpty() {
1950        return !mQuotedTextView.isTextIncluded();
1951    }
1952
1953    /**
1954     * Test to see if the subject is empty.
1955     *
1956     * @return boolean.
1957     */
1958    // TODO: this will likely go away when composeArea.focus() is implemented
1959    // after all the widget control is moved over.
1960    public boolean isSubjectEmpty() {
1961        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
1962    }
1963
1964    /* package */
1965    static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount,
1966            Message message, final Message refMessage, Spanned body, final CharSequence quotedText,
1967            SendOrSaveCallback callback, Handler handler, boolean save, int composeMode) {
1968        ContentValues values = new ContentValues();
1969
1970        String refMessageId = refMessage != null ? refMessage.uri.toString() : "";
1971
1972        MessageModification.putToAddresses(values, message.getToAddresses());
1973        MessageModification.putCcAddresses(values, message.getCcAddresses());
1974        MessageModification.putBccAddresses(values, message.getBccAddresses());
1975
1976        MessageModification.putCustomFromAddress(values, message.from);
1977
1978        MessageModification.putSubject(values, message.subject);
1979        String htmlBody = Html.toHtml(body);
1980
1981        boolean includeQuotedText = !TextUtils.isEmpty(quotedText);
1982        StringBuilder fullBody = new StringBuilder(htmlBody);
1983        if (includeQuotedText) {
1984            // HTML gets converted to text for now
1985            final String text = quotedText.toString();
1986            if (QuotedTextView.containsQuotedText(text)) {
1987                int pos = QuotedTextView.getQuotedTextOffset(text);
1988                final int quoteStartPos = fullBody.length() + pos;
1989                fullBody.append(text);
1990                MessageModification.putQuoteStartPos(values, quoteStartPos);
1991                MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
1992                MessageModification.putAppendRefMessageContent(values, includeQuotedText);
1993            } else {
1994                LogUtils.w(LOG_TAG, "Couldn't find quoted text");
1995                // This shouldn't happen, but just use what we have,
1996                // and don't do server-side expansion
1997                fullBody.append(text);
1998            }
1999        }
2000        int draftType = getDraftType(composeMode);
2001        MessageModification.putDraftType(values, draftType);
2002        if (refMessage != null) {
2003            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
2004                MessageModification.putBodyHtml(values, fullBody.toString());
2005            }
2006            if (!TextUtils.isEmpty(refMessage.bodyText)) {
2007                MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString());
2008            }
2009        } else {
2010            MessageModification.putBodyHtml(values, fullBody.toString());
2011            MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString());
2012        }
2013        MessageModification.putAttachments(values, message.getAttachments());
2014        if (!TextUtils.isEmpty(refMessageId)) {
2015            MessageModification.putRefMessageId(values, refMessageId);
2016        }
2017
2018        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(replyFromAccount,
2019                values, refMessageId, save);
2020        SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback);
2021
2022        callback.initializeSendOrSave(sendOrSaveTask);
2023
2024        // Do the send/save action on the specified handler to avoid possible
2025        // ANRs
2026        handler.post(sendOrSaveTask);
2027
2028        return sendOrSaveMessage.requestId();
2029    }
2030
2031    private static int getDraftType(int mode) {
2032        int draftType = -1;
2033        switch (mode) {
2034            case ComposeActivity.COMPOSE:
2035                draftType = DraftType.COMPOSE;
2036                break;
2037            case ComposeActivity.REPLY:
2038                draftType = DraftType.REPLY;
2039                break;
2040            case ComposeActivity.REPLY_ALL:
2041                draftType = DraftType.REPLY_ALL;
2042                break;
2043            case ComposeActivity.FORWARD:
2044                draftType = DraftType.FORWARD;
2045                break;
2046        }
2047        return draftType;
2048    }
2049
2050    private void sendOrSave(Spanned body, boolean save, boolean showToast,
2051            boolean orientationChanged) {
2052        // Check if user is a monkey. Monkeys can compose and hit send
2053        // button but are not allowed to send anything off the device.
2054        if (ActivityManager.isUserAMonkey()) {
2055            return;
2056        }
2057
2058        String[] to, cc, bcc;
2059        if (orientationChanged) {
2060            to = cc = bcc = new String[0];
2061        } else {
2062            to = getToAddresses();
2063            cc = getCcAddresses();
2064            bcc = getBccAddresses();
2065        }
2066
2067        SendOrSaveCallback callback = new SendOrSaveCallback() {
2068            private int mRestoredRequestId;
2069
2070            @Override
2071            public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
2072                synchronized (mActiveTasks) {
2073                    int numTasks = mActiveTasks.size();
2074                    if (numTasks == 0) {
2075                        // Start service so we won't be killed if this app is
2076                        // put in the background.
2077                        startService(new Intent(ComposeActivity.this, EmptyService.class));
2078                    }
2079
2080                    mActiveTasks.add(sendOrSaveTask);
2081                }
2082                if (sTestSendOrSaveCallback != null) {
2083                    sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
2084                }
2085            }
2086
2087            @Override
2088            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
2089                    Message message) {
2090                synchronized (mDraftLock) {
2091                    mDraftId = message.id;
2092                    mDraft = message;
2093                    if (sRequestMessageIdMap != null) {
2094                        sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
2095                    }
2096                    // Cache request message map, in case the process is killed
2097                    saveRequestMap();
2098                }
2099                if (sTestSendOrSaveCallback != null) {
2100                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
2101                }
2102            }
2103
2104            @Override
2105            public Message getMessage() {
2106                synchronized (mDraftLock) {
2107                    return mDraft;
2108                }
2109            }
2110
2111            @Override
2112            public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
2113                if (success) {
2114                    // Successfully sent or saved so reset change markers
2115                    discardChanges();
2116                } else {
2117                    // A failure happened with saving/sending the draft
2118                    // TODO(pwestbro): add a better string that should be used
2119                    // when failing to send or save
2120                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
2121                            .show();
2122                }
2123
2124                int numTasks;
2125                synchronized (mActiveTasks) {
2126                    // Remove the task from the list of active tasks
2127                    mActiveTasks.remove(task);
2128                    numTasks = mActiveTasks.size();
2129                }
2130
2131                if (numTasks == 0) {
2132                    // Stop service so we can be killed.
2133                    stopService(new Intent(ComposeActivity.this, EmptyService.class));
2134                }
2135                if (sTestSendOrSaveCallback != null) {
2136                    sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
2137                }
2138            }
2139        };
2140
2141        // Get the selected account if the from spinner has been setup.
2142        ReplyFromAccount selectedAccount = mReplyFromAccount;
2143        String fromAddress = selectedAccount.name;
2144        if (selectedAccount == null || fromAddress == null) {
2145            // We don't have either the selected account or from address,
2146            // use mAccount.
2147            selectedAccount = mReplyFromAccount;
2148            fromAddress = mAccount.name;
2149        }
2150
2151        if (mSendSaveTaskHandler == null) {
2152            HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
2153            handlerThread.start();
2154
2155            mSendSaveTaskHandler = new Handler(handlerThread.getLooper());
2156        }
2157
2158        Message msg = createMessage(mReplyFromAccount, getMode());
2159        mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body,
2160                mQuotedTextView.getQuotedTextIfIncluded(), callback,
2161                mSendSaveTaskHandler, save, mComposeMode);
2162
2163        if (mRecipient != null && mRecipient.equals(mAccount.name)) {
2164            mRecipient = selectedAccount.name;
2165        }
2166        setAccount(selectedAccount.account);
2167
2168        // Don't display the toast if the user is just changing the orientation,
2169        // but we still need to save the draft to the cursor because this is how we restore
2170        // the attachments when the configuration change completes.
2171        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
2172            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
2173                    Toast.LENGTH_LONG).show();
2174        }
2175
2176        // Need to update variables here because the send or save completes
2177        // asynchronously even though the toast shows right away.
2178        discardChanges();
2179        updateSaveUi();
2180
2181        // If we are sending, finish the activity
2182        if (!save) {
2183            finish();
2184        }
2185    }
2186
2187    /**
2188     * Save the state of the request messageid map. This allows for the Gmail
2189     * process to be killed, but and still allow for ComposeActivity instances
2190     * to be recreated correctly.
2191     */
2192    private void saveRequestMap() {
2193        // TODO: store the request map in user preferences.
2194    }
2195
2196    public void openAttachmentTypeSelectionDialog() {
2197        AlertDialog.Builder builder = new AlertDialog.Builder(this);
2198        builder.setTitle(R.string.add_file_attachment);
2199        builder.setAdapter(new AttachmentTypeSelectorAdapter(this),
2200                new DialogInterface.OnClickListener() {
2201            public void onClick(DialogInterface dialog, int position) {
2202                doAttach(position);
2203            }
2204        });
2205        builder.show();
2206    }
2207
2208    private void doAttach(int position) {
2209        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
2210        i.addCategory(Intent.CATEGORY_OPENABLE);
2211        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
2212        i.setType(AttachmentTypeSelectorAdapter.ITEMS.get(position).mMimeType);
2213        mAddingAttachment = true;
2214        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
2215                RESULT_PICK_ATTACHMENT);
2216    }
2217
2218    private void showCcBccViews() {
2219        mCcBccView.show(true, true, true);
2220        if (mCcBccButton != null) {
2221            mCcBccButton.setVisibility(View.GONE);
2222        }
2223    }
2224
2225    @Override
2226    public boolean onNavigationItemSelected(int position, long itemId) {
2227        int initialComposeMode = mComposeMode;
2228        if (position == ComposeActivity.REPLY) {
2229            mComposeMode = ComposeActivity.REPLY;
2230        } else if (position == ComposeActivity.REPLY_ALL) {
2231            mComposeMode = ComposeActivity.REPLY_ALL;
2232        } else if (position == ComposeActivity.FORWARD) {
2233            mComposeMode = ComposeActivity.FORWARD;
2234        }
2235        clearChangeListeners();
2236        if (initialComposeMode != mComposeMode) {
2237            resetMessageForModeChange();
2238            if (mDraft == null && mRefMessage != null) {
2239                initFromRefMessage(mComposeMode, mAccount.name);
2240            }
2241            boolean showCc = false;
2242            boolean showBcc = false;
2243            if (mDraft != null) {
2244                // Following desktop behavior, if the user has added a BCC
2245                // field to a draft, we show it regardless of compose mode.
2246                showBcc = !TextUtils.isEmpty(mDraft.bcc);
2247                // Use the draft to determine what to populate.
2248                // If the Bcc field is showing, show the Cc field whether it is populated or not.
2249                showCc = showBcc || (!TextUtils.isEmpty(mDraft.cc) && mComposeMode == REPLY_ALL);
2250            } else if (mRefMessage != null) {
2251                showCc = mComposeMode == REPLY_ALL && !TextUtils.isEmpty(mRefMessage.cc);
2252            }
2253            mCcBccView.show(false, showCc, showBcc);
2254        }
2255        updateHideOrShowCcBcc();
2256        initChangeListeners();
2257        return true;
2258    }
2259
2260    @VisibleForTesting
2261    protected void resetMessageForModeChange() {
2262        // When switching between reply, reply all, forward,
2263        // follow the behavior of webview.
2264        // The contents of the following fields are cleared
2265        // so that they can be populated directly from the
2266        // ref message:
2267        // 1) Any recipient fields
2268        // 2) The subject
2269        mTo.setText("");
2270        mCc.setText("");
2271        mBcc.setText("");
2272        // Any edits to the subject are replaced with the original subject.
2273        mSubject.setText("");
2274
2275        // Any changes to the contents of the following fields are kept:
2276        // 1) Body
2277        // 2) Attachments
2278        // If the user made changes to attachments, keep their changes.
2279        if (!mAttachmentsChanged) {
2280            mAttachmentsView.deleteAllAttachments();
2281        }
2282    }
2283
2284    private class ComposeModeAdapter extends ArrayAdapter<String> {
2285
2286        private LayoutInflater mInflater;
2287
2288        public ComposeModeAdapter(Context context) {
2289            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
2290                    .getStringArray(R.array.compose_modes));
2291        }
2292
2293        private LayoutInflater getInflater() {
2294            if (mInflater == null) {
2295                mInflater = LayoutInflater.from(getContext());
2296            }
2297            return mInflater;
2298        }
2299
2300        @Override
2301        public View getView(int position, View convertView, ViewGroup parent) {
2302            if (convertView == null) {
2303                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
2304            }
2305            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
2306            return super.getView(position, convertView, parent);
2307        }
2308    }
2309
2310    @Override
2311    public void onRespondInline(String text) {
2312        appendToBody(text, false);
2313    }
2314
2315    /**
2316     * Append text to the body of the message. If there is no existing body
2317     * text, just sets the body to text.
2318     *
2319     * @param text
2320     * @param withSignature True to append a signature.
2321     */
2322    public void appendToBody(CharSequence text, boolean withSignature) {
2323        Editable bodyText = mBodyView.getEditableText();
2324        if (bodyText != null && bodyText.length() > 0) {
2325            bodyText.append(text);
2326        } else {
2327            setBody(text, withSignature);
2328        }
2329    }
2330
2331    /**
2332     * Set the body of the message.
2333     *
2334     * @param text
2335     * @param withSignature True to append a signature.
2336     */
2337    public void setBody(CharSequence text, boolean withSignature) {
2338        mBodyView.setText(text);
2339        if (withSignature) {
2340            appendSignature();
2341        }
2342    }
2343
2344    private void appendSignature() {
2345        String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
2346        boolean hasFocus = mBodyView.hasFocus();
2347        if (!TextUtils.equals(newSignature, mSignature)) {
2348            mSignature = newSignature;
2349            if (!TextUtils.isEmpty(mSignature)
2350                    && getSignatureStartPosition(mSignature,
2351                            mBodyView.getText().toString()) < 0) {
2352                // Appending a signature does not count as changing text.
2353                mBodyView.removeTextChangedListener(this);
2354                mBodyView.append(convertToPrintableSignature(mSignature));
2355                mBodyView.addTextChangedListener(this);
2356            }
2357            if (hasFocus) {
2358                focusBody();
2359            }
2360        }
2361    }
2362
2363    private String convertToPrintableSignature(String signature) {
2364        String signatureResource = getResources().getString(R.string.signature);
2365        if (signature == null) {
2366            signature = "";
2367        }
2368        return String.format(signatureResource, signature);
2369    }
2370
2371    @Override
2372    public void onAccountChanged() {
2373        mReplyFromAccount = mFromSpinner.getCurrentAccount();
2374        if (!mAccount.equals(mReplyFromAccount.account)) {
2375            setAccount(mReplyFromAccount.account);
2376
2377            // TODO: handle discarding attachments when switching accounts.
2378            // Only enable save for this draft if there is any other content
2379            // in the message.
2380            if (!isBlank()) {
2381                enableSave(true);
2382            }
2383            mReplyFromChanged = true;
2384            initRecipients();
2385        }
2386    }
2387
2388    public void enableSave(boolean enabled) {
2389        if (mSave != null) {
2390            mSave.setEnabled(enabled);
2391        }
2392    }
2393
2394    public void enableSend(boolean enabled) {
2395        if (mSend != null) {
2396            mSend.setEnabled(enabled);
2397        }
2398    }
2399
2400    /**
2401     * Handles button clicks from any error dialogs dealing with sending
2402     * a message.
2403     */
2404    @Override
2405    public void onClick(DialogInterface dialog, int which) {
2406        switch (which) {
2407            case DialogInterface.BUTTON_POSITIVE: {
2408                doDiscardWithoutConfirmation(true /* show toast */ );
2409                break;
2410            }
2411            case DialogInterface.BUTTON_NEGATIVE: {
2412                // If the user cancels the send, re-enable the send button.
2413                enableSend(true);
2414                break;
2415            }
2416        }
2417
2418    }
2419
2420    private void doDiscard() {
2421        new AlertDialog.Builder(this).setMessage(R.string.confirm_discard_text)
2422                .setPositiveButton(R.string.ok, this)
2423                .setNegativeButton(R.string.cancel, null)
2424                .create().show();
2425    }
2426    /**
2427     * Effectively discard the current message.
2428     *
2429     * This method is either invoked from the menu or from the dialog
2430     * once the user has confirmed that they want to discard the message.
2431     * @param showToast show "Message discarded" toast if true
2432     */
2433    private void doDiscardWithoutConfirmation(boolean showToast) {
2434        synchronized (mDraftLock) {
2435            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
2436                ContentValues values = new ContentValues();
2437                values.put(BaseColumns._ID, mDraftId);
2438                if (mAccount.expungeMessageUri != null) {
2439                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
2440                } else {
2441                    getContentResolver().delete(mDraft.uri, null, null);
2442                }
2443                // This is not strictly necessary (since we should not try to
2444                // save the draft after calling this) but it ensures that if we
2445                // do save again for some reason we make a new draft rather than
2446                // trying to resave an expunged draft.
2447                mDraftId = UIProvider.INVALID_MESSAGE_ID;
2448            }
2449        }
2450
2451        if (showToast) {
2452            // Display a toast to let the user know
2453            Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
2454        }
2455
2456        // This prevents the draft from being saved in onPause().
2457        discardChanges();
2458        finish();
2459    }
2460
2461    private void saveIfNeeded() {
2462        if (mAccount == null) {
2463            // We have not chosen an account yet so there's no way that we can save. This is ok,
2464            // though, since we are saving our state before AccountsActivity is activated. Thus, the
2465            // user has not interacted with us yet and there is no real state to save.
2466            return;
2467        }
2468
2469        if (shouldSave()) {
2470            doSave(!mAddingAttachment /* show toast */);
2471        }
2472    }
2473
2474    @Override
2475    public void onAttachmentDeleted() {
2476        mAttachmentsChanged = true;
2477        updateSaveUi();
2478    }
2479
2480
2481    /**
2482     * This is called any time one of our text fields changes.
2483     */
2484    @Override
2485    public void afterTextChanged(Editable s) {
2486        mTextChanged = true;
2487        updateSaveUi();
2488    }
2489
2490    @Override
2491    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
2492        // Do nothing.
2493    }
2494
2495    @Override
2496    public void onTextChanged(CharSequence s, int start, int before, int count) {
2497        // Do nothing.
2498    }
2499
2500
2501    // There is a big difference between the text associated with an address changing
2502    // to add the display name or to format properly and a recipient being added or deleted.
2503    // Make sure we only notify of changes when a recipient has been added or deleted.
2504    private class RecipientTextWatcher implements TextWatcher {
2505        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
2506
2507        private RecipientEditTextView mView;
2508
2509        private TextWatcher mListener;
2510
2511        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
2512            mView = view;
2513            mListener = listener;
2514        }
2515
2516        @Override
2517        public void afterTextChanged(Editable s) {
2518            if (hasChanged()) {
2519                mListener.afterTextChanged(s);
2520            }
2521        }
2522
2523        private boolean hasChanged() {
2524            String[] currRecips = tokenizeRecips(getAddressesFromList(mView));
2525            int totalCount = currRecips.length;
2526            int totalPrevCount = 0;
2527            for (Entry<String, Integer> entry : mContent.entrySet()) {
2528                totalPrevCount += entry.getValue();
2529            }
2530            if (totalCount != totalPrevCount) {
2531                return true;
2532            }
2533
2534            for (String recip : currRecips) {
2535                if (!mContent.containsKey(recip)) {
2536                    return true;
2537                } else {
2538                    int count = mContent.get(recip) - 1;
2539                    if (count < 0) {
2540                        return true;
2541                    } else {
2542                        mContent.put(recip, count);
2543                    }
2544                }
2545            }
2546            return false;
2547        }
2548
2549        private String[] tokenizeRecips(String[] recips) {
2550            // Tokenize them all and put them in the list.
2551            String[] recipAddresses = new String[recips.length];
2552            for (int i = 0; i < recips.length; i++) {
2553                recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress();
2554            }
2555            return recipAddresses;
2556        }
2557
2558        @Override
2559        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
2560            String[] recips = tokenizeRecips(getAddressesFromList(mView));
2561            for (String recip : recips) {
2562                if (!mContent.containsKey(recip)) {
2563                    mContent.put(recip, 1);
2564                } else {
2565                    mContent.put(recip, (mContent.get(recip)) + 1);
2566                }
2567            }
2568        }
2569
2570        @Override
2571        public void onTextChanged(CharSequence s, int start, int before, int count) {
2572            // Do nothing.
2573        }
2574    }
2575
2576    public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) {
2577        if (sTestSendOrSaveCallback != null && testCallback != null) {
2578            throw new IllegalStateException("Attempting to register more than one test callback");
2579        }
2580        sTestSendOrSaveCallback = testCallback;
2581    }
2582
2583    @VisibleForTesting
2584    protected ArrayList<Attachment> getAttachments() {
2585        return mAttachmentsView.getAttachments();
2586    }
2587}
2588