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