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