ComposeActivity.java revision c97ec3e157f1b2a30ebdc96ba9c6007f2f719aa6
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.app.DialogFragment;
26import android.app.Fragment;
27import android.app.FragmentTransaction;
28import android.app.LoaderManager;
29import android.content.ContentResolver;
30import android.content.ContentValues;
31import android.content.Context;
32import android.content.CursorLoader;
33import android.content.DialogInterface;
34import android.content.Intent;
35import android.content.Loader;
36import android.content.pm.ActivityInfo;
37import android.database.Cursor;
38import android.net.Uri;
39import android.os.Bundle;
40import android.os.Handler;
41import android.os.HandlerThread;
42import android.os.ParcelFileDescriptor;
43import android.os.Parcelable;
44import android.provider.BaseColumns;
45import android.text.Editable;
46import android.text.Html;
47import android.text.SpannableString;
48import android.text.Spanned;
49import android.text.TextUtils;
50import android.text.TextWatcher;
51import android.text.util.Rfc822Token;
52import android.text.util.Rfc822Tokenizer;
53import android.view.Gravity;
54import android.view.KeyEvent;
55import android.view.LayoutInflater;
56import android.view.Menu;
57import android.view.MenuInflater;
58import android.view.MenuItem;
59import android.view.View;
60import android.view.View.OnClickListener;
61import android.view.ViewGroup;
62import android.view.inputmethod.BaseInputConnection;
63import android.view.inputmethod.EditorInfo;
64import android.widget.ArrayAdapter;
65import android.widget.Button;
66import android.widget.EditText;
67import android.widget.TextView;
68import android.widget.Toast;
69
70import com.android.common.Rfc822Validator;
71import com.android.ex.chips.RecipientEditTextView;
72import com.android.mail.MailIntentService;
73import com.android.mail.R;
74import com.android.mail.browse.MessageHeaderView;
75import com.android.mail.compose.AttachmentsView.AttachmentAddedOrDeletedListener;
76import com.android.mail.compose.AttachmentsView.AttachmentFailureException;
77import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener;
78import com.android.mail.compose.QuotedTextView.RespondInlineListener;
79import com.android.mail.providers.Account;
80import com.android.mail.providers.Address;
81import com.android.mail.providers.Attachment;
82import com.android.mail.providers.Folder;
83import com.android.mail.providers.MailAppProvider;
84import com.android.mail.providers.Message;
85import com.android.mail.providers.MessageModification;
86import com.android.mail.providers.ReplyFromAccount;
87import com.android.mail.providers.Settings;
88import com.android.mail.providers.UIProvider;
89import com.android.mail.providers.UIProvider.AccountCapabilities;
90import com.android.mail.providers.UIProvider.DraftType;
91import com.android.mail.ui.AttachmentTile.AttachmentPreview;
92import com.android.mail.ui.FeedbackEnabledActivity;
93import com.android.mail.ui.MailActivity;
94import com.android.mail.ui.WaitFragment;
95import com.android.mail.utils.AccountUtils;
96import com.android.mail.utils.AttachmentUtils;
97import com.android.mail.utils.ContentProviderTask;
98import com.android.mail.utils.LogTag;
99import com.android.mail.utils.LogUtils;
100import com.android.mail.utils.Utils;
101import com.google.common.annotations.VisibleForTesting;
102import com.google.common.collect.Lists;
103import com.google.common.collect.Sets;
104
105import java.io.FileNotFoundException;
106import java.io.IOException;
107import java.io.UnsupportedEncodingException;
108import java.net.URLDecoder;
109import java.util.ArrayList;
110import java.util.Arrays;
111import java.util.Collection;
112import java.util.HashMap;
113import java.util.HashSet;
114import java.util.List;
115import java.util.Map.Entry;
116import java.util.Set;
117import java.util.concurrent.ConcurrentHashMap;
118
119public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
120        RespondInlineListener, TextWatcher,
121        AttachmentAddedOrDeletedListener, OnAccountChangedListener,
122        LoaderManager.LoaderCallbacks<Cursor>, TextView.OnEditorActionListener,
123        FeedbackEnabledActivity {
124    // Identifiers for which type of composition this is
125    protected static final int COMPOSE = -1;
126    protected static final int REPLY = 0;
127    protected static final int REPLY_ALL = 1;
128    protected static final int FORWARD = 2;
129    protected static final int EDIT_DRAFT = 3;
130
131    // Integer extra holding one of the above compose action
132    protected static final String EXTRA_ACTION = "action";
133
134    private static final String EXTRA_SHOW_CC = "showCc";
135    private static final String EXTRA_SHOW_BCC = "showBcc";
136    private static final String EXTRA_RESPONDED_INLINE = "respondedInline";
137    private static final String EXTRA_SAVE_ENABLED = "saveEnabled";
138
139    private static final String UTF8_ENCODING_NAME = "UTF-8";
140
141    private static final String MAIL_TO = "mailto";
142
143    private static final String EXTRA_SUBJECT = "subject";
144
145    private static final String EXTRA_BODY = "body";
146
147    protected static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString";
148
149    private static final String EXTRA_ATTACHMENT_PREVIEWS = "attachmentPreviews";
150
151    // Extra that we can get passed from other activities
152    private static final String EXTRA_TO = "to";
153    private static final String EXTRA_CC = "cc";
154    private static final String EXTRA_BCC = "bcc";
155
156    // List of all the fields
157    static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC };
158
159    private static SendOrSaveCallback sTestSendOrSaveCallback = null;
160    // Map containing information about requests to create new messages, and the id of the
161    // messages that were the result of those requests.
162    //
163    // This map is used when the activity that initiated the save a of a new message, is killed
164    // before the save has completed (and when we know the id of the newly created message).  When
165    // a save is completed, the service that is running in the background, will update the map
166    //
167    // When a new ComposeActivity instance is created, it will attempt to use the information in
168    // the previously instantiated map.  If ComposeActivity.onCreate() is called, with a bundle
169    // (restoring data from a previous instance), and the map hasn't been created, we will attempt
170    // to populate the map with data stored in shared preferences.
171    // FIXME: values in this map are never read.
172    private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null;
173    /**
174     * Notifies the {@code Activity} that the caller is an Email
175     * {@code Activity}, so that the back behavior may be modified accordingly.
176     *
177     * @see #onAppUpPressed
178     */
179    public static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
180
181    public static final String EXTRA_ATTACHMENTS = "attachments";
182
183    /** If set, we will clear notifications for this folder. */
184    public static final String EXTRA_NOTIFICATION_FOLDER = "extra-notification-folder";
185
186    //  If this is a reply/forward then this extra will hold the original message
187    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message";
188    // If this is a reply/forward then this extra will hold a uri we must query
189    // to get the original message.
190    protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri";
191    // If this is an action to edit an existing draft message, this extra will hold the
192    // draft message
193    private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message";
194    private static final String END_TOKEN = ", ";
195    private static final String LOG_TAG = LogTag.getLogTag();
196    // Request numbers for activities we start
197    private static final int RESULT_PICK_ATTACHMENT = 1;
198    private static final int RESULT_CREATE_ACCOUNT = 2;
199    // TODO(mindyp) set mime-type for auto send?
200    public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND";
201
202    private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount";
203    private static final String EXTRA_REQUEST_ID = "requestId";
204    private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart";
205    private static final String EXTRA_FOCUS_SELECTION_END = null;
206    private static final String EXTRA_MESSAGE = "extraMessage";
207    private static final int REFERENCE_MESSAGE_LOADER = 0;
208    private static final int LOADER_ACCOUNT_CURSOR = 1;
209    private static final int INIT_DRAFT_USING_REFERENCE_MESSAGE = 2;
210    private static final String EXTRA_SELECTED_ACCOUNT = "selectedAccount";
211    private static final String TAG_WAIT = "wait-fragment";
212    private static final String MIME_TYPE_PHOTO = "image/*";
213    private static final String MIME_TYPE_VIDEO = "video/*";
214
215    private static final String KEY_INNER_SAVED_STATE = "compose_state";
216
217    /**
218     * A single thread for running tasks in the background.
219     */
220    private Handler mSendSaveTaskHandler = null;
221    private RecipientEditTextView mTo;
222    private RecipientEditTextView mCc;
223    private RecipientEditTextView mBcc;
224    private Button mCcBccButton;
225    private CcBccView mCcBccView;
226    private AttachmentsView mAttachmentsView;
227    private Account mAccount;
228    private ReplyFromAccount mReplyFromAccount;
229    private Settings mCachedSettings;
230    private Rfc822Validator mValidator;
231    private TextView mSubject;
232
233    private ComposeModeAdapter mComposeModeAdapter;
234    private int mComposeMode = -1;
235    private boolean mForward;
236    private String mRecipient;
237    private QuotedTextView mQuotedTextView;
238    private EditText mBodyView;
239    private View mFromStatic;
240    private TextView mFromStaticText;
241    private View mFromSpinnerWrapper;
242    @VisibleForTesting
243    protected FromAddressSpinner mFromSpinner;
244    private boolean mAddingAttachment;
245    private boolean mAttachmentsChanged;
246    private boolean mTextChanged;
247    private boolean mReplyFromChanged;
248    private MenuItem mSave;
249    private MenuItem mSend;
250    @VisibleForTesting
251    protected Message mRefMessage;
252    private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
253    private Message mDraft;
254    private ReplyFromAccount mDraftAccount;
255    private Object mDraftLock = new Object();
256    private View mPhotoAttachmentsButton;
257    private View mVideoAttachmentsButton;
258
259    /**
260     * Boolean indicating whether ComposeActivity was launched from a Gmail controlled view.
261     */
262    private boolean mLaunchedFromEmail = false;
263    private RecipientTextWatcher mToListener;
264    private RecipientTextWatcher mCcListener;
265    private RecipientTextWatcher mBccListener;
266    private Uri mRefMessageUri;
267    private boolean mShowQuotedText = false;
268    private Bundle mInnerSavedState;
269
270
271    // Array of the outstanding send or save tasks.  Access is synchronized
272    // with the object itself
273    /* package for testing */
274    @VisibleForTesting
275    public ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList();
276    // FIXME: this variable is never read. related to sRequestMessageIdMap.
277    private int mRequestId;
278    private String mSignature;
279    private Account[] mAccounts;
280    private boolean mRespondedInline;
281
282    /**
283     * Can be called from a non-UI thread.
284     */
285    public static void editDraft(Context launcher, Account account, Message message) {
286        launch(launcher, account, message, EDIT_DRAFT, null, null);
287    }
288
289    /**
290     * Can be called from a non-UI thread.
291     */
292    public static void compose(Context launcher, Account account) {
293        launch(launcher, account, null, COMPOSE, null, null);
294    }
295
296    /**
297     * Can be called from a non-UI thread.
298     */
299    public static Intent createReplyIntent(final Context launcher, final Account account,
300            final Uri messageUri, final boolean isReplyAll) {
301        return createActionIntent(launcher, account, messageUri, isReplyAll ? REPLY_ALL : REPLY);
302    }
303
304    /**
305     * Can be called from a non-UI thread.
306     */
307    public static Intent createForwardIntent(final Context launcher, final Account account,
308            final Uri messageUri) {
309        return createActionIntent(launcher, account, messageUri, FORWARD);
310    }
311
312    private static Intent createActionIntent(final Context launcher, final Account account,
313            final Uri messageUri, final int action) {
314        final Intent intent = new Intent(launcher, ComposeActivity.class);
315
316        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
317        intent.putExtra(EXTRA_ACTION, action);
318        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
319        intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, messageUri);
320
321        return intent;
322    }
323
324    /**
325     * Can be called from a non-UI thread.
326     */
327    public static void reply(Context launcher, Account account, Message message) {
328        launch(launcher, account, message, REPLY, null, null);
329    }
330
331    /**
332     * Can be called from a non-UI thread.
333     */
334    public static void replyAll(Context launcher, Account account, Message message) {
335        launch(launcher, account, message, REPLY_ALL, null, null);
336    }
337
338    /**
339     * Can be called from a non-UI thread.
340     */
341    public static void forward(Context launcher, Account account, Message message) {
342        launch(launcher, account, message, FORWARD, null, null);
343    }
344
345    public static void reportRenderingFeedback(Context launcher, Account account, Message message,
346            String body) {
347        launch(launcher, account, message, FORWARD, "android-gmail-readability@google.com", body);
348    }
349
350    private static void launch(Context launcher, Account account, Message message, int action,
351            String toAddress, String body) {
352        Intent intent = new Intent(launcher, ComposeActivity.class);
353        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
354        intent.putExtra(EXTRA_ACTION, action);
355        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
356        if (action == EDIT_DRAFT) {
357            intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message);
358        } else {
359            intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message);
360        }
361        if (toAddress != null) {
362            intent.putExtra(EXTRA_TO, toAddress);
363        }
364        if (body != null) {
365            intent.putExtra(EXTRA_BODY, body);
366        }
367        launcher.startActivity(intent);
368    }
369
370    @Override
371    public void onCreate(Bundle savedInstanceState) {
372        super.onCreate(savedInstanceState);
373        setContentView(R.layout.compose);
374        mInnerSavedState = (savedInstanceState != null) ?
375                savedInstanceState.getBundle(KEY_INNER_SAVED_STATE) : null;
376        checkValidAccounts();
377    }
378
379    private void finishCreate() {
380        final Bundle savedState = mInnerSavedState;
381        findViews();
382        Intent intent = getIntent();
383        Message message;
384        ArrayList<AttachmentPreview> previews;
385        mShowQuotedText = false;
386        int action;
387        // Check for any of the possibly supplied accounts.;
388        Account account = null;
389        if (hadSavedInstanceStateMessage(savedState)) {
390            action = savedState.getInt(EXTRA_ACTION, COMPOSE);
391            account = savedState.getParcelable(Utils.EXTRA_ACCOUNT);
392            message = (Message) savedState.getParcelable(EXTRA_MESSAGE);
393
394            previews = savedState.getParcelableArrayList(EXTRA_ATTACHMENT_PREVIEWS);
395            mRefMessage = (Message) savedState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE);
396        } else {
397            account = obtainAccount(intent);
398            action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
399            // Initialize the message from the message in the intent
400            message = (Message) intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE);
401            previews = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_PREVIEWS);
402            mRefMessage = (Message) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
403            mRefMessageUri = (Uri) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI);
404        }
405        mAttachmentsView.setAttachmentPreviews(previews);
406
407        setAccount(account);
408        if (mAccount == null) {
409            return;
410        }
411
412        initRecipients();
413
414        // Clear the notification and mark the conversation as seen, if necessary
415        final Folder notificationFolder =
416                intent.getParcelableExtra(EXTRA_NOTIFICATION_FOLDER);
417        if (notificationFolder != null) {
418            final Intent clearNotifIntent =
419                    new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
420            clearNotifIntent.setPackage(getPackageName());
421            clearNotifIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
422            clearNotifIntent.putExtra(Utils.EXTRA_FOLDER, notificationFolder);
423
424            startService(clearNotifIntent);
425        }
426
427        if (intent.getBooleanExtra(EXTRA_FROM_EMAIL_TASK, false)) {
428            mLaunchedFromEmail = true;
429        } else if (Intent.ACTION_SEND.equals(intent.getAction())) {
430            final Uri dataUri = intent.getData();
431            if (dataUri != null) {
432                final String dataScheme = intent.getData().getScheme();
433                final String accountScheme = mAccount.composeIntentUri.getScheme();
434                mLaunchedFromEmail = TextUtils.equals(dataScheme, accountScheme);
435            }
436        }
437
438        if (mRefMessageUri != null) {
439            mShowQuotedText = true;
440            mComposeMode = action;
441            getLoaderManager().initLoader(INIT_DRAFT_USING_REFERENCE_MESSAGE, null, this);
442            return;
443        } else if (message != null && action != EDIT_DRAFT) {
444            initFromDraftMessage(message);
445            initQuotedTextFromRefMessage(mRefMessage, action);
446            showCcBcc(savedState);
447            mShowQuotedText = message.appendRefMessageContent;
448        } else if (action == EDIT_DRAFT) {
449            initFromDraftMessage(message);
450            boolean showBcc = !TextUtils.isEmpty(message.getBcc());
451            boolean showCc = showBcc || !TextUtils.isEmpty(message.getCc());
452            mCcBccView.show(false, showCc, showBcc);
453            // Update the action to the draft type of the previous draft
454            switch (message.draftType) {
455                case UIProvider.DraftType.REPLY:
456                    action = REPLY;
457                    break;
458                case UIProvider.DraftType.REPLY_ALL:
459                    action = REPLY_ALL;
460                    break;
461                case UIProvider.DraftType.FORWARD:
462                    action = FORWARD;
463                    break;
464                case UIProvider.DraftType.COMPOSE:
465                default:
466                    action = COMPOSE;
467                    break;
468            }
469            LogUtils.d(LOG_TAG, "Previous draft had action type: %d", action);
470
471            mShowQuotedText = message.appendRefMessageContent;
472            if (message.refMessageUri != null) {
473                // If we're editing an existing draft that was in reference to an existing message,
474                // still need to load that original message since we might need to refer to the
475                // original sender and recipients if user switches "reply <-> reply-all".
476                mRefMessageUri = message.refMessageUri;
477                mComposeMode = action;
478                getLoaderManager().initLoader(REFERENCE_MESSAGE_LOADER, null, this);
479                return;
480            }
481        } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
482            if (mRefMessage != null) {
483                initFromRefMessage(action);
484                mShowQuotedText = true;
485            }
486        } else {
487            initFromExtras(intent);
488        }
489
490        mComposeMode = action;
491        finishSetup(action, intent, savedState);
492    }
493
494    private void checkValidAccounts() {
495        final Account[] allAccounts = AccountUtils.getAccounts(this);
496        if (allAccounts == null || allAccounts.length == 0) {
497            final Intent noAccountIntent = MailAppProvider.getNoAccountIntent(this);
498            if (noAccountIntent != null) {
499                mAccounts = null;
500                startActivityForResult(noAccountIntent, RESULT_CREATE_ACCOUNT);
501            }
502        } else {
503            // If none of the accounts are syncing, setup a watcher.
504            boolean anySyncing = false;
505            for (Account a : allAccounts) {
506                if (a.isAccountReady()) {
507                    anySyncing = true;
508                    break;
509                }
510            }
511            if (!anySyncing) {
512                // There are accounts, but none are sync'd, which is just like having no accounts.
513                mAccounts = null;
514                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
515                return;
516            }
517            mAccounts = AccountUtils.getSyncingAccounts(this);
518            finishCreate();
519        }
520    }
521
522    private Account obtainAccount(Intent intent) {
523        Account account = null;
524        Object accountExtra = null;
525        if (intent != null && intent.getExtras() != null) {
526            accountExtra = intent.getExtras().get(Utils.EXTRA_ACCOUNT);
527            if (accountExtra instanceof Account) {
528                return (Account) accountExtra;
529            } else if (accountExtra instanceof String) {
530                // This is the Account attached to the widget compose intent.
531                account = Account.newinstance((String)accountExtra);
532                if (account != null) {
533                    return account;
534                }
535            }
536            accountExtra = intent.hasExtra(Utils.EXTRA_ACCOUNT) ?
537                    intent.getStringExtra(Utils.EXTRA_ACCOUNT) :
538                        intent.getStringExtra(EXTRA_SELECTED_ACCOUNT);
539        }
540        if (account == null) {
541            MailAppProvider provider = MailAppProvider.getInstance();
542            String lastAccountUri = provider.getLastSentFromAccount();
543            if (TextUtils.isEmpty(lastAccountUri)) {
544                lastAccountUri = provider.getLastViewedAccount();
545            }
546            if (!TextUtils.isEmpty(lastAccountUri)) {
547                accountExtra = Uri.parse(lastAccountUri);
548            }
549        }
550        if (mAccounts != null && mAccounts.length > 0) {
551            if (accountExtra instanceof String && !TextUtils.isEmpty((String) accountExtra)) {
552                // For backwards compatibility, we need to check account
553                // names.
554                for (Account a : mAccounts) {
555                    if (a.name.equals(accountExtra)) {
556                        account = a;
557                    }
558                }
559            } else if (accountExtra instanceof Uri) {
560                // The uri of the last viewed account is what is stored in
561                // the current code base.
562                for (Account a : mAccounts) {
563                    if (a.uri.equals(accountExtra)) {
564                        account = a;
565                    }
566                }
567            }
568            if (account == null) {
569                account = mAccounts[0];
570            }
571        }
572        return account;
573    }
574
575    private void finishSetup(int action, Intent intent, Bundle savedInstanceState) {
576        setFocus(action);
577        if (action == COMPOSE) {
578            mQuotedTextView.setVisibility(View.GONE);
579        }
580        // Don't bother with the intent if we have procured a message from the
581        // intent already.
582        if (!hadSavedInstanceStateMessage(savedInstanceState)) {
583            initAttachmentsFromIntent(intent);
584        }
585        initActionBar();
586        initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(),
587                action);
588
589        // If this is a draft message, the draft account is whatever account was
590        // used to open the draft message in Compose.
591        if (mDraft != null) {
592            mDraftAccount = mReplyFromAccount;
593        }
594
595        initChangeListeners();
596        updateHideOrShowCcBcc();
597        updateHideOrShowQuotedText(mShowQuotedText);
598
599        mRespondedInline = mInnerSavedState != null ?
600                mInnerSavedState.getBoolean(EXTRA_RESPONDED_INLINE) : false;
601        if (mRespondedInline) {
602            mQuotedTextView.setVisibility(View.GONE);
603        }
604    }
605
606    private static boolean hadSavedInstanceStateMessage(final Bundle savedInstanceState) {
607        return savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE);
608    }
609
610    private void updateHideOrShowQuotedText(boolean showQuotedText) {
611        mQuotedTextView.updateCheckedState(showQuotedText);
612        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
613    }
614
615    private void setFocus(int action) {
616        if (action == EDIT_DRAFT) {
617            int type = mDraft.draftType;
618            switch (type) {
619                case UIProvider.DraftType.COMPOSE:
620                case UIProvider.DraftType.FORWARD:
621                    action = COMPOSE;
622                    break;
623                case UIProvider.DraftType.REPLY:
624                case UIProvider.DraftType.REPLY_ALL:
625                default:
626                    action = REPLY;
627                    break;
628            }
629        }
630        switch (action) {
631            case FORWARD:
632            case COMPOSE:
633                if (TextUtils.isEmpty(mTo.getText())) {
634                    mTo.requestFocus();
635                    break;
636                }
637                //$FALL-THROUGH$
638            case REPLY:
639            case REPLY_ALL:
640            default:
641                focusBody();
642                break;
643        }
644    }
645
646    /**
647     * Focus the body of the message.
648     */
649    public void focusBody() {
650        mBodyView.requestFocus();
651        int length = mBodyView.getText().length();
652
653        int signatureStartPos = getSignatureStartPosition(
654                mSignature, mBodyView.getText().toString());
655        if (signatureStartPos > -1) {
656            // In case the user deleted the newlines...
657            mBodyView.setSelection(signatureStartPos);
658        } else if (length >= 0) {
659            // Move cursor to the end.
660            mBodyView.setSelection(length);
661        }
662    }
663
664    @Override
665    protected void onResume() {
666        super.onResume();
667        // Update the from spinner as other accounts
668        // may now be available.
669        if (mFromSpinner != null && mAccount != null) {
670            mFromSpinner.initialize(mComposeMode, mAccount, mAccounts, mRefMessage);
671        }
672    }
673
674    @Override
675    protected void onPause() {
676        super.onPause();
677
678        // When the user exits the compose view, see if this draft needs saving.
679        // Don't save unnecessary drafts if we are only changing the orientation.
680        if (!isChangingConfigurations()) {
681            saveIfNeeded();
682        }
683    }
684
685    @Override
686    protected final void onActivityResult(int request, int result, Intent data) {
687        if (request == RESULT_PICK_ATTACHMENT && result == RESULT_OK) {
688            addAttachmentAndUpdateView(data);
689            mAddingAttachment = false;
690        } else if (request == RESULT_CREATE_ACCOUNT) {
691            // We were waiting for the user to create an account
692            if (result != RESULT_OK) {
693                finish();
694            } else {
695                // Watch for accounts to show up!
696                // restart the loader to get the updated list of accounts
697                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
698                showWaitFragment(null);
699            }
700        }
701    }
702
703    @Override
704    public final void onRestoreInstanceState(Bundle savedInstanceState) {
705        final boolean hasAccounts = mAccounts != null && mAccounts.length > 0;
706        if (hasAccounts) {
707            clearChangeListeners();
708        }
709        super.onRestoreInstanceState(savedInstanceState);
710        if (mInnerSavedState != null) {
711            if (mInnerSavedState.containsKey(EXTRA_FOCUS_SELECTION_START)) {
712                int selectionStart = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_START);
713                int selectionEnd = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_END);
714                // There should be a focus and it should be an EditText since we
715                // only save these extras if these conditions are true.
716                EditText focusEditText = (EditText) getCurrentFocus();
717                final int length = focusEditText.getText().length();
718                if (selectionStart < length && selectionEnd < length) {
719                    focusEditText.setSelection(selectionStart, selectionEnd);
720                }
721            }
722        }
723        if (hasAccounts) {
724            initChangeListeners();
725        }
726    }
727
728    @Override
729    public final void onSaveInstanceState(Bundle state) {
730        super.onSaveInstanceState(state);
731        final Bundle inner = new Bundle();
732        saveState(inner);
733        state.putBundle(KEY_INNER_SAVED_STATE, inner);
734    }
735
736    private void saveState(Bundle state) {
737        // We have no accounts so there is nothing to compose, and therefore, nothing to save.
738        if (mAccounts == null || mAccounts.length == 0) {
739            return;
740        }
741        // The framework is happy to save and restore the selection but only if it also saves and
742        // restores the contents of the edit text. That's a lot of text to put in a bundle so we do
743        // this manually.
744        View focus = getCurrentFocus();
745        if (focus != null && focus instanceof EditText) {
746            EditText focusEditText = (EditText) focus;
747            state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart());
748            state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd());
749        }
750
751        final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
752        final int selectedPos = mFromSpinner.getSelectedItemPosition();
753        final ReplyFromAccount selectedReplyFromAccount = (replyFromAccounts != null
754                && replyFromAccounts.size() > 0 && replyFromAccounts.size() > selectedPos) ?
755                        replyFromAccounts.get(selectedPos) : null;
756        if (selectedReplyFromAccount != null) {
757            state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize()
758                    .toString());
759            state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account);
760        } else {
761            state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount);
762        }
763
764        if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId !=0) {
765            // We don't have a draft id, and we have a request id,
766            // save the request id.
767            state.putInt(EXTRA_REQUEST_ID, mRequestId);
768        }
769
770        // We want to restore the current mode after a pause
771        // or rotation.
772        int mode = getMode();
773        state.putInt(EXTRA_ACTION, mode);
774
775        final Message message = createMessage(selectedReplyFromAccount, mode);
776        if (mDraft != null) {
777            message.id = mDraft.id;
778            message.serverId = mDraft.serverId;
779            message.uri = mDraft.uri;
780        }
781        state.putParcelable(EXTRA_MESSAGE, message);
782
783        if (mRefMessage != null) {
784            state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage);
785        }
786        state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible());
787        state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible());
788        state.putBoolean(EXTRA_RESPONDED_INLINE, mRespondedInline);
789        state.putBoolean(EXTRA_SAVE_ENABLED, mSave != null && mSave.isEnabled());
790        state.putParcelableArrayList(
791                EXTRA_ATTACHMENT_PREVIEWS, mAttachmentsView.getAttachmentPreviews());
792    }
793
794    private int getMode() {
795        int mode = ComposeActivity.COMPOSE;
796        ActionBar actionBar = getActionBar();
797        if (actionBar != null
798                && actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) {
799            mode = actionBar.getSelectedNavigationIndex();
800        }
801        return mode;
802    }
803
804    private Message createMessage(ReplyFromAccount selectedReplyFromAccount, int mode) {
805        Message message = new Message();
806        message.id = UIProvider.INVALID_MESSAGE_ID;
807        message.serverId = null;
808        message.uri = null;
809        message.conversationUri = null;
810        message.subject = mSubject.getText().toString();
811        message.snippet = null;
812        message.setTo(formatSenders(mTo.getText().toString()));
813        message.setCc(formatSenders(mCc.getText().toString()));
814        message.setBcc(formatSenders(mBcc.getText().toString()));
815        message.setReplyTo(null);
816        message.dateReceivedMs = 0;
817        final String htmlBody = Html.toHtml(removeComposingSpans(mBodyView.getText()));
818        final StringBuilder fullBody = new StringBuilder(htmlBody);
819        message.bodyHtml = fullBody.toString();
820        message.bodyText = mBodyView.getText().toString();
821        message.embedsExternalResources = false;
822        message.refMessageUri = mRefMessage != null ? mRefMessage.uri : null;
823        message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null;
824        ArrayList<Attachment> attachments = mAttachmentsView.getAttachments();
825        message.hasAttachments = attachments != null && attachments.size() > 0;
826        message.attachmentListUri = null;
827        message.messageFlags = 0;
828        message.alwaysShowImages = false;
829        message.attachmentsJson = Attachment.toJSONArray(attachments);
830        CharSequence quotedText = mQuotedTextView.getQuotedText();
831        message.quotedTextOffset = !TextUtils.isEmpty(quotedText) ? QuotedTextView
832                .getQuotedTextOffset(quotedText.toString()) : -1;
833        message.accountUri = null;
834        message.setFrom(selectedReplyFromAccount != null ? selectedReplyFromAccount.address
835                : mAccount != null ? mAccount.name : null);
836        message.draftType = getDraftType(mode);
837        message.setTo(formatSenders(mTo.getText().toString()));
838        message.setCc(formatSenders(mCc.getText().toString()));
839        message.setBcc(formatSenders(mBcc.getText().toString()));
840        return message;
841    }
842
843    private static String formatSenders(final String string) {
844        if (!TextUtils.isEmpty(string) && string.charAt(string.length() - 1) == ',') {
845            return string.substring(0, string.length() - 1);
846        }
847        return string;
848    }
849
850    @VisibleForTesting
851    void setAccount(Account account) {
852        if (account == null) {
853            return;
854        }
855        if (!account.equals(mAccount)) {
856            mAccount = account;
857            mCachedSettings = mAccount.settings;
858            appendSignature();
859        }
860        if (mAccount != null) {
861            MailActivity.setNfcMessage(mAccount.name);
862        }
863    }
864
865    private void initFromSpinner(Bundle bundle, int action) {
866        if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) {
867            action = COMPOSE;
868        }
869        mFromSpinner.initialize(action, mAccount, mAccounts, mRefMessage);
870
871        if (bundle != null) {
872            if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) {
873                mReplyFromAccount = ReplyFromAccount.deserialize(mAccount,
874                        bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT));
875            } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) {
876                final String accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING);
877                mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString);
878            }
879        }
880        if (mReplyFromAccount == null) {
881            if (mDraft != null) {
882                mReplyFromAccount = getReplyFromAccountFromDraft(mAccount, mDraft);
883            } else if (mRefMessage != null) {
884                mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage);
885            }
886        }
887        if (mReplyFromAccount == null) {
888            mReplyFromAccount = getDefaultReplyFromAccount(mAccount);
889        }
890
891        mFromSpinner.setCurrentAccount(mReplyFromAccount);
892
893        if (mFromSpinner.getCount() > 1) {
894            // If there is only 1 account, just show that account.
895            // Otherwise, give the user the ability to choose which account to
896            // send mail from / save drafts to.
897            mFromStatic.setVisibility(View.GONE);
898            mFromStaticText.setText(mReplyFromAccount.name);
899            mFromSpinnerWrapper.setVisibility(View.VISIBLE);
900        } else {
901            mFromStatic.setVisibility(View.VISIBLE);
902            mFromStaticText.setText(mReplyFromAccount.name);
903            mFromSpinnerWrapper.setVisibility(View.GONE);
904        }
905    }
906
907    private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) {
908        if (refMessage.accountUri != null) {
909            // This must be from combined inbox.
910            List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
911            for (ReplyFromAccount from : replyFromAccounts) {
912                if (from.account.uri.equals(refMessage.accountUri)) {
913                    return from;
914                }
915            }
916            return null;
917        } else {
918            return getReplyFromAccount(account, refMessage);
919        }
920    }
921
922    /**
923     * Given an account and which email address the message was sent to,
924     * return who the message should be sent from.
925     * @param account Account in which the message arrived.
926     * @param sentTo Email address to which the message was sent.
927     * @return the address from which to reply.
928     */
929    public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) {
930        // First see if we are supposed to use the default address or
931        // the address it was sentTo.
932        if (mCachedSettings.forceReplyFromDefault) {
933            return getDefaultReplyFromAccount(account);
934        } else {
935            // If we aren't explicitly told which account to look for, look at
936            // all the message recipients and find one that matches
937            // a custom from or account.
938            List<String> allRecipients = new ArrayList<String>();
939            allRecipients.addAll(Arrays.asList(refMessage.getToAddresses()));
940            allRecipients.addAll(Arrays.asList(refMessage.getCcAddresses()));
941            return getMatchingRecipient(account, allRecipients);
942        }
943    }
944
945    /**
946     * Compare all the recipients of an email to the current account and all
947     * custom addresses associated with that account. Return the match if there
948     * is one, or the default account if there isn't.
949     */
950    protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) {
951        // Tokenize the list and place in a hashmap.
952        ReplyFromAccount matchingReplyFrom = null;
953        Rfc822Token[] tokens;
954        HashSet<String> recipientsMap = new HashSet<String>();
955        for (String address : sentTo) {
956            tokens = Rfc822Tokenizer.tokenize(address);
957            for (int i = 0; i < tokens.length; i++) {
958                recipientsMap.add(tokens[i].getAddress());
959            }
960        }
961
962        int matchingAddressCount = 0;
963        List<ReplyFromAccount> customFroms;
964        customFroms = account.getReplyFroms();
965        if (customFroms != null) {
966            for (ReplyFromAccount entry : customFroms) {
967                if (recipientsMap.contains(entry.address)) {
968                    matchingReplyFrom = entry;
969                    matchingAddressCount++;
970                }
971            }
972        }
973        if (matchingAddressCount > 1) {
974            matchingReplyFrom = getDefaultReplyFromAccount(account);
975        }
976        return matchingReplyFrom;
977    }
978
979    private static ReplyFromAccount getDefaultReplyFromAccount(final Account account) {
980        for (final ReplyFromAccount from : account.getReplyFroms()) {
981            if (from.isDefault) {
982                return from;
983            }
984        }
985        return new ReplyFromAccount(account, account.uri, account.name, account.name, account.name,
986                true, false);
987    }
988
989    private ReplyFromAccount getReplyFromAccountFromDraft(Account account, Message msg) {
990        String sender = msg.getFrom();
991        ReplyFromAccount replyFromAccount = null;
992        List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
993        if (TextUtils.equals(account.name, sender)) {
994            replyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name,
995                    mAccount.name, mAccount.name, true, false);
996        } else {
997            for (ReplyFromAccount fromAccount : replyFromAccounts) {
998                if (TextUtils.equals(fromAccount.name, sender)) {
999                    replyFromAccount = fromAccount;
1000                    break;
1001                }
1002            }
1003        }
1004        return replyFromAccount;
1005    }
1006
1007    private void findViews() {
1008        findViewById(R.id.compose).setVisibility(View.VISIBLE);
1009        mCcBccButton = (Button) findViewById(R.id.add_cc_bcc);
1010        if (mCcBccButton != null) {
1011            mCcBccButton.setOnClickListener(this);
1012        }
1013        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
1014        mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments);
1015        mPhotoAttachmentsButton = findViewById(R.id.add_photo_attachment);
1016        if (mPhotoAttachmentsButton != null) {
1017            mPhotoAttachmentsButton.setOnClickListener(this);
1018        }
1019        mVideoAttachmentsButton = findViewById(R.id.add_video_attachment);
1020        if (mVideoAttachmentsButton != null) {
1021            mVideoAttachmentsButton.setOnClickListener(this);
1022        }
1023        mTo = (RecipientEditTextView) findViewById(R.id.to);
1024        mTo.setTokenizer(new Rfc822Tokenizer());
1025        mCc = (RecipientEditTextView) findViewById(R.id.cc);
1026        mCc.setTokenizer(new Rfc822Tokenizer());
1027        mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
1028        mBcc.setTokenizer(new Rfc822Tokenizer());
1029        // TODO: add special chips text change watchers before adding
1030        // this as a text changed watcher to the to, cc, bcc fields.
1031        mSubject = (TextView) findViewById(R.id.subject);
1032        mSubject.setOnEditorActionListener(this);
1033        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
1034        mQuotedTextView.setRespondInlineListener(this);
1035        mBodyView = (EditText) findViewById(R.id.body);
1036        mFromStatic = findViewById(R.id.static_from_content);
1037        mFromStaticText = (TextView) findViewById(R.id.from_account_name);
1038        mFromSpinnerWrapper = findViewById(R.id.spinner_from_content);
1039        mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker);
1040    }
1041
1042    @Override
1043    public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
1044        if (action == EditorInfo.IME_ACTION_DONE) {
1045            focusBody();
1046            return true;
1047        }
1048        return false;
1049    }
1050
1051    protected TextView getBody() {
1052        return mBodyView;
1053    }
1054
1055    @VisibleForTesting
1056    public Account getFromAccount() {
1057        return mReplyFromAccount != null && mReplyFromAccount.account != null ?
1058                mReplyFromAccount.account : mAccount;
1059    }
1060
1061    private void clearChangeListeners() {
1062        mSubject.removeTextChangedListener(this);
1063        mBodyView.removeTextChangedListener(this);
1064        mTo.removeTextChangedListener(mToListener);
1065        mCc.removeTextChangedListener(mCcListener);
1066        mBcc.removeTextChangedListener(mBccListener);
1067        mFromSpinner.setOnAccountChangedListener(null);
1068        mAttachmentsView.setAttachmentChangesListener(null);
1069    }
1070
1071    // Now that the message has been initialized from any existing draft or
1072    // ref message data, set up listeners for any changes that occur to the
1073    // message.
1074    private void initChangeListeners() {
1075        // Make sure we only add text changed listeners once!
1076        clearChangeListeners();
1077        mSubject.addTextChangedListener(this);
1078        mBodyView.addTextChangedListener(this);
1079        if (mToListener == null) {
1080            mToListener = new RecipientTextWatcher(mTo, this);
1081        }
1082        mTo.addTextChangedListener(mToListener);
1083        if (mCcListener == null) {
1084            mCcListener = new RecipientTextWatcher(mCc, this);
1085        }
1086        mCc.addTextChangedListener(mCcListener);
1087        if (mBccListener == null) {
1088            mBccListener = new RecipientTextWatcher(mBcc, this);
1089        }
1090        mBcc.addTextChangedListener(mBccListener);
1091        mFromSpinner.setOnAccountChangedListener(this);
1092        mAttachmentsView.setAttachmentChangesListener(this);
1093    }
1094
1095    private void initActionBar() {
1096        LogUtils.d(LOG_TAG, "initializing action bar in ComposeActivity");
1097        ActionBar actionBar = getActionBar();
1098        if (actionBar == null) {
1099            return;
1100        }
1101        if (mComposeMode == ComposeActivity.COMPOSE) {
1102            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
1103            actionBar.setTitle(R.string.compose);
1104        } else {
1105            actionBar.setTitle(null);
1106            if (mComposeModeAdapter == null) {
1107                mComposeModeAdapter = new ComposeModeAdapter(this);
1108            }
1109            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
1110            actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
1111            switch (mComposeMode) {
1112                case ComposeActivity.REPLY:
1113                    actionBar.setSelectedNavigationItem(0);
1114                    break;
1115                case ComposeActivity.REPLY_ALL:
1116                    actionBar.setSelectedNavigationItem(1);
1117                    break;
1118                case ComposeActivity.FORWARD:
1119                    actionBar.setSelectedNavigationItem(2);
1120                    break;
1121            }
1122        }
1123        actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME,
1124                ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME);
1125        actionBar.setHomeButtonEnabled(true);
1126    }
1127
1128    private void initFromRefMessage(int action) {
1129        setFieldsFromRefMessage(action);
1130
1131        // Check if To: address and email body needs to be prefilled based on extras.
1132        // This is used for reporting rendering feedback.
1133        if (MessageHeaderView.ENABLE_REPORT_RENDERING_PROBLEM) {
1134            Intent intent = getIntent();
1135            if (intent.getExtras() != null) {
1136                String toAddresses = intent.getStringExtra(EXTRA_TO);
1137                if (toAddresses != null) {
1138                    addToAddresses(Arrays.asList(TextUtils.split(toAddresses, ",")));
1139                }
1140                String body = intent.getStringExtra(EXTRA_BODY);
1141                if (body != null) {
1142                    setBody(body, false /* withSignature */);
1143                }
1144            }
1145        }
1146
1147        if (mRefMessage != null) {
1148            // CC field only gets populated when doing REPLY_ALL.
1149            // BCC never gets auto-populated, unless the user is editing
1150            // a draft with one.
1151            if (!TextUtils.isEmpty(mCc.getText()) && action == REPLY_ALL) {
1152                mCcBccView.show(false, true, false);
1153            }
1154        }
1155        updateHideOrShowCcBcc();
1156    }
1157
1158    private void setFieldsFromRefMessage(int action) {
1159        setSubject(mRefMessage, action);
1160        // Setup recipients
1161        if (action == FORWARD) {
1162            mForward = true;
1163        }
1164        initRecipientsFromRefMessage(mRefMessage, action);
1165        initQuotedTextFromRefMessage(mRefMessage, action);
1166        if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
1167            initAttachments(mRefMessage);
1168        }
1169    }
1170
1171    private void initFromDraftMessage(Message message) {
1172        LogUtils.d(LOG_TAG, "Intializing draft from previous draft message: %s", message);
1173
1174        mDraft = message;
1175        mDraftId = message.id;
1176        mSubject.setText(message.subject);
1177        mForward = message.draftType == UIProvider.DraftType.FORWARD;
1178        final List<String> toAddresses = Arrays.asList(message.getToAddresses());
1179        addToAddresses(toAddresses);
1180        addCcAddresses(Arrays.asList(message.getCcAddresses()), toAddresses);
1181        addBccAddresses(Arrays.asList(message.getBccAddresses()));
1182        if (message.hasAttachments) {
1183            List<Attachment> attachments = message.getAttachments();
1184            for (Attachment a : attachments) {
1185                addAttachmentAndUpdateView(a);
1186            }
1187        }
1188        int quotedTextIndex = message.appendRefMessageContent ?
1189                message.quotedTextOffset : -1;
1190        // Set the body
1191        CharSequence quotedText = null;
1192        if (!TextUtils.isEmpty(message.bodyHtml)) {
1193            CharSequence htmlText = "";
1194            if (quotedTextIndex > -1) {
1195                // Find the offset in the htmltext of the actual quoted text and strip it out.
1196                quotedTextIndex = QuotedTextView.findQuotedTextIndex(message.bodyHtml);
1197                if (quotedTextIndex > -1) {
1198                    htmlText = Utils.convertHtmlToPlainText(message.bodyHtml.substring(0,
1199                            quotedTextIndex));
1200                    quotedText = message.bodyHtml.subSequence(quotedTextIndex,
1201                            message.bodyHtml.length());
1202                }
1203            } else {
1204                htmlText = Utils.convertHtmlToPlainText(message.bodyHtml);
1205            }
1206            mBodyView.setText(htmlText);
1207        } else {
1208            final String body = message.bodyText;
1209            final CharSequence bodyText = !TextUtils.isEmpty(body) ?
1210                    (quotedTextIndex > -1 ?
1211                            message.bodyText.substring(0, quotedTextIndex) : message.bodyText)
1212                            : "";
1213            if (quotedTextIndex > -1) {
1214                quotedText = !TextUtils.isEmpty(body) ? message.bodyText.substring(quotedTextIndex)
1215                        : null;
1216            }
1217            mBodyView.setText(bodyText);
1218        }
1219        if (quotedTextIndex > -1 && quotedText != null) {
1220            mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward);
1221        }
1222    }
1223
1224    /**
1225     * Fill all the widgets with the content found in the Intent Extra, if any.
1226     * Also apply the same style to all widgets. Note: if initFromExtras is
1227     * called as a result of switching between reply, reply all, and forward per
1228     * the latest revision of Gmail, and the user has already made changes to
1229     * attachments on a previous incarnation of the message (as a reply, reply
1230     * all, or forward), the original attachments from the message will not be
1231     * re-instantiated. The user's changes will be respected. This follows the
1232     * web gmail interaction.
1233     */
1234    public void initFromExtras(Intent intent) {
1235        // If we were invoked with a SENDTO intent, the value
1236        // should take precedence
1237        final Uri dataUri = intent.getData();
1238        if (dataUri != null) {
1239            if (MAIL_TO.equals(dataUri.getScheme())) {
1240                initFromMailTo(dataUri.toString());
1241            } else {
1242                if (!mAccount.composeIntentUri.equals(dataUri)) {
1243                    String toText = dataUri.getSchemeSpecificPart();
1244                    if (toText != null) {
1245                        mTo.setText("");
1246                        addToAddresses(Arrays.asList(TextUtils.split(toText, ",")));
1247                    }
1248                }
1249            }
1250        }
1251
1252        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
1253        if (extraStrings != null) {
1254            addToAddresses(Arrays.asList(extraStrings));
1255        }
1256        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
1257        if (extraStrings != null) {
1258            addCcAddresses(Arrays.asList(extraStrings), null);
1259        }
1260        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1261        if (extraStrings != null) {
1262            addBccAddresses(Arrays.asList(extraStrings));
1263        }
1264
1265        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1266        if (extraString != null) {
1267            mSubject.setText(extraString);
1268        }
1269
1270        for (String extra : ALL_EXTRAS) {
1271            if (intent.hasExtra(extra)) {
1272                String value = intent.getStringExtra(extra);
1273                if (EXTRA_TO.equals(extra)) {
1274                    addToAddresses(Arrays.asList(TextUtils.split(value, ",")));
1275                } else if (EXTRA_CC.equals(extra)) {
1276                    addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null);
1277                } else if (EXTRA_BCC.equals(extra)) {
1278                    addBccAddresses(Arrays.asList(TextUtils.split(value, ",")));
1279                } else if (EXTRA_SUBJECT.equals(extra)) {
1280                    mSubject.setText(value);
1281                } else if (EXTRA_BODY.equals(extra)) {
1282                    setBody(value, true /* with signature */);
1283                }
1284            }
1285        }
1286
1287        Bundle extras = intent.getExtras();
1288        if (extras != null) {
1289            CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT);
1290            if (text != null) {
1291                setBody(text, true /* with signature */);
1292            }
1293        }
1294    }
1295
1296    @VisibleForTesting
1297    protected String decodeEmailInUri(String s) throws UnsupportedEncodingException {
1298        // TODO: handle the case where there are spaces in the display name as
1299        // well as the email such as "Guy with spaces <guy+with+spaces@gmail.com>"
1300        // as they could be encoded ambiguously.
1301        // Since URLDecode.decode changes + into ' ', and + is a valid
1302        // email character, we need to find/ replace these ourselves before
1303        // decoding.
1304        try {
1305            return URLDecoder.decode(replacePlus(s), UTF8_ENCODING_NAME);
1306        } catch (IllegalArgumentException e) {
1307            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1308                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s);
1309            } else {
1310                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1311            }
1312            return null;
1313        }
1314    }
1315
1316    /**
1317     * Replaces all occurrences of '+' with "%2B", to prevent URLDecode.decode from
1318     * changing '+' into ' '
1319     *
1320     * @param toReplace Input string
1321     * @return The string with all "+" characters replaced with "%2B"
1322     */
1323    private static String replacePlus(String toReplace) {
1324        return toReplace.replace("+", "%2B");
1325    }
1326
1327    /**
1328     * Initialize the compose view from a String representing a mailTo uri.
1329     * @param mailToString The uri as a string.
1330     */
1331    public void initFromMailTo(String mailToString) {
1332        // We need to disguise this string as a URI in order to parse it
1333        // TODO:  Remove this hack when http://b/issue?id=1445295 gets fixed
1334        Uri uri = Uri.parse("foo://" + mailToString);
1335        int index = mailToString.indexOf("?");
1336        int length = "mailto".length() + 1;
1337        String to;
1338        try {
1339            // Extract the recipient after mailto:
1340            if (index == -1) {
1341                to = decodeEmailInUri(mailToString.substring(length));
1342            } else {
1343                to = decodeEmailInUri(mailToString.substring(length, index));
1344            }
1345            if (!TextUtils.isEmpty(to)) {
1346                addToAddresses(Arrays.asList(TextUtils.split(to, ",")));
1347            }
1348        } catch (UnsupportedEncodingException e) {
1349            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1350                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString);
1351            } else {
1352                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1353            }
1354        }
1355
1356        List<String> cc = uri.getQueryParameters("cc");
1357        addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null);
1358
1359        List<String> otherTo = uri.getQueryParameters("to");
1360        addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()])));
1361
1362        List<String> bcc = uri.getQueryParameters("bcc");
1363        addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()])));
1364
1365        List<String> subject = uri.getQueryParameters("subject");
1366        if (subject.size() > 0) {
1367            try {
1368                mSubject.setText(URLDecoder.decode(replacePlus(subject.get(0)),
1369                        UTF8_ENCODING_NAME));
1370            } catch (UnsupportedEncodingException e) {
1371                LogUtils.e(LOG_TAG, "%s while decoding subject '%s'",
1372                        e.getMessage(), subject);
1373            }
1374        }
1375
1376        List<String> body = uri.getQueryParameters("body");
1377        if (body.size() > 0) {
1378            try {
1379                setBody(URLDecoder.decode(replacePlus(body.get(0)), UTF8_ENCODING_NAME),
1380                        true /* with signature */);
1381            } catch (UnsupportedEncodingException e) {
1382                LogUtils.e(LOG_TAG, "%s while decoding body '%s'", e.getMessage(), body);
1383            }
1384        }
1385    }
1386
1387    @VisibleForTesting
1388    protected void initAttachments(Message refMessage) {
1389        addAttachments(refMessage.getAttachments());
1390    }
1391
1392    public long addAttachments(List<Attachment> attachments) {
1393        long size = 0;
1394        AttachmentFailureException error = null;
1395        for (Attachment a : attachments) {
1396            try {
1397                size += mAttachmentsView.addAttachment(mAccount, a);
1398            } catch (AttachmentFailureException e) {
1399                error = e;
1400            }
1401        }
1402        if (error != null) {
1403            LogUtils.e(LOG_TAG, error, "Error adding attachment");
1404            if (attachments.size() > 1) {
1405                showAttachmentTooBigToast(R.string.too_large_to_attach_multiple);
1406            } else {
1407                showAttachmentTooBigToast(error.getErrorRes());
1408            }
1409        }
1410        return size;
1411    }
1412
1413    /**
1414     * When an attachment is too large to be added to a message, show a toast.
1415     * This method also updates the position of the toast so that it is shown
1416     * clearly above they keyboard if it happens to be open.
1417     */
1418    private void showAttachmentTooBigToast(int errorRes) {
1419        String maxSize = AttachmentUtils.convertToHumanReadableSize(
1420                getApplicationContext(), mAccount.settings.getMaxAttachmentSize());
1421        showErrorToast(getString(errorRes, maxSize));
1422    }
1423
1424    private void showErrorToast(String message) {
1425        Toast t = Toast.makeText(this, message, Toast.LENGTH_LONG);
1426        t.setText(message);
1427        t.setGravity(Gravity.CENTER_HORIZONTAL, 0,
1428                getResources().getDimensionPixelSize(R.dimen.attachment_toast_yoffset));
1429        t.show();
1430    }
1431
1432    private void initAttachmentsFromIntent(Intent intent) {
1433        Bundle extras = intent.getExtras();
1434        if (extras == null) {
1435            extras = Bundle.EMPTY;
1436        }
1437        final String action = intent.getAction();
1438        if (!mAttachmentsChanged) {
1439            long totalSize = 0;
1440            if (extras.containsKey(EXTRA_ATTACHMENTS)) {
1441                String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS);
1442                for (String uriString : uris) {
1443                    final Uri uri = Uri.parse(uriString);
1444                    long size = 0;
1445                    try {
1446                        size =  mAttachmentsView.addAttachment(mAccount, uri);
1447                    } catch (AttachmentFailureException e) {
1448                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1449                        showAttachmentTooBigToast(e.getErrorRes());
1450                    }
1451                    totalSize += size;
1452                }
1453            }
1454            if (extras.containsKey(Intent.EXTRA_STREAM)) {
1455                if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
1456                    ArrayList<Parcelable> uris = extras
1457                            .getParcelableArrayList(Intent.EXTRA_STREAM);
1458                    ArrayList<Attachment> attachments = new ArrayList<Attachment>();
1459                    for (Parcelable uri : uris) {
1460                        try {
1461                            attachments.add(mAttachmentsView.generateLocalAttachment((Uri) uri));
1462                        } catch (AttachmentFailureException e) {
1463                            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1464                            String maxSize = AttachmentUtils.convertToHumanReadableSize(
1465                                    getApplicationContext(),
1466                                    mAccount.settings.getMaxAttachmentSize());
1467                            showErrorToast(getString
1468                                    (R.string.generic_attachment_problem, maxSize));
1469                        }
1470                    }
1471                    totalSize += addAttachments(attachments);
1472                } else {
1473                    final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM);
1474                    long size = 0;
1475                    try {
1476                        size = mAttachmentsView.addAttachment(mAccount, uri);
1477                    } catch (AttachmentFailureException e) {
1478                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1479                        showAttachmentTooBigToast(e.getErrorRes());
1480                    }
1481                    totalSize += size;
1482                }
1483            }
1484
1485            if (totalSize > 0) {
1486                mAttachmentsChanged = true;
1487                updateSaveUi();
1488            }
1489        }
1490    }
1491
1492
1493    private void initQuotedTextFromRefMessage(Message refMessage, int action) {
1494        if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) {
1495            mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD);
1496        }
1497    }
1498
1499    private void updateHideOrShowCcBcc() {
1500        // Its possible there is a menu item OR a button.
1501        boolean ccVisible = mCcBccView.isCcVisible();
1502        boolean bccVisible = mCcBccView.isBccVisible();
1503        if (mCcBccButton != null) {
1504            if (!ccVisible || !bccVisible) {
1505                mCcBccButton.setVisibility(View.VISIBLE);
1506                mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label
1507                        : R.string.add_bcc_label));
1508            } else {
1509                mCcBccButton.setVisibility(View.INVISIBLE);
1510            }
1511        }
1512    }
1513
1514    private void showCcBcc(Bundle state) {
1515        if (state != null && state.containsKey(EXTRA_SHOW_CC)) {
1516            boolean showCc = state.getBoolean(EXTRA_SHOW_CC);
1517            boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC);
1518            if (showCc || showBcc) {
1519                mCcBccView.show(false, showCc, showBcc);
1520            }
1521        }
1522    }
1523
1524    /**
1525     * Add attachment and update the compose area appropriately.
1526     * @param data
1527     */
1528    public void addAttachmentAndUpdateView(Intent data) {
1529        addAttachmentAndUpdateView(data != null ? data.getData() : (Uri) null);
1530    }
1531
1532    public void addAttachmentAndUpdateView(Uri contentUri) {
1533        if (contentUri == null) {
1534            return;
1535        }
1536        try {
1537            addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri));
1538        } catch (AttachmentFailureException e) {
1539            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1540            showErrorToast(getResources().getString(
1541                    e.getErrorRes(),
1542                    AttachmentUtils.convertToHumanReadableSize(
1543                            getApplicationContext(), mAccount.settings.getMaxAttachmentSize())));
1544        }
1545    }
1546
1547    public void addAttachmentAndUpdateView(Attachment attachment) {
1548        try {
1549            long size = mAttachmentsView.addAttachment(mAccount, attachment);
1550            if (size > 0) {
1551                mAttachmentsChanged = true;
1552                updateSaveUi();
1553            }
1554        } catch (AttachmentFailureException e) {
1555            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1556            showAttachmentTooBigToast(e.getErrorRes());
1557        }
1558    }
1559
1560    void initRecipientsFromRefMessage(Message refMessage, int action) {
1561        // Don't populate the address if this is a forward.
1562        if (action == ComposeActivity.FORWARD) {
1563            return;
1564        }
1565        initReplyRecipients(refMessage, action);
1566    }
1567
1568    @VisibleForTesting
1569    void initReplyRecipients(final Message refMessage, final int action) {
1570        String[] sentToAddresses = refMessage.getToAddresses();
1571        final Collection<String> toAddresses;
1572        String replytoAddress = refMessage.getReplyTo();
1573        // If there is no reply to address, the reply to address is the sender.
1574        if (TextUtils.isEmpty(replytoAddress)) {
1575            replytoAddress = refMessage.getFrom();
1576        }
1577
1578        // If this is a reply, the Cc list is empty. If this is a reply-all, the
1579        // Cc list is the union of the To and Cc recipients of the original
1580        // message, excluding the current user's email address and any addresses
1581        // already on the To list.
1582        if (action == ComposeActivity.REPLY) {
1583            toAddresses = initToRecipients(refMessage.getFrom(), replytoAddress, sentToAddresses);
1584            addToAddresses(toAddresses);
1585        } else if (action == ComposeActivity.REPLY_ALL) {
1586            final Set<String> ccAddresses = Sets.newHashSet();
1587            toAddresses = initToRecipients(refMessage.getFrom(), replytoAddress, sentToAddresses);
1588            addToAddresses(toAddresses);
1589            addRecipients(ccAddresses, sentToAddresses);
1590            addRecipients(ccAddresses, refMessage.getCcAddresses());
1591            addCcAddresses(ccAddresses, toAddresses);
1592        }
1593    }
1594
1595    private void addToAddresses(Collection<String> addresses) {
1596        addAddressesToList(addresses, mTo);
1597    }
1598
1599    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
1600        addCcAddressesToList(tokenizeAddressList(addresses),
1601                toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc);
1602    }
1603
1604    private void addBccAddresses(Collection<String> addresses) {
1605        addAddressesToList(addresses, mBcc);
1606    }
1607
1608    @VisibleForTesting
1609    protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
1610            List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
1611        String address;
1612
1613        if (compareToList == null) {
1614            for (Rfc822Token[] tokens : addresses) {
1615                for (int i = 0; i < tokens.length; i++) {
1616                    address = tokens[i].toString();
1617                    list.append(address + END_TOKEN);
1618                }
1619            }
1620        } else {
1621            HashSet<String> compareTo = convertToHashSet(compareToList);
1622            for (Rfc822Token[] tokens : addresses) {
1623                for (int i = 0; i < tokens.length; i++) {
1624                    address = tokens[i].toString();
1625                    // Check if this is a duplicate:
1626                    if (!compareTo.contains(tokens[i].getAddress())) {
1627                        // Get the address here
1628                        list.append(address + END_TOKEN);
1629                    }
1630                }
1631            }
1632        }
1633    }
1634
1635    private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) {
1636        final HashSet<String> hash = new HashSet<String>();
1637        for (final Rfc822Token[] tokens : list) {
1638            for (int i = 0; i < tokens.length; i++) {
1639                hash.add(tokens[i].getAddress());
1640            }
1641        }
1642        return hash;
1643    }
1644
1645    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
1646        @VisibleForTesting
1647        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
1648
1649        for (String address: addresses) {
1650            tokenized.add(Rfc822Tokenizer.tokenize(address));
1651        }
1652        return tokenized;
1653    }
1654
1655    @VisibleForTesting
1656    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
1657        for (String address : addresses) {
1658            addAddressToList(address, list);
1659        }
1660    }
1661
1662    private static void addAddressToList(final String address, final RecipientEditTextView list) {
1663        if (address == null || list == null)
1664            return;
1665
1666        final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
1667
1668        for (int i = 0; i < tokens.length; i++) {
1669            list.append(tokens[i] + END_TOKEN);
1670        }
1671    }
1672
1673    @VisibleForTesting
1674    protected Collection<String> initToRecipients(final String fullSenderAddress,
1675            final String replyToAddress, final String[] inToAddresses) {
1676        // The To recipient is the reply-to address specified in the original
1677        // message, unless it is:
1678        // the current user OR a custom from of the current user, in which case
1679        // it's the To recipient list of the original message.
1680        // OR missing, in which case use the sender of the original message
1681        Set<String> toAddresses = Sets.newHashSet();
1682        if (!TextUtils.isEmpty(replyToAddress) && !recipientMatchesThisAccount(replyToAddress)) {
1683            toAddresses.add(replyToAddress);
1684        } else {
1685            // In this case, the user is replying to a message in which their
1686            // current account or one of their custom from addresses is the only
1687            // recipient and they sent the original message.
1688            if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress)
1689                    && recipientMatchesThisAccount(inToAddresses[0])) {
1690                toAddresses.add(inToAddresses[0]);
1691                return toAddresses;
1692            }
1693            // This happens if the user replies to a message they originally
1694            // wrote. In this case, "reply" really means "re-send," so we
1695            // target the original recipients. This works as expected even
1696            // if the user sent the original message to themselves.
1697            for (String address : inToAddresses) {
1698                if (!recipientMatchesThisAccount(address)) {
1699                    toAddresses.add(address);
1700                }
1701            }
1702        }
1703        return toAddresses;
1704    }
1705
1706    private void addRecipients(final Set<String> recipients, final String[] addresses) {
1707        for (final String email : addresses) {
1708            // Do not add this account, or any of its custom from addresses, to
1709            // the list of recipients.
1710            final String recipientAddress = Address.getEmailAddress(email).getAddress();
1711            if (!recipientMatchesThisAccount(recipientAddress)) {
1712                recipients.add(email.replace("\"\"", ""));
1713            }
1714        }
1715    }
1716
1717    /**
1718     * A recipient matches this account if it has the same address as the
1719     * currently selected account OR one of the custom from addresses associated
1720     * with the currently selected account.
1721     * @param accountAddress currently selected account
1722     * @param recipientAddress address we are comparing with the currently selected account
1723     * @return
1724     */
1725    protected boolean recipientMatchesThisAccount(String recipientAddress) {
1726        return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress,
1727                        mAccount.getReplyFroms());
1728    }
1729
1730    private void setSubject(Message refMessage, int action) {
1731        String subject = refMessage.subject;
1732        String prefix;
1733        String correctedSubject = null;
1734        if (action == ComposeActivity.COMPOSE) {
1735            prefix = "";
1736        } else if (action == ComposeActivity.FORWARD) {
1737            prefix = getString(R.string.forward_subject_label);
1738        } else {
1739            prefix = getString(R.string.reply_subject_label);
1740        }
1741
1742        // Don't duplicate the prefix
1743        if (!TextUtils.isEmpty(subject)
1744                && subject.toLowerCase().startsWith(prefix.toLowerCase())) {
1745            correctedSubject = subject;
1746        } else {
1747            correctedSubject = String
1748                    .format(getString(R.string.formatted_subject), prefix, subject);
1749        }
1750        mSubject.setText(correctedSubject);
1751    }
1752
1753    private void initRecipients() {
1754        setupRecipients(mTo);
1755        setupRecipients(mCc);
1756        setupRecipients(mBcc);
1757    }
1758
1759    private void setupRecipients(RecipientEditTextView view) {
1760        view.setAdapter(new RecipientAdapter(this, mAccount));
1761        if (mValidator == null) {
1762            final String accountName = mAccount.name;
1763            int offset = accountName.indexOf("@") + 1;
1764            String account = accountName;
1765            if (offset > -1) {
1766                account = account.substring(accountName.indexOf("@") + 1);
1767            }
1768            mValidator = new Rfc822Validator(account);
1769        }
1770        view.setValidator(mValidator);
1771    }
1772
1773    @Override
1774    public void onClick(View v) {
1775        int id = v.getId();
1776        switch (id) {
1777            case R.id.add_cc_bcc:
1778                // Verify that cc/ bcc aren't showing.
1779                // Animate in cc/bcc.
1780                showCcBccViews();
1781                break;
1782            case R.id.add_photo_attachment:
1783                doAttach(MIME_TYPE_PHOTO);
1784                break;
1785            case R.id.add_video_attachment:
1786                doAttach(MIME_TYPE_VIDEO);
1787                break;
1788        }
1789    }
1790
1791    @Override
1792    public boolean onCreateOptionsMenu(Menu menu) {
1793        super.onCreateOptionsMenu(menu);
1794        // Don't render any menu items when there are no accounts.
1795        if (mAccounts == null || mAccounts.length == 0) {
1796            return true;
1797        }
1798        MenuInflater inflater = getMenuInflater();
1799        inflater.inflate(R.menu.compose_menu, menu);
1800
1801        /*
1802         * Start save in the correct enabled state.
1803         * 1) If a user launches compose from within gmail, save is disabled
1804         * until they add something, at which point, save is enabled, auto save
1805         * on exit; if the user empties everything, save is disabled, exiting does not
1806         * auto-save
1807         * 2) if a user replies/ reply all/ forwards from within gmail, save is
1808         * disabled until they change something, at which point, save is
1809         * enabled, auto save on exit; if the user empties everything, save is
1810         * disabled, exiting does not auto-save.
1811         * 3) If a user launches compose from another application and something
1812         * gets populated (attachments, recipients, body, subject, etc), save is
1813         * enabled, auto save on exit; if the user empties everything, save is
1814         * disabled, exiting does not auto-save
1815         */
1816        mSave = menu.findItem(R.id.save);
1817        String action = getIntent() != null ? getIntent().getAction() : null;
1818        enableSave(mInnerSavedState != null ?
1819                mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED)
1820                    : (Intent.ACTION_SEND.equals(action)
1821                            || Intent.ACTION_SEND_MULTIPLE.equals(action)
1822                            || Intent.ACTION_SENDTO.equals(action)
1823                            || shouldSave()));
1824
1825        mSend = menu.findItem(R.id.send);
1826        MenuItem helpItem = menu.findItem(R.id.help_info_menu_item);
1827        MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item);
1828        if (helpItem != null) {
1829            helpItem.setVisible(mAccount != null
1830                    && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT));
1831        }
1832        if (sendFeedbackItem != null) {
1833            sendFeedbackItem.setVisible(mAccount != null
1834                    && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK));
1835        }
1836        return true;
1837    }
1838
1839    @Override
1840    public boolean onPrepareOptionsMenu(Menu menu) {
1841        MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
1842        if (ccBcc != null && mCc != null) {
1843            // Its possible there is a menu item OR a button.
1844            boolean ccFieldVisible = mCc.isShown();
1845            boolean bccFieldVisible = mBcc.isShown();
1846            if (!ccFieldVisible || !bccFieldVisible) {
1847                ccBcc.setVisible(true);
1848                ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
1849                        : R.string.add_bcc_label));
1850            } else {
1851                ccBcc.setVisible(false);
1852            }
1853        }
1854        return true;
1855    }
1856
1857    @Override
1858    public boolean onOptionsItemSelected(MenuItem item) {
1859        int id = item.getItemId();
1860        boolean handled = true;
1861        switch (id) {
1862            case R.id.add_photo_attachment:
1863                doAttach(MIME_TYPE_PHOTO);
1864                break;
1865            case R.id.add_video_attachment:
1866                doAttach(MIME_TYPE_VIDEO);
1867                break;
1868            case R.id.add_cc_bcc:
1869                showCcBccViews();
1870                break;
1871            case R.id.save:
1872                doSave(true);
1873                break;
1874            case R.id.send:
1875                doSend();
1876                break;
1877            case R.id.discard:
1878                doDiscard();
1879                break;
1880            case R.id.settings:
1881                Utils.showSettings(this, mAccount);
1882                break;
1883            case android.R.id.home:
1884                onAppUpPressed();
1885                break;
1886            case R.id.help_info_menu_item:
1887                Utils.showHelp(this, mAccount, getString(R.string.compose_help_context));
1888                break;
1889            case R.id.feedback_menu_item:
1890                Utils.sendFeedback(this, mAccount, false);
1891                break;
1892            default:
1893                handled = false;
1894                break;
1895        }
1896        return !handled ? super.onOptionsItemSelected(item) : handled;
1897    }
1898
1899    @Override
1900    public void onBackPressed() {
1901        // If we are showing the wait fragment, just exit.
1902        if (getWaitFragment() != null) {
1903            finish();
1904        } else {
1905            super.onBackPressed();
1906        }
1907    }
1908
1909    /**
1910     * Carries out the "up" action in the action bar.
1911     */
1912    private void onAppUpPressed() {
1913        if (mLaunchedFromEmail) {
1914            // If this was started from Gmail, simply treat app up as the system back button, so
1915            // that the last view is restored.
1916            onBackPressed();
1917            return;
1918        }
1919
1920        // Fire the main activity to ensure it launches the "top" screen of mail.
1921        // Since the main Activity is singleTask, it should revive that task if it was already
1922        // started.
1923        final Intent mailIntent = Utils.createViewInboxIntent(mAccount);
1924        mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
1925                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
1926        startActivity(mailIntent);
1927        finish();
1928    }
1929
1930    private void doSend() {
1931        sendOrSaveWithSanityChecks(false, true, false, false);
1932    }
1933
1934    private void doSave(boolean showToast) {
1935        sendOrSaveWithSanityChecks(true, showToast, false, false);
1936    }
1937
1938    @VisibleForTesting
1939    public interface SendOrSaveCallback {
1940        public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask);
1941        public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
1942        public Message getMessage();
1943        public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success);
1944    }
1945
1946    @VisibleForTesting
1947    public static class SendOrSaveTask implements Runnable {
1948        private final Context mContext;
1949        @VisibleForTesting
1950        public final SendOrSaveCallback mSendOrSaveCallback;
1951        @VisibleForTesting
1952        public final SendOrSaveMessage mSendOrSaveMessage;
1953        private ReplyFromAccount mExistingDraftAccount;
1954
1955        public SendOrSaveTask(Context context, SendOrSaveMessage message,
1956                SendOrSaveCallback callback, ReplyFromAccount draftAccount) {
1957            mContext = context;
1958            mSendOrSaveCallback = callback;
1959            mSendOrSaveMessage = message;
1960            mExistingDraftAccount = draftAccount;
1961        }
1962
1963        @Override
1964        public void run() {
1965            final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage;
1966
1967            final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount;
1968            Message message = mSendOrSaveCallback.getMessage();
1969            long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID;
1970            // If a previous draft has been saved, in an account that is different
1971            // than what the user wants to send from, remove the old draft, and treat this
1972            // as a new message
1973            if (mExistingDraftAccount != null
1974                    && !selectedAccount.account.uri.equals(mExistingDraftAccount.account.uri)) {
1975                if (messageId != UIProvider.INVALID_MESSAGE_ID) {
1976                    ContentResolver resolver = mContext.getContentResolver();
1977                    ContentValues values = new ContentValues();
1978                    values.put(BaseColumns._ID, messageId);
1979                    if (mExistingDraftAccount.account.expungeMessageUri != null) {
1980                        new ContentProviderTask.UpdateTask()
1981                                .run(resolver, mExistingDraftAccount.account.expungeMessageUri,
1982                                        values, null, null);
1983                    } else {
1984                        // TODO(mindyp) delete the conversation.
1985                    }
1986                    // reset messageId to 0, so a new message will be created
1987                    messageId = UIProvider.INVALID_MESSAGE_ID;
1988                }
1989            }
1990
1991            final long messageIdToSave = messageId;
1992            sendOrSaveMessage(messageIdToSave, sendOrSaveMessage, selectedAccount);
1993
1994            if (!sendOrSaveMessage.mSave) {
1995                UIProvider.incrementRecipientsTimesContacted(mContext,
1996                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO));
1997                UIProvider.incrementRecipientsTimesContacted(mContext,
1998                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC));
1999                UIProvider.incrementRecipientsTimesContacted(mContext,
2000                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
2001            }
2002            mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
2003        }
2004
2005        /**
2006         * Send or Save a message.
2007         */
2008        private void sendOrSaveMessage(final long messageIdToSave,
2009                final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) {
2010            final ContentResolver resolver = mContext.getContentResolver();
2011            final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID;
2012
2013            final String accountMethod = sendOrSaveMessage.mSave ?
2014                    UIProvider.AccountCallMethods.SAVE_MESSAGE :
2015                    UIProvider.AccountCallMethods.SEND_MESSAGE;
2016
2017            try {
2018                if (updateExistingMessage) {
2019                    sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
2020
2021                    callAccountSendSaveMethod(resolver,
2022                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2023                } else {
2024                    Uri messageUri = null;
2025                    final Bundle result = callAccountSendSaveMethod(resolver,
2026                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2027                    if (result != null) {
2028                        // If a non-null value was returned, then the provider handled the call
2029                        // method
2030                        messageUri = result.getParcelable(UIProvider.MessageColumns.URI);
2031                    }
2032                    if (sendOrSaveMessage.mSave && messageUri != null) {
2033                        final Cursor messageCursor = resolver.query(messageUri,
2034                                UIProvider.MESSAGE_PROJECTION, null, null, null);
2035                        if (messageCursor != null) {
2036                            try {
2037                                if (messageCursor.moveToFirst()) {
2038                                    // Broadcast notification that a new message has
2039                                    // been allocated
2040                                    mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
2041                                            new Message(messageCursor));
2042                                }
2043                            } finally {
2044                                messageCursor.close();
2045                            }
2046                        }
2047                    }
2048                }
2049            } finally {
2050                // Close any opened file descriptors
2051                closeOpenedAttachmentFds(sendOrSaveMessage);
2052            }
2053        }
2054
2055        private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) {
2056            final Bundle openedFds = sendOrSaveMessage.attachmentFds();
2057            if (openedFds != null) {
2058                final Set<String> keys = openedFds.keySet();
2059                for (final String key : keys) {
2060                    final ParcelFileDescriptor fd = openedFds.getParcelable(key);
2061                    if (fd != null) {
2062                        try {
2063                            fd.close();
2064                        } catch (IOException e) {
2065                            // Do nothing
2066                        }
2067                    }
2068                }
2069            }
2070        }
2071
2072        /**
2073         * Use the {@link ContentResolver#call()} method to send or save the message.
2074         *
2075         * If this was successful, this method will return an non-null Bundle instance
2076         */
2077        private static Bundle callAccountSendSaveMethod(final ContentResolver resolver,
2078                final Account account, final String method,
2079                final SendOrSaveMessage sendOrSaveMessage) {
2080            // Copy all of the values from the content values to the bundle
2081            final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size());
2082            final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet();
2083
2084            for (Entry<String, Object> entry : valueSet) {
2085                final Object entryValue = entry.getValue();
2086                final String key = entry.getKey();
2087                if (entryValue instanceof String) {
2088                    methodExtras.putString(key, (String)entryValue);
2089                } else if (entryValue instanceof Boolean) {
2090                    methodExtras.putBoolean(key, (Boolean)entryValue);
2091                } else if (entryValue instanceof Integer) {
2092                    methodExtras.putInt(key, (Integer)entryValue);
2093                } else if (entryValue instanceof Long) {
2094                    methodExtras.putLong(key, (Long)entryValue);
2095                } else {
2096                    LogUtils.wtf(LOG_TAG, "Unexpected object type: %s",
2097                            entryValue.getClass().getName());
2098                }
2099            }
2100
2101            // If the SendOrSaveMessage has some opened fds, add them to the bundle
2102            final Bundle fdMap = sendOrSaveMessage.attachmentFds();
2103            if (fdMap != null) {
2104                methodExtras.putParcelable(
2105                        UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap);
2106            }
2107
2108            return resolver.call(account.uri, method, account.uri.toString(), methodExtras);
2109        }
2110    }
2111
2112    @VisibleForTesting
2113    public static class SendOrSaveMessage {
2114        final ReplyFromAccount mAccount;
2115        final ContentValues mValues;
2116        final String mRefMessageId;
2117        @VisibleForTesting
2118        public final boolean mSave;
2119        final int mRequestId;
2120        private final Bundle mAttachmentFds;
2121
2122        public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values,
2123                String refMessageId, List<Attachment> attachments, boolean save) {
2124            mAccount = account;
2125            mValues = values;
2126            mRefMessageId = refMessageId;
2127            mSave = save;
2128            mRequestId = mValues.hashCode() ^ hashCode();
2129
2130            mAttachmentFds = initializeAttachmentFds(context, attachments);
2131        }
2132
2133        int requestId() {
2134            return mRequestId;
2135        }
2136
2137        Bundle attachmentFds() {
2138            return mAttachmentFds;
2139        }
2140
2141        /**
2142         * Opens {@link ParcelFileDescriptor} for each of the attachments.  This method must be
2143         * called before the ComposeActivity finishes.
2144         * Note: The caller is responsible for closing these file descriptors.
2145         */
2146        private static Bundle initializeAttachmentFds(final Context context,
2147                final List<Attachment> attachments) {
2148            if (attachments == null || attachments.size() == 0) {
2149                return null;
2150            }
2151
2152            final Bundle result = new Bundle(attachments.size());
2153            final ContentResolver resolver = context.getContentResolver();
2154
2155            for (Attachment attachment : attachments) {
2156                if (attachment == null || Utils.isEmpty(attachment.contentUri)) {
2157                    continue;
2158                }
2159
2160                ParcelFileDescriptor fileDescriptor;
2161                try {
2162                    fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r");
2163                } catch (FileNotFoundException e) {
2164                    LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment");
2165                    fileDescriptor = null;
2166                } catch (SecurityException e) {
2167                    // We have encountered a security exception when attempting to open the file
2168                    // specified by the content uri.  If the attachment has been cached, this
2169                    // isn't a problem, as even through the original permission may have been
2170                    // revoked, we have cached the file.  This will happen when saving/sending
2171                    // a previously saved draft.
2172                    // TODO(markwei): Expose whether the attachment has been cached through the
2173                    // attachment object.  This would allow us to limit when the log is made, as
2174                    // if the attachment has been cached, this really isn't an error
2175                    LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment");
2176                    // Just set the file descriptor to null, as the underlying provider needs
2177                    // to handle the file descriptor not being set.
2178                    fileDescriptor = null;
2179                }
2180
2181                if (fileDescriptor != null) {
2182                    result.putParcelable(attachment.contentUri.toString(), fileDescriptor);
2183                }
2184            }
2185
2186            return result;
2187        }
2188    }
2189
2190    /**
2191     * Get the to recipients.
2192     */
2193    public String[] getToAddresses() {
2194        return getAddressesFromList(mTo);
2195    }
2196
2197    /**
2198     * Get the cc recipients.
2199     */
2200    public String[] getCcAddresses() {
2201        return getAddressesFromList(mCc);
2202    }
2203
2204    /**
2205     * Get the bcc recipients.
2206     */
2207    public String[] getBccAddresses() {
2208        return getAddressesFromList(mBcc);
2209    }
2210
2211    public String[] getAddressesFromList(RecipientEditTextView list) {
2212        if (list == null) {
2213            return new String[0];
2214        }
2215        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
2216        int count = tokens.length;
2217        String[] result = new String[count];
2218        for (int i = 0; i < count; i++) {
2219            result[i] = tokens[i].toString();
2220        }
2221        return result;
2222    }
2223
2224    /**
2225     * Check for invalid email addresses.
2226     * @param to String array of email addresses to check.
2227     * @param wrongEmailsOut Emails addresses that were invalid.
2228     */
2229    public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) {
2230        if (mValidator == null) {
2231            return;
2232        }
2233        for (final String email : to) {
2234            if (!mValidator.isValid(email)) {
2235                wrongEmailsOut.add(email);
2236            }
2237        }
2238    }
2239
2240    public static class RecipientErrorDialogFragment extends DialogFragment {
2241        public static RecipientErrorDialogFragment newInstance(final String message) {
2242            final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment();
2243            final Bundle args = new Bundle(1);
2244            args.putString("message", message);
2245            frag.setArguments(args);
2246            return frag;
2247        }
2248
2249        @Override
2250        public Dialog onCreateDialog(Bundle savedInstanceState) {
2251            final String message = getArguments().getString("message");
2252            return new AlertDialog.Builder(getActivity()).setMessage(message).setTitle(
2253                    R.string.recipient_error_dialog_title)
2254                    .setIconAttribute(android.R.attr.alertDialogIcon)
2255                    .setPositiveButton(
2256                            R.string.ok, new Dialog.OnClickListener() {
2257                        @Override
2258                        public void onClick(DialogInterface dialog, int which) {
2259                            ((ComposeActivity)getActivity()).finishRecipientErrorDialog();
2260                        }
2261                    }).create();
2262        }
2263    }
2264
2265    private void finishRecipientErrorDialog() {
2266        // after the user dismisses the recipient error
2267        // dialog we want to make sure to refocus the
2268        // recipient to field so they can fix the issue
2269        // easily
2270        if (mTo != null) {
2271            mTo.requestFocus();
2272        }
2273    }
2274
2275    /**
2276     * Show an error because the user has entered an invalid recipient.
2277     * @param message
2278     */
2279    private void showRecipientErrorDialog(final String message) {
2280        final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message);
2281        frag.show(getFragmentManager(), "recipient error");
2282    }
2283
2284    /**
2285     * Update the state of the UI based on whether or not the current draft
2286     * needs to be saved and the message is not empty.
2287     */
2288    public void updateSaveUi() {
2289        if (mSave != null) {
2290            mSave.setEnabled((shouldSave() && !isBlank()));
2291        }
2292    }
2293
2294    /**
2295     * Returns true if we need to save the current draft.
2296     */
2297    private boolean shouldSave() {
2298        synchronized (mDraftLock) {
2299            // The message should only be saved if:
2300            // It hasn't been sent AND
2301            // Some text has been added to the message OR
2302            // an attachment has been added or removed
2303            // AND there is actually something in the draft to save.
2304            return (mTextChanged || mAttachmentsChanged || mReplyFromChanged)
2305                    && !isBlank();
2306        }
2307    }
2308
2309    /**
2310     * Check if all fields are blank.
2311     * @return boolean
2312     */
2313    public boolean isBlank() {
2314        return mSubject.getText().length() == 0
2315                && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
2316                        mBodyView.getText().toString()) == 0)
2317                && mTo.length() == 0
2318                && mCc.length() == 0 && mBcc.length() == 0
2319                && mAttachmentsView.getAttachments().size() == 0;
2320    }
2321
2322    @VisibleForTesting
2323    protected int getSignatureStartPosition(String signature, String bodyText) {
2324        int startPos = -1;
2325
2326        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
2327            return startPos;
2328        }
2329
2330        int bodyLength = bodyText.length();
2331        int signatureLength = signature.length();
2332        String printableVersion = convertToPrintableSignature(signature);
2333        int printableLength = printableVersion.length();
2334
2335        if (bodyLength >= printableLength
2336                && bodyText.substring(bodyLength - printableLength)
2337                .equals(printableVersion)) {
2338            startPos = bodyLength - printableLength;
2339        } else if (bodyLength >= signatureLength
2340                && bodyText.substring(bodyLength - signatureLength)
2341                .equals(signature)) {
2342            startPos = bodyLength - signatureLength;
2343        }
2344        return startPos;
2345    }
2346
2347    /**
2348     * Allows any changes made by the user to be ignored. Called when the user
2349     * decides to discard a draft.
2350     */
2351    private void discardChanges() {
2352        mTextChanged = false;
2353        mAttachmentsChanged = false;
2354        mReplyFromChanged = false;
2355    }
2356
2357    /**
2358     * @param save
2359     * @param showToast
2360     * @return Whether the send or save succeeded.
2361     */
2362    protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
2363            final boolean orientationChanged, final boolean autoSend) {
2364        if (mAccounts == null || mAccount == null) {
2365            Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show();
2366            if (autoSend) {
2367                finish();
2368            }
2369            return false;
2370        }
2371
2372        final String[] to, cc, bcc;
2373        if (orientationChanged) {
2374            to = cc = bcc = new String[0];
2375        } else {
2376            to = getToAddresses();
2377            cc = getCcAddresses();
2378            bcc = getBccAddresses();
2379        }
2380
2381        // Don't let the user send to nobody (but it's okay to save a message
2382        // with no recipients)
2383        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
2384            showRecipientErrorDialog(getString(R.string.recipient_needed));
2385            return false;
2386        }
2387
2388        List<String> wrongEmails = new ArrayList<String>();
2389        if (!save) {
2390            checkInvalidEmails(to, wrongEmails);
2391            checkInvalidEmails(cc, wrongEmails);
2392            checkInvalidEmails(bcc, wrongEmails);
2393        }
2394
2395        // Don't let the user send an email with invalid recipients
2396        if (wrongEmails.size() > 0) {
2397            String errorText = String.format(getString(R.string.invalid_recipient),
2398                    wrongEmails.get(0));
2399            showRecipientErrorDialog(errorText);
2400            return false;
2401        }
2402
2403        // Show a warning before sending only if there are no attachments.
2404        if (!save) {
2405            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
2406                boolean warnAboutEmptySubject = isSubjectEmpty();
2407                boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0;
2408
2409                // A warning about an empty body may not be warranted when
2410                // forwarding mails, since a common use case is to forward
2411                // quoted text and not append any more text.
2412                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
2413
2414                // When we bring up a dialog warning the user about a send,
2415                // assume that they accept sending the message. If they do not,
2416                // the dialog listener is required to enable sending again.
2417                if (warnAboutEmptySubject) {
2418                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, save,
2419                            showToast);
2420                    return true;
2421                }
2422
2423                if (warnAboutEmptyBody) {
2424                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body, save,
2425                            showToast);
2426                    return true;
2427                }
2428            }
2429            // Ask for confirmation to send (if always required)
2430            if (showSendConfirmation()) {
2431                showSendConfirmDialog(R.string.confirm_send_message, save, showToast);
2432                return true;
2433            }
2434        }
2435
2436        sendOrSave(save, showToast);
2437        return true;
2438    }
2439
2440    /**
2441     * Returns a boolean indicating whether warnings should be shown for empty
2442     * subject and body fields
2443     *
2444     * @return True if a warning should be shown for empty text fields
2445     */
2446    protected boolean showEmptyTextWarnings() {
2447        return mAttachmentsView.getAttachments().size() == 0;
2448    }
2449
2450    /**
2451     * Returns a boolean indicating whether the user should confirm each send
2452     *
2453     * @return True if a warning should be on each send
2454     */
2455    protected boolean showSendConfirmation() {
2456        return mCachedSettings != null ? mCachedSettings.confirmSend : false;
2457    }
2458
2459    public static class SendConfirmDialogFragment extends DialogFragment {
2460        public static SendConfirmDialogFragment newInstance(final int messageId,
2461                final boolean save, final boolean showToast) {
2462            final SendConfirmDialogFragment frag = new SendConfirmDialogFragment();
2463            final Bundle args = new Bundle(3);
2464            args.putInt("messageId", messageId);
2465            args.putBoolean("save", save);
2466            args.putBoolean("showToast", showToast);
2467            frag.setArguments(args);
2468            return frag;
2469        }
2470
2471        @Override
2472        public Dialog onCreateDialog(Bundle savedInstanceState) {
2473            final int messageId = getArguments().getInt("messageId");
2474            final boolean save = getArguments().getBoolean("save");
2475            final boolean showToast = getArguments().getBoolean("showToast");
2476
2477            return new AlertDialog.Builder(getActivity())
2478                    .setMessage(messageId)
2479                    .setTitle(R.string.confirm_send_title)
2480                    .setIconAttribute(android.R.attr.alertDialogIcon)
2481                    .setPositiveButton(R.string.send,
2482                            new DialogInterface.OnClickListener() {
2483                                public void onClick(DialogInterface dialog, int whichButton) {
2484                                    ((ComposeActivity)getActivity()).finishSendConfirmDialog(save,
2485                                            showToast);
2486                                }
2487                            })
2488                    .create();
2489        }
2490    }
2491
2492    private void finishSendConfirmDialog(final boolean save, final boolean showToast) {
2493        sendOrSave(save, showToast);
2494    }
2495
2496    private void showSendConfirmDialog(final int messageId, final boolean save,
2497            final boolean showToast) {
2498        final DialogFragment frag = SendConfirmDialogFragment.newInstance(messageId, save,
2499                showToast);
2500        frag.show(getFragmentManager(), "send confirm");
2501    }
2502
2503    /**
2504     * Returns whether the ComposeArea believes there is any text in the body of
2505     * the composition. TODO: When ComposeArea controls the Body as well, add
2506     * that here.
2507     */
2508    public boolean isBodyEmpty() {
2509        return !mQuotedTextView.isTextIncluded();
2510    }
2511
2512    /**
2513     * Test to see if the subject is empty.
2514     *
2515     * @return boolean.
2516     */
2517    // TODO: this will likely go away when composeArea.focus() is implemented
2518    // after all the widget control is moved over.
2519    public boolean isSubjectEmpty() {
2520        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
2521    }
2522
2523    /* package */
2524    static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount,
2525            Message message, final Message refMessage, Spanned body, final CharSequence quotedText,
2526            SendOrSaveCallback callback, Handler handler, boolean save, int composeMode,
2527            ReplyFromAccount draftAccount) {
2528        final ContentValues values = new ContentValues();
2529
2530        final String refMessageId = refMessage != null ? refMessage.uri.toString() : "";
2531
2532        MessageModification.putToAddresses(values, message.getToAddresses());
2533        MessageModification.putCcAddresses(values, message.getCcAddresses());
2534        MessageModification.putBccAddresses(values, message.getBccAddresses());
2535
2536        MessageModification.putCustomFromAddress(values, message.getFrom());
2537
2538        MessageModification.putSubject(values, message.subject);
2539        // Make sure to remove only the composing spans from the Spannable before saving.
2540        final String htmlBody = Html.toHtml(removeComposingSpans(body));
2541
2542        boolean includeQuotedText = !TextUtils.isEmpty(quotedText);
2543        StringBuilder fullBody = new StringBuilder(htmlBody);
2544        if (includeQuotedText) {
2545            // HTML gets converted to text for now
2546            final String text = quotedText.toString();
2547            if (QuotedTextView.containsQuotedText(text)) {
2548                int pos = QuotedTextView.getQuotedTextOffset(text);
2549                final int quoteStartPos = fullBody.length() + pos;
2550                fullBody.append(text);
2551                MessageModification.putQuoteStartPos(values, quoteStartPos);
2552                MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
2553                MessageModification.putAppendRefMessageContent(values, includeQuotedText);
2554            } else {
2555                LogUtils.w(LOG_TAG, "Couldn't find quoted text");
2556                // This shouldn't happen, but just use what we have,
2557                // and don't do server-side expansion
2558                fullBody.append(text);
2559            }
2560        }
2561        int draftType = getDraftType(composeMode);
2562        MessageModification.putDraftType(values, draftType);
2563        if (refMessage != null) {
2564            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
2565                MessageModification.putBodyHtml(values, fullBody.toString());
2566            }
2567            if (!TextUtils.isEmpty(refMessage.bodyText)) {
2568                MessageModification.putBody(values,
2569                        Utils.convertHtmlToPlainText(fullBody.toString()).toString());
2570            }
2571        } else {
2572            MessageModification.putBodyHtml(values, fullBody.toString());
2573            MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString())
2574                    .toString());
2575        }
2576        MessageModification.putAttachments(values, message.getAttachments());
2577        if (!TextUtils.isEmpty(refMessageId)) {
2578            MessageModification.putRefMessageId(values, refMessageId);
2579        }
2580        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount,
2581                values, refMessageId, message.getAttachments(), save);
2582        SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback,
2583                draftAccount);
2584
2585        callback.initializeSendOrSave(sendOrSaveTask);
2586        // Do the send/save action on the specified handler to avoid possible
2587        // ANRs
2588        handler.post(sendOrSaveTask);
2589
2590        return sendOrSaveMessage.requestId();
2591    }
2592
2593    /**
2594     * Removes any composing spans from the specified string.  This will create a new
2595     * SpannableString instance, as to not modify the behavior of the EditText view.
2596     */
2597    private static SpannableString removeComposingSpans(Spanned body) {
2598        final SpannableString messageBody = new SpannableString(body);
2599        BaseInputConnection.removeComposingSpans(messageBody);
2600        return messageBody;
2601    }
2602
2603    private static int getDraftType(int mode) {
2604        int draftType = -1;
2605        switch (mode) {
2606            case ComposeActivity.COMPOSE:
2607                draftType = DraftType.COMPOSE;
2608                break;
2609            case ComposeActivity.REPLY:
2610                draftType = DraftType.REPLY;
2611                break;
2612            case ComposeActivity.REPLY_ALL:
2613                draftType = DraftType.REPLY_ALL;
2614                break;
2615            case ComposeActivity.FORWARD:
2616                draftType = DraftType.FORWARD;
2617                break;
2618        }
2619        return draftType;
2620    }
2621
2622    private void sendOrSave(final boolean save, final boolean showToast) {
2623        // Check if user is a monkey. Monkeys can compose and hit send
2624        // button but are not allowed to send anything off the device.
2625        if (ActivityManager.isUserAMonkey()) {
2626            return;
2627        }
2628
2629        final Spanned body = mBodyView.getEditableText();
2630
2631        SendOrSaveCallback callback = new SendOrSaveCallback() {
2632            // FIXME: unused
2633            private int mRestoredRequestId;
2634
2635            @Override
2636            public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
2637                synchronized (mActiveTasks) {
2638                    int numTasks = mActiveTasks.size();
2639                    if (numTasks == 0) {
2640                        // Start service so we won't be killed if this app is
2641                        // put in the background.
2642                        startService(new Intent(ComposeActivity.this, EmptyService.class));
2643                    }
2644
2645                    mActiveTasks.add(sendOrSaveTask);
2646                }
2647                if (sTestSendOrSaveCallback != null) {
2648                    sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
2649                }
2650            }
2651
2652            @Override
2653            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
2654                    Message message) {
2655                synchronized (mDraftLock) {
2656                    mDraftAccount = sendOrSaveMessage.mAccount;
2657                    mDraftId = message.id;
2658                    mDraft = message;
2659                    if (sRequestMessageIdMap != null) {
2660                        sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
2661                    }
2662                    // Cache request message map, in case the process is killed
2663                    saveRequestMap();
2664                }
2665                if (sTestSendOrSaveCallback != null) {
2666                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
2667                }
2668            }
2669
2670            @Override
2671            public Message getMessage() {
2672                synchronized (mDraftLock) {
2673                    return mDraft;
2674                }
2675            }
2676
2677            @Override
2678            public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
2679                // Update the last sent from account.
2680                if (mAccount != null) {
2681                    MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString());
2682                }
2683                if (success) {
2684                    // Successfully sent or saved so reset change markers
2685                    discardChanges();
2686                } else {
2687                    // A failure happened with saving/sending the draft
2688                    // TODO(pwestbro): add a better string that should be used
2689                    // when failing to send or save
2690                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
2691                            .show();
2692                }
2693
2694                int numTasks;
2695                synchronized (mActiveTasks) {
2696                    // Remove the task from the list of active tasks
2697                    mActiveTasks.remove(task);
2698                    numTasks = mActiveTasks.size();
2699                }
2700
2701                if (numTasks == 0) {
2702                    // Stop service so we can be killed.
2703                    stopService(new Intent(ComposeActivity.this, EmptyService.class));
2704                }
2705                if (sTestSendOrSaveCallback != null) {
2706                    sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
2707                }
2708            }
2709        };
2710
2711        // Get the selected account if the from spinner has been setup.
2712        ReplyFromAccount selectedAccount = mReplyFromAccount;
2713        String fromAddress = selectedAccount.name;
2714        if (selectedAccount == null || fromAddress == null) {
2715            // We don't have either the selected account or from address,
2716            // use mAccount.
2717            selectedAccount = mReplyFromAccount;
2718            fromAddress = mAccount.name;
2719        }
2720
2721        if (mSendSaveTaskHandler == null) {
2722            HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
2723            handlerThread.start();
2724
2725            mSendSaveTaskHandler = new Handler(handlerThread.getLooper());
2726        }
2727
2728        Message msg = createMessage(mReplyFromAccount, getMode());
2729        mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body,
2730                mQuotedTextView.getQuotedTextIfIncluded(), callback,
2731                mSendSaveTaskHandler, save, mComposeMode, mDraftAccount);
2732
2733        if (mRecipient != null && mRecipient.equals(mAccount.name)) {
2734            mRecipient = selectedAccount.name;
2735        }
2736        setAccount(selectedAccount.account);
2737
2738        // Don't display the toast if the user is just changing the orientation,
2739        // but we still need to save the draft to the cursor because this is how we restore
2740        // the attachments when the configuration change completes.
2741        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
2742            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
2743                    Toast.LENGTH_LONG).show();
2744        }
2745
2746        // Need to update variables here because the send or save completes
2747        // asynchronously even though the toast shows right away.
2748        discardChanges();
2749        updateSaveUi();
2750
2751        // If we are sending, finish the activity
2752        if (!save) {
2753            finish();
2754        }
2755    }
2756
2757    /**
2758     * Save the state of the request messageid map. This allows for the Gmail
2759     * process to be killed, but and still allow for ComposeActivity instances
2760     * to be recreated correctly.
2761     */
2762    private void saveRequestMap() {
2763        // TODO: store the request map in user preferences.
2764    }
2765
2766    private void doAttach(String type) {
2767        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
2768        i.addCategory(Intent.CATEGORY_OPENABLE);
2769        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
2770        i.setType(type);
2771        mAddingAttachment = true;
2772        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
2773                RESULT_PICK_ATTACHMENT);
2774    }
2775
2776    private void showCcBccViews() {
2777        mCcBccView.show(true, true, true);
2778        if (mCcBccButton != null) {
2779            mCcBccButton.setVisibility(View.INVISIBLE);
2780        }
2781    }
2782
2783    @Override
2784    public boolean onNavigationItemSelected(int position, long itemId) {
2785        int initialComposeMode = mComposeMode;
2786        if (position == ComposeActivity.REPLY) {
2787            mComposeMode = ComposeActivity.REPLY;
2788        } else if (position == ComposeActivity.REPLY_ALL) {
2789            mComposeMode = ComposeActivity.REPLY_ALL;
2790        } else if (position == ComposeActivity.FORWARD) {
2791            mComposeMode = ComposeActivity.FORWARD;
2792        }
2793        clearChangeListeners();
2794        if (initialComposeMode != mComposeMode) {
2795            resetMessageForModeChange();
2796            if (mRefMessage != null) {
2797                setFieldsFromRefMessage(mComposeMode);
2798            }
2799            boolean showCc = false;
2800            boolean showBcc = false;
2801            if (mDraft != null) {
2802                // Following desktop behavior, if the user has added a BCC
2803                // field to a draft, we show it regardless of compose mode.
2804                showBcc = !TextUtils.isEmpty(mDraft.getBcc());
2805                // Use the draft to determine what to populate.
2806                // If the Bcc field is showing, show the Cc field whether it is populated or not.
2807                showCc = showBcc
2808                        || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL);
2809            }
2810            if (mRefMessage != null) {
2811                showCc = !TextUtils.isEmpty(mCc.getText());
2812                showBcc = !TextUtils.isEmpty(mBcc.getText());
2813            }
2814            mCcBccView.show(false, showCc, showBcc);
2815        }
2816        updateHideOrShowCcBcc();
2817        initChangeListeners();
2818        return true;
2819    }
2820
2821    @VisibleForTesting
2822    protected void resetMessageForModeChange() {
2823        // When switching between reply, reply all, forward,
2824        // follow the behavior of webview.
2825        // The contents of the following fields are cleared
2826        // so that they can be populated directly from the
2827        // ref message:
2828        // 1) Any recipient fields
2829        // 2) The subject
2830        mTo.setText("");
2831        mCc.setText("");
2832        mBcc.setText("");
2833        // Any edits to the subject are replaced with the original subject.
2834        mSubject.setText("");
2835
2836        // Any changes to the contents of the following fields are kept:
2837        // 1) Body
2838        // 2) Attachments
2839        // If the user made changes to attachments, keep their changes.
2840        if (!mAttachmentsChanged) {
2841            mAttachmentsView.deleteAllAttachments();
2842        }
2843    }
2844
2845    private class ComposeModeAdapter extends ArrayAdapter<String> {
2846
2847        private LayoutInflater mInflater;
2848
2849        public ComposeModeAdapter(Context context) {
2850            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
2851                    .getStringArray(R.array.compose_modes));
2852        }
2853
2854        private LayoutInflater getInflater() {
2855            if (mInflater == null) {
2856                mInflater = LayoutInflater.from(getContext());
2857            }
2858            return mInflater;
2859        }
2860
2861        @Override
2862        public View getView(int position, View convertView, ViewGroup parent) {
2863            if (convertView == null) {
2864                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
2865            }
2866            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
2867            return super.getView(position, convertView, parent);
2868        }
2869    }
2870
2871    @Override
2872    public void onRespondInline(String text) {
2873        appendToBody(text, false);
2874        mQuotedTextView.setUpperDividerVisible(false);
2875        mRespondedInline = true;
2876        if (!mBodyView.hasFocus()) {
2877            mBodyView.requestFocus();
2878        }
2879    }
2880
2881    /**
2882     * Append text to the body of the message. If there is no existing body
2883     * text, just sets the body to text.
2884     *
2885     * @param text
2886     * @param withSignature True to append a signature.
2887     */
2888    public void appendToBody(CharSequence text, boolean withSignature) {
2889        Editable bodyText = mBodyView.getEditableText();
2890        if (bodyText != null && bodyText.length() > 0) {
2891            bodyText.append(text);
2892        } else {
2893            setBody(text, withSignature);
2894        }
2895    }
2896
2897    /**
2898     * Set the body of the message.
2899     *
2900     * @param text
2901     * @param withSignature True to append a signature.
2902     */
2903    public void setBody(CharSequence text, boolean withSignature) {
2904        mBodyView.setText(text);
2905        if (withSignature) {
2906            appendSignature();
2907        }
2908    }
2909
2910    private void appendSignature() {
2911        String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
2912        boolean hasFocus = mBodyView.hasFocus();
2913        int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString());
2914        if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) {
2915            mSignature = newSignature;
2916            if (!TextUtils.isEmpty(mSignature)) {
2917                // Appending a signature does not count as changing text.
2918                mBodyView.removeTextChangedListener(this);
2919                mBodyView.append(convertToPrintableSignature(mSignature));
2920                mBodyView.addTextChangedListener(this);
2921            }
2922            if (hasFocus) {
2923                focusBody();
2924            }
2925        }
2926    }
2927
2928    private String convertToPrintableSignature(String signature) {
2929        String signatureResource = getResources().getString(R.string.signature);
2930        if (signature == null) {
2931            signature = "";
2932        }
2933        return String.format(signatureResource, signature);
2934    }
2935
2936    @Override
2937    public void onAccountChanged() {
2938        mReplyFromAccount = mFromSpinner.getCurrentAccount();
2939        if (!mAccount.equals(mReplyFromAccount.account)) {
2940            // Clear a signature, if there was one.
2941            mBodyView.removeTextChangedListener(this);
2942            String oldSignature = mSignature;
2943            String bodyText = getBody().getText().toString();
2944            if (!TextUtils.isEmpty(oldSignature)) {
2945                int pos = getSignatureStartPosition(oldSignature, bodyText);
2946                if (pos > -1) {
2947                    mBodyView.setText(bodyText.substring(0, pos));
2948                }
2949            }
2950            setAccount(mReplyFromAccount.account);
2951            mBodyView.addTextChangedListener(this);
2952            // TODO: handle discarding attachments when switching accounts.
2953            // Only enable save for this draft if there is any other content
2954            // in the message.
2955            if (!isBlank()) {
2956                enableSave(true);
2957            }
2958            mReplyFromChanged = true;
2959            initRecipients();
2960        }
2961    }
2962
2963    public void enableSave(boolean enabled) {
2964        if (mSave != null) {
2965            mSave.setEnabled(enabled);
2966        }
2967    }
2968
2969    public static class DiscardConfirmDialogFragment extends DialogFragment {
2970        @Override
2971        public Dialog onCreateDialog(Bundle savedInstanceState) {
2972            return new AlertDialog.Builder(getActivity())
2973                    .setMessage(R.string.confirm_discard_text)
2974                    .setPositiveButton(R.string.discard,
2975                            new DialogInterface.OnClickListener() {
2976                                @Override
2977                                public void onClick(DialogInterface dialog, int which) {
2978                                    ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation();
2979                                }
2980                            })
2981                    .create();
2982        }
2983    }
2984
2985    private void doDiscard() {
2986        final DialogFragment frag = new DiscardConfirmDialogFragment();
2987        frag.show(getFragmentManager(), "discard confirm");
2988    }
2989    /**
2990     * Effectively discard the current message.
2991     *
2992     * This method is either invoked from the menu or from the dialog
2993     * once the user has confirmed that they want to discard the message.
2994     */
2995    private void doDiscardWithoutConfirmation() {
2996        synchronized (mDraftLock) {
2997            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
2998                ContentValues values = new ContentValues();
2999                values.put(BaseColumns._ID, mDraftId);
3000                if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) {
3001                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
3002                } else {
3003                    getContentResolver().delete(mDraft.uri, null, null);
3004                }
3005                // This is not strictly necessary (since we should not try to
3006                // save the draft after calling this) but it ensures that if we
3007                // do save again for some reason we make a new draft rather than
3008                // trying to resave an expunged draft.
3009                mDraftId = UIProvider.INVALID_MESSAGE_ID;
3010            }
3011        }
3012
3013        // Display a toast to let the user know
3014        Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
3015
3016        // This prevents the draft from being saved in onPause().
3017        discardChanges();
3018        finish();
3019    }
3020
3021    private void saveIfNeeded() {
3022        if (mAccount == null) {
3023            // We have not chosen an account yet so there's no way that we can save. This is ok,
3024            // though, since we are saving our state before AccountsActivity is activated. Thus, the
3025            // user has not interacted with us yet and there is no real state to save.
3026            return;
3027        }
3028
3029        if (shouldSave()) {
3030            doSave(!mAddingAttachment /* show toast */);
3031        }
3032    }
3033
3034    @Override
3035    public void onAttachmentDeleted() {
3036        mAttachmentsChanged = true;
3037        // If we are showing any attachments, make sure we have an upper
3038        // divider.
3039        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3040        updateSaveUi();
3041    }
3042
3043    @Override
3044    public void onAttachmentAdded() {
3045        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3046        mAttachmentsView.focusLastAttachment();
3047    }
3048
3049    /**
3050     * This is called any time one of our text fields changes.
3051     */
3052    @Override
3053    public void afterTextChanged(Editable s) {
3054        mTextChanged = true;
3055        updateSaveUi();
3056    }
3057
3058    @Override
3059    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3060        // Do nothing.
3061    }
3062
3063    @Override
3064    public void onTextChanged(CharSequence s, int start, int before, int count) {
3065        // Do nothing.
3066    }
3067
3068
3069    // There is a big difference between the text associated with an address changing
3070    // to add the display name or to format properly and a recipient being added or deleted.
3071    // Make sure we only notify of changes when a recipient has been added or deleted.
3072    private class RecipientTextWatcher implements TextWatcher {
3073        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
3074
3075        private RecipientEditTextView mView;
3076
3077        private TextWatcher mListener;
3078
3079        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
3080            mView = view;
3081            mListener = listener;
3082        }
3083
3084        @Override
3085        public void afterTextChanged(Editable s) {
3086            if (hasChanged()) {
3087                mListener.afterTextChanged(s);
3088            }
3089        }
3090
3091        private boolean hasChanged() {
3092            String[] currRecips = tokenizeRecips(getAddressesFromList(mView));
3093            int totalCount = currRecips.length;
3094            int totalPrevCount = 0;
3095            for (Entry<String, Integer> entry : mContent.entrySet()) {
3096                totalPrevCount += entry.getValue();
3097            }
3098            if (totalCount != totalPrevCount) {
3099                return true;
3100            }
3101
3102            for (String recip : currRecips) {
3103                if (!mContent.containsKey(recip)) {
3104                    return true;
3105                } else {
3106                    int count = mContent.get(recip) - 1;
3107                    if (count < 0) {
3108                        return true;
3109                    } else {
3110                        mContent.put(recip, count);
3111                    }
3112                }
3113            }
3114            return false;
3115        }
3116
3117        private String[] tokenizeRecips(String[] recips) {
3118            // Tokenize them all and put them in the list.
3119            String[] recipAddresses = new String[recips.length];
3120            for (int i = 0; i < recips.length; i++) {
3121                recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress();
3122            }
3123            return recipAddresses;
3124        }
3125
3126        @Override
3127        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3128            String[] recips = tokenizeRecips(getAddressesFromList(mView));
3129            for (String recip : recips) {
3130                if (!mContent.containsKey(recip)) {
3131                    mContent.put(recip, 1);
3132                } else {
3133                    mContent.put(recip, (mContent.get(recip)) + 1);
3134                }
3135            }
3136        }
3137
3138        @Override
3139        public void onTextChanged(CharSequence s, int start, int before, int count) {
3140            // Do nothing.
3141        }
3142    }
3143
3144    public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) {
3145        if (sTestSendOrSaveCallback != null && testCallback != null) {
3146            throw new IllegalStateException("Attempting to register more than one test callback");
3147        }
3148        sTestSendOrSaveCallback = testCallback;
3149    }
3150
3151    @VisibleForTesting
3152    protected ArrayList<Attachment> getAttachments() {
3153        return mAttachmentsView.getAttachments();
3154    }
3155
3156    @Override
3157    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
3158        switch (id) {
3159            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3160                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3161                        null, null);
3162            case REFERENCE_MESSAGE_LOADER:
3163                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3164                        null, null);
3165            case LOADER_ACCOUNT_CURSOR:
3166                return new CursorLoader(this, MailAppProvider.getAccountsUri(),
3167                        UIProvider.ACCOUNTS_PROJECTION, null, null, null);
3168        }
3169        return null;
3170    }
3171
3172    @Override
3173    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
3174        int id = loader.getId();
3175        switch (id) {
3176            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3177                if (data != null && data.moveToFirst()) {
3178                    mRefMessage = new Message(data);
3179                    Intent intent = getIntent();
3180                    initFromRefMessage(mComposeMode);
3181                    finishSetup(mComposeMode, intent, null);
3182                    if (mComposeMode != FORWARD) {
3183                        String to = intent.getStringExtra(EXTRA_TO);
3184                        if (!TextUtils.isEmpty(to)) {
3185                            mRefMessage.setTo(null);
3186                            mRefMessage.setFrom(null);
3187                            clearChangeListeners();
3188                            mTo.append(to);
3189                            initChangeListeners();
3190                        }
3191                    }
3192                } else {
3193                    finish();
3194                }
3195                break;
3196            case REFERENCE_MESSAGE_LOADER:
3197                // Only populate mRefMessage and leave other fields untouched.
3198                if (data != null && data.moveToFirst()) {
3199                    mRefMessage = new Message(data);
3200                }
3201                finishSetup(mComposeMode, getIntent(), mInnerSavedState);
3202                break;
3203            case LOADER_ACCOUNT_CURSOR:
3204                if (data != null && data.moveToFirst()) {
3205                    // there are accounts now!
3206                    Account account;
3207                    final ArrayList<Account> accounts = new ArrayList<Account>();
3208                    final ArrayList<Account> initializedAccounts = new ArrayList<Account>();
3209                    do {
3210                        account = new Account(data);
3211                        if (account.isAccountReady()) {
3212                            initializedAccounts.add(account);
3213                        }
3214                        accounts.add(account);
3215                    } while (data.moveToNext());
3216                    if (initializedAccounts.size() > 0) {
3217                        findViewById(R.id.wait).setVisibility(View.GONE);
3218                        getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR);
3219                        findViewById(R.id.compose).setVisibility(View.VISIBLE);
3220                        mAccounts = initializedAccounts.toArray(
3221                                new Account[initializedAccounts.size()]);
3222
3223                        finishCreate();
3224                        invalidateOptionsMenu();
3225                    } else {
3226                        // Show "waiting"
3227                        account = accounts.size() > 0 ? accounts.get(0) : null;
3228                        showWaitFragment(account);
3229                    }
3230                }
3231                break;
3232        }
3233    }
3234
3235    private void showWaitFragment(Account account) {
3236        WaitFragment fragment = getWaitFragment();
3237        if (fragment != null) {
3238            fragment.updateAccount(account);
3239        } else {
3240            findViewById(R.id.wait).setVisibility(View.VISIBLE);
3241            replaceFragment(WaitFragment.newInstance(account, true),
3242                    FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT);
3243        }
3244    }
3245
3246    private WaitFragment getWaitFragment() {
3247        return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT);
3248    }
3249
3250    private int replaceFragment(Fragment fragment, int transition, String tag) {
3251        FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction();
3252        fragmentTransaction.setTransition(transition);
3253        fragmentTransaction.replace(R.id.wait, fragment, tag);
3254        final int transactionId = fragmentTransaction.commitAllowingStateLoss();
3255        return transactionId;
3256    }
3257
3258    @Override
3259    public void onLoaderReset(Loader<Cursor> arg0) {
3260        // Do nothing.
3261    }
3262
3263    @Override
3264    public Context getActivityContext() {
3265        return this;
3266    }
3267}
3268