ComposeActivity.java revision bdf7a40583381ae26d11dcd0fca40bca230f96f3
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.provider.BaseColumns;
40import android.text.Editable;
41import android.text.Html;
42import android.text.Spanned;
43import android.text.TextUtils;
44import android.text.TextWatcher;
45import android.text.util.Rfc822Token;
46import android.text.util.Rfc822Tokenizer;
47import android.view.LayoutInflater;
48import android.view.Menu;
49import android.view.MenuInflater;
50import android.view.MenuItem;
51import android.view.View;
52import android.view.ViewGroup;
53import android.view.View.OnClickListener;
54import android.view.inputmethod.BaseInputConnection;
55import android.widget.ArrayAdapter;
56import android.widget.Button;
57import android.widget.TextView;
58import android.widget.Toast;
59
60import com.android.common.Rfc822Validator;
61import com.android.mail.compose.AttachmentsView.AttachmentDeletedListener;
62import com.android.mail.compose.AttachmentsView.AttachmentFailureException;
63import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener;
64import com.android.mail.compose.QuotedTextView.RespondInlineListener;
65import com.android.mail.providers.Account;
66import com.android.mail.providers.Address;
67import com.android.mail.providers.Attachment;
68import com.android.mail.providers.Message;
69import com.android.mail.providers.MessageModification;
70import com.android.mail.providers.Settings;
71import com.android.mail.providers.UIProvider;
72import com.android.mail.providers.UIProvider.MessageColumns;
73import com.android.mail.R;
74import com.android.mail.utils.LogUtils;
75import com.android.mail.utils.Utils;
76import com.android.ex.chips.RecipientEditTextView;
77import com.google.common.annotations.VisibleForTesting;
78import com.google.common.collect.Lists;
79import com.google.common.collect.Sets;
80
81import java.util.ArrayList;
82import java.util.Collection;
83import java.util.HashMap;
84import java.util.HashSet;
85import java.util.List;
86import java.util.Set;
87import java.util.Map.Entry;
88import java.util.concurrent.ConcurrentHashMap;
89
90public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
91        RespondInlineListener, DialogInterface.OnClickListener, TextWatcher,
92        AttachmentDeletedListener, OnAccountChangedListener, LoaderCallbacks<Cursor> {
93    // Identifiers for which type of composition this is
94    static final int COMPOSE = -1;  // also used for editing a draft
95    static final int REPLY = 0;
96    static final int REPLY_ALL = 1;
97    static final int FORWARD = 2;
98
99    // Integer extra holding one of the above compose action
100    private static final String EXTRA_ACTION = "action";
101
102    private static SendOrSaveCallback sTestSendOrSaveCallback = null;
103    // Map containing information about requests to create new messages, and the id of the
104    // messages that were the result of those requests.
105    //
106    // This map is used when the activity that initiated the save a of a new message, is killed
107    // before the save has completed (and when we know the id of the newly created message).  When
108    // a save is completed, the service that is running in the background, will update the map
109    //
110    // When a new ComposeActivity instance is created, it will attempt to use the information in
111    // the previously instantiated map.  If ComposeActivity.onCreate() is called, with a bundle
112    // (restoring data from a previous instance), and the map hasn't been created, we will attempt
113    // to populate the map with data stored in shared preferences.
114    private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null;
115    // Key used to store the above map
116    private static final String CACHED_MESSAGE_REQUEST_IDS_KEY = "cache-message-request-ids";
117    /**
118     * Notifies the {@code Activity} that the caller is an Email
119     * {@code Activity}, so that the back behavior may be modified accordingly.
120     *
121     * @see #onAppUpPressed
122     */
123    private static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
124
125    //  If this is a reply/forward then this extra will hold the original message
126    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message";
127    private static final String END_TOKEN = ", ";
128    private static final String LOG_TAG = new LogUtils().getLogTag();
129    // Request numbers for activities we start
130    private static final int RESULT_PICK_ATTACHMENT = 1;
131    private static final int RESULT_CREATE_ACCOUNT = 2;
132    private static final int ACCOUNT_SETTINGS_LOADER = 0;
133
134    /**
135     * A single thread for running tasks in the background.
136     */
137    private Handler mSendSaveTaskHandler = null;
138    private RecipientEditTextView mTo;
139    private RecipientEditTextView mCc;
140    private RecipientEditTextView mBcc;
141    private Button mCcBccButton;
142    private CcBccView mCcBccView;
143    private AttachmentsView mAttachmentsView;
144    private Account mAccount;
145    private Settings mCachedSettings;
146    private Rfc822Validator mValidator;
147    private TextView mSubject;
148
149    private ComposeModeAdapter mComposeModeAdapter;
150    private int mComposeMode = -1;
151    private boolean mForward;
152    private String mRecipient;
153    private QuotedTextView mQuotedTextView;
154    private TextView mBodyView;
155    private View mFromStatic;
156    private View mFromSpinnerWrapper;
157    private FromAddressSpinner mFromSpinner;
158    private boolean mAddingAttachment;
159    private boolean mAttachmentsChanged;
160    private boolean mTextChanged;
161    private boolean mReplyFromChanged;
162    private MenuItem mSave;
163    private MenuItem mSend;
164    private String mRefMessageId;
165    private AlertDialog mRecipientErrorDialog;
166    private AlertDialog mSendConfirmDialog;
167    private Message mRefMessage;
168    private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
169    private Message mDraft;
170    private Object mDraftLock = new Object();
171
172    /**
173     * Can be called from a non-UI thread.
174     */
175    public static void editDraft(Context launcher, Account account, Message message) {
176    }
177
178    /**
179     * Can be called from a non-UI thread.
180     */
181    public static void compose(Context launcher, Account account) {
182        launch(launcher, account, null, COMPOSE);
183    }
184
185    /**
186     * Can be called from a non-UI thread.
187     */
188    public static void reply(Context launcher, Account account, Message message) {
189        launch(launcher, account, message, REPLY);
190    }
191
192    /**
193     * Can be called from a non-UI thread.
194     */
195    public static void replyAll(Context launcher, Account account, Message message) {
196        launch(launcher, account, message, REPLY_ALL);
197    }
198
199    /**
200     * Can be called from a non-UI thread.
201     */
202    public static void forward(Context launcher, Account account, Message message) {
203        launch(launcher, account, message, FORWARD);
204    }
205
206    private static void launch(Context launcher, Account account, Message message, int action) {
207        Intent intent = new Intent(launcher, ComposeActivity.class);
208        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
209        intent.putExtra(EXTRA_ACTION, action);
210        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
211        intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message);
212        launcher.startActivity(intent);
213    }
214
215    @Override
216    public void onCreate(Bundle savedInstanceState) {
217        super.onCreate(savedInstanceState);
218        setContentView(R.layout.compose);
219        findViews();
220        Intent intent = getIntent();
221        setAccount((Account)intent.getParcelableExtra(Utils.EXTRA_ACCOUNT));
222        if (mAccount == null) {
223            return;
224        }
225        int action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
226        mRefMessage = (Message) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
227        if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
228            initFromRefMessage(action, mAccount.name);
229        } else {
230            mQuotedTextView.setVisibility(View.GONE);
231        }
232        initRecipients();
233        initActionBar(action);
234        initFromSpinner();
235        initChangeListeners();
236    }
237
238    @Override
239    protected void onResume() {
240        super.onResume();
241        // Update the from spinner as other accounts
242        // may now be available.
243        if (mFromSpinner != null && mAccount != null) {
244            mFromSpinner.asyncInitFromSpinner();
245        }
246    }
247
248    @Override
249    protected void onPause() {
250        super.onPause();
251
252        if (mSendConfirmDialog != null) {
253            mSendConfirmDialog.dismiss();
254        }
255        if (mRecipientErrorDialog != null) {
256            mRecipientErrorDialog.dismiss();
257        }
258
259        saveIfNeeded();
260    }
261
262    @Override
263    protected final void onActivityResult(int request, int result, Intent data) {
264        mAddingAttachment = false;
265
266        if (result == RESULT_OK && request == RESULT_PICK_ATTACHMENT) {
267            addAttachmentAndUpdateView(data);
268        }
269    }
270
271    @Override
272    public final void onSaveInstanceState(Bundle state) {
273        super.onSaveInstanceState(state);
274
275        // onSaveInstanceState is only called if the user might come back to this activity so it is
276        // not an ideal location to save the draft. However, if we have never saved the draft before
277        // we have to save it here in order to have an id to save in the bundle.
278        saveIfNeededOnOrientationChanged();
279    }
280
281    @VisibleForTesting
282    void setAccount(Account account) {
283        mAccount = account;
284        getLoaderManager().restartLoader(ACCOUNT_SETTINGS_LOADER, null, this);
285    }
286
287    private void initFromSpinner() {
288        mFromSpinner.setCurrentAccount(mAccount);
289        mFromSpinner.asyncInitFromSpinner();
290        boolean showSpinner = mFromSpinner.getCount() > 1;
291        // If there is only 1 account, just show that account.
292        // Otherwise, give the user the ability to choose which account to send
293        // mail from / save drafts to.
294        mFromStatic.setVisibility(
295                showSpinner ? View.GONE : View.VISIBLE);
296        mFromSpinnerWrapper.setVisibility(
297                showSpinner ? View.VISIBLE : View.GONE);
298    }
299
300    private void findViews() {
301        mCcBccButton = (Button) findViewById(R.id.add_cc_bcc);
302        if (mCcBccButton != null) {
303            mCcBccButton.setOnClickListener(this);
304        }
305        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
306        mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments);
307        mTo = (RecipientEditTextView) findViewById(R.id.to);
308        mCc = (RecipientEditTextView) findViewById(R.id.cc);
309        mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
310        // TODO: add special chips text change watchers before adding
311        // this as a text changed watcher to the to, cc, bcc fields.
312        mSubject = (TextView) findViewById(R.id.subject);
313        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
314        mQuotedTextView.setRespondInlineListener(this);
315        mBodyView = (TextView) findViewById(R.id.body);
316        mFromStatic = findViewById(R.id.static_from_content);
317        mFromSpinnerWrapper = findViewById(R.id.spinner_from_content);
318        mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker);
319    }
320
321    // Now that the message has been initialized from any existing draft or
322    // ref message data, set up listeners for any changes that occur to the
323    // message.
324    private void initChangeListeners() {
325        mSubject.addTextChangedListener(this);
326        mBodyView.addTextChangedListener(this);
327        mTo.addTextChangedListener(new RecipientTextWatcher(mTo, this));
328        mCc.addTextChangedListener(new RecipientTextWatcher(mCc, this));
329        mBcc.addTextChangedListener(new RecipientTextWatcher(mBcc, this));
330        mFromSpinner.setOnAccountChangedListener(this);
331        mAttachmentsView.setAttachmentChangesListener(this);
332    }
333
334    private void initActionBar(int action) {
335        mComposeMode = action;
336        ActionBar actionBar = getActionBar();
337        if (action == ComposeActivity.COMPOSE) {
338            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
339            actionBar.setTitle(R.string.compose);
340        } else {
341            actionBar.setTitle(null);
342            if (mComposeModeAdapter == null) {
343                mComposeModeAdapter = new ComposeModeAdapter(this);
344            }
345            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
346            actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
347            switch (action) {
348                case ComposeActivity.REPLY:
349                    actionBar.setSelectedNavigationItem(0);
350                    break;
351                case ComposeActivity.REPLY_ALL:
352                    actionBar.setSelectedNavigationItem(1);
353                    break;
354                case ComposeActivity.FORWARD:
355                    actionBar.setSelectedNavigationItem(2);
356                    break;
357            }
358        }
359    }
360
361    private void initFromRefMessage(int action, String recipientAddress) {
362        if (mRefMessage != null) {
363            mRefMessageId = mRefMessage.refMessageId;
364            setSubject(mRefMessage, action);
365            // Setup recipients
366            if (action == FORWARD) {
367                mForward = true;
368            }
369            initRecipientsFromRefMessage(recipientAddress, mRefMessage, action);
370            initBodyFromRefMessage(mRefMessage, action);
371            if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
372                initAttachments(mRefMessage);
373            }
374            updateHideOrShowCcBcc();
375        }
376    }
377
378    private void initAttachments(Message refMessage) {
379        mAttachmentsView.addAttachments(mAccount, refMessage);
380    }
381
382    private void initBodyFromRefMessage(Message refMessage, int action) {
383        if (action == REPLY || action == REPLY_ALL || action == FORWARD) {
384            mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD);
385        }
386    }
387
388    private void updateHideOrShowCcBcc() {
389        // Its possible there is a menu item OR a button.
390        boolean ccVisible = !TextUtils.isEmpty(mCc.getText());
391        boolean bccVisible = !TextUtils.isEmpty(mBcc.getText());
392        if (ccVisible || bccVisible) {
393            mCcBccView.show(false, ccVisible, bccVisible);
394        }
395        if (mCcBccButton != null) {
396            if (!mCc.isShown() || !mBcc.isShown()) {
397                mCcBccButton.setVisibility(View.VISIBLE);
398                mCcBccButton.setText(getString(!mCc.isShown() ? R.string.add_cc_label
399                        : R.string.add_bcc_label));
400            } else {
401                mCcBccButton.setVisibility(View.GONE);
402            }
403        }
404    }
405
406    /**
407     * Add attachment and update the compose area appropriately.
408     * @param data
409     */
410    public void addAttachmentAndUpdateView(Intent data) {
411        Uri uri = data != null ? data.getData() : null;
412        try {
413            long size =  mAttachmentsView.addAttachment(mAccount, uri, false /* doSave */,
414                    true /* local file */);
415            if (size > 0) {
416                mAttachmentsChanged = true;
417                updateSaveUi();
418            }
419        } catch (AttachmentFailureException e) {
420            // A toast has already been shown to the user, no need to do
421            // anything.
422            LogUtils.e(LOG_TAG, e, "Error adding attachment");
423        }
424    }
425
426    void initRecipientsFromRefMessage(String recipientAddress, Message refMessage,
427            int action) {
428        // Don't populate the address if this is a forward.
429        if (action == ComposeActivity.FORWARD) {
430            return;
431        }
432        initReplyRecipients(mAccount.name, refMessage, action);
433    }
434
435    @VisibleForTesting
436    void initReplyRecipients(String account, Message refMessage, int action) {
437        // This is the email address of the current user, i.e. the one composing
438        // the reply.
439        final String accountEmail = Address.getEmailAddress(account).getAddress();
440        String fromAddress = refMessage.from;
441        String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage.to);
442        String replytoAddress = refMessage.replyTo;
443        final Collection<String> toAddresses;
444
445        // If this is a reply, the Cc list is empty. If this is a reply-all, the
446        // Cc list is the union of the To and Cc recipients of the original
447        // message, excluding the current user's email address and any addresses
448        // already on the To list.
449        if (action == ComposeActivity.REPLY) {
450            toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress,
451                    new String[0]);
452            addToAddresses(toAddresses);
453        } else if (action == ComposeActivity.REPLY_ALL) {
454            final Set<String> ccAddresses = Sets.newHashSet();
455            toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress,
456                    new String[0]);
457            addToAddresses(toAddresses);
458            addRecipients(accountEmail, ccAddresses, sentToAddresses);
459            addRecipients(accountEmail, ccAddresses,
460                    Utils.splitCommaSeparatedString(refMessage.cc));
461            addCcAddresses(ccAddresses, toAddresses);
462        }
463    }
464
465    private void addToAddresses(Collection<String> addresses) {
466        addAddressesToList(addresses, mTo);
467    }
468
469    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
470        addCcAddressesToList(tokenizeAddressList(addresses), tokenizeAddressList(toAddresses),
471                mCc);
472    }
473
474    @VisibleForTesting
475    protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
476            List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
477        String address;
478
479        HashSet<String> compareTo = convertToHashSet(compareToList);
480        for (Rfc822Token[] tokens : addresses) {
481            for (int i = 0; i < tokens.length; i++) {
482                address = tokens[i].toString();
483                // Check if this is a duplicate:
484                if (!compareTo.contains(tokens[i].getAddress())) {
485                    // Get the address here
486                    list.append(address + END_TOKEN);
487                }
488            }
489        }
490    }
491
492    private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) {
493        HashSet<String> hash = new HashSet<String>();
494        for (Rfc822Token[] tokens : list) {
495            for (int i = 0; i < tokens.length; i++) {
496                hash.add(tokens[i].getAddress());
497            }
498        }
499        return hash;
500    }
501
502    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
503        @VisibleForTesting
504        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
505
506        for (String address: addresses) {
507            tokenized.add(Rfc822Tokenizer.tokenize(address));
508        }
509        return tokenized;
510    }
511
512    @VisibleForTesting
513    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
514        for (String address : addresses) {
515            addAddressToList(address, list);
516        }
517    }
518
519    private void addAddressToList(String address, RecipientEditTextView list) {
520        if (address == null || list == null)
521            return;
522
523        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
524
525        for (int i = 0; i < tokens.length; i++) {
526            list.append(tokens[i] + END_TOKEN);
527        }
528    }
529
530    @VisibleForTesting
531    protected Collection<String> initToRecipients(String account, String accountEmail,
532            String senderAddress, String replyToAddress, String[] inToAddresses) {
533        // The To recipient is the reply-to address specified in the original
534        // message, unless it is:
535        // the current user OR a custom from of the current user, in which case
536        // it's the To recipient list of the original message.
537        // OR missing, in which case use the sender of the original message
538        Set<String> toAddresses = Sets.newHashSet();
539        if (!TextUtils.isEmpty(replyToAddress)) {
540            toAddresses.add(replyToAddress);
541        } else {
542            toAddresses.add(senderAddress);
543        }
544        return toAddresses;
545    }
546
547    private static void addRecipients(String account, Set<String> recipients, String[] addresses) {
548        for (String email : addresses) {
549            // Do not add this account, or any of the custom froms, to the list
550            // of recipients.
551            final String recipientAddress = Address.getEmailAddress(email).getAddress();
552            if (!account.equalsIgnoreCase(recipientAddress)) {
553                recipients.add(email.replace("\"\"", ""));
554            }
555        }
556    }
557
558    private void setSubject(Message refMessage, int action) {
559        String subject = refMessage.subject;
560        String prefix;
561        String correctedSubject = null;
562        if (action == ComposeActivity.COMPOSE) {
563            prefix = "";
564        } else if (action == ComposeActivity.FORWARD) {
565            prefix = getString(R.string.forward_subject_label);
566        } else {
567            prefix = getString(R.string.reply_subject_label);
568        }
569
570        // Don't duplicate the prefix
571        if (subject.toLowerCase().startsWith(prefix.toLowerCase())) {
572            correctedSubject = subject;
573        } else {
574            correctedSubject = String
575                    .format(getString(R.string.formatted_subject), prefix, subject);
576        }
577        mSubject.setText(correctedSubject);
578    }
579
580    private void initRecipients() {
581        setupRecipients(mTo);
582        setupRecipients(mCc);
583        setupRecipients(mBcc);
584    }
585
586    private void setupRecipients(RecipientEditTextView view) {
587        view.setAdapter(new RecipientAdapter(this, mAccount));
588        view.setTokenizer(new Rfc822Tokenizer());
589        if (mValidator == null) {
590            final String accountName = mAccount.name;
591            int offset = accountName.indexOf("@") + 1;
592            String account = accountName;
593            if (offset > -1) {
594                account = account.substring(accountName.indexOf("@") + 1);
595            }
596            mValidator = new Rfc822Validator(account);
597        }
598        view.setValidator(mValidator);
599    }
600
601    @Override
602    public void onClick(View v) {
603        int id = v.getId();
604        switch (id) {
605            case R.id.add_cc_bcc:
606                // Verify that cc/ bcc aren't showing.
607                // Animate in cc/bcc.
608                showCcBccViews();
609                break;
610        }
611    }
612
613    @Override
614    public boolean onCreateOptionsMenu(Menu menu) {
615        super.onCreateOptionsMenu(menu);
616        MenuInflater inflater = getMenuInflater();
617        inflater.inflate(R.menu.compose_menu, menu);
618        mSave = menu.findItem(R.id.save);
619        mSend = menu.findItem(R.id.send);
620        return true;
621    }
622
623    @Override
624    public boolean onPrepareOptionsMenu(Menu menu) {
625        MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
626        if (ccBcc != null && mCc != null) {
627            // Its possible there is a menu item OR a button.
628            boolean ccFieldVisible = mCc.isShown();
629            boolean bccFieldVisible = mBcc.isShown();
630            if (!ccFieldVisible || !bccFieldVisible) {
631                ccBcc.setVisible(true);
632                ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
633                        : R.string.add_bcc_label));
634            } else {
635                ccBcc.setVisible(false);
636            }
637        }
638        if (mSave != null) {
639            mSave.setEnabled(shouldSave());
640        }
641        return true;
642    }
643
644    @Override
645    public boolean onOptionsItemSelected(MenuItem item) {
646        int id = item.getItemId();
647        boolean handled = true;
648        switch (id) {
649            case R.id.add_attachment:
650                doAttach();
651                break;
652            case R.id.add_cc_bcc:
653                showCcBccViews();
654                break;
655            case R.id.save:
656                doSave(true, false);
657                break;
658            case R.id.send:
659                doSend();
660                break;
661            case R.id.discard:
662                doDiscard();
663                break;
664            default:
665                handled = false;
666                break;
667        }
668        return !handled ? super.onOptionsItemSelected(item) : handled;
669    }
670
671    private void doSend() {
672        sendOrSaveWithSanityChecks(false, true, false);
673    }
674
675    private void doSave(boolean showToast, boolean resetIME) {
676        sendOrSaveWithSanityChecks(true, showToast, false);
677        if (resetIME) {
678            // Clear the IME composing suggestions from the body.
679            BaseInputConnection.removeComposingSpans(mBodyView.getEditableText());
680        }
681    }
682
683    /*package*/ interface SendOrSaveCallback {
684        public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask);
685        public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
686        public Message getMessage();
687        public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success);
688    }
689
690    /*package*/ static class SendOrSaveTask implements Runnable {
691        private final Context mContext;
692        private final SendOrSaveCallback mSendOrSaveCallback;
693        @VisibleForTesting
694        final SendOrSaveMessage mSendOrSaveMessage;
695
696        public SendOrSaveTask(Context context, SendOrSaveMessage message,
697                SendOrSaveCallback callback) {
698            mContext = context;
699            mSendOrSaveCallback = callback;
700            mSendOrSaveMessage = message;
701        }
702
703        @Override
704        public void run() {
705            final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage;
706
707            final Account selectedAccount = sendOrSaveMessage.mSelectedAccount;
708            Message message = mSendOrSaveCallback.getMessage();
709            long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID;
710            // If a previous draft has been saved, in an account that is different
711            // than what the user wants to send from, remove the old draft, and treat this
712            // as a new message
713            if (!selectedAccount.equals(sendOrSaveMessage.mAccount)) {
714                if (messageId != UIProvider.INVALID_MESSAGE_ID) {
715                    ContentResolver resolver = mContext.getContentResolver();
716                    ContentValues values = new ContentValues();
717                    values.put(BaseColumns._ID, messageId);
718                    if (selectedAccount.expungeMessageUri != null) {
719                        resolver.update(selectedAccount.expungeMessageUri, values, null,
720                                null);
721                    } else {
722                        // TODO(mindyp) delete the conversation.
723                    }
724                    // reset messageId to 0, so a new message will be created
725                    messageId = UIProvider.INVALID_MESSAGE_ID;
726                }
727            }
728
729            final long messageIdToSave = messageId;
730            if (messageIdToSave != UIProvider.INVALID_MESSAGE_ID) {
731                sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
732                mContext.getContentResolver().update(
733                        Uri.parse(sendOrSaveMessage.mSave ? message.saveUri : message.sendUri),
734                        sendOrSaveMessage.mValues, null, null);
735            } else {
736                ContentResolver resolver = mContext.getContentResolver();
737                Uri messageUri = resolver.insert(
738                        sendOrSaveMessage.mSave ? selectedAccount.saveDraftUri
739                                : selectedAccount.sendMessageUri, sendOrSaveMessage.mValues);
740                if (sendOrSaveMessage.mSave && messageUri != null) {
741                    Cursor messageCursor = resolver.query(messageUri,
742                            UIProvider.MESSAGE_PROJECTION, null, null, null);
743                    if (messageCursor != null && messageCursor.moveToFirst()) {
744                        // Broadcast notification that a new message has
745                        // been allocated
746                        mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
747                                new Message(messageCursor));
748                    }
749                }
750            }
751
752            if (!sendOrSaveMessage.mSave) {
753                UIProvider.incrementRecipientsTimesContacted(mContext,
754                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO));
755                UIProvider.incrementRecipientsTimesContacted(mContext,
756                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC));
757                UIProvider.incrementRecipientsTimesContacted(mContext,
758                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
759            }
760            mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
761        }
762    }
763
764    // Array of the outstanding send or save tasks.  Access is synchronized
765    // with the object itself
766    /* package for testing */
767    ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList();
768    private int mRequestId;
769    private String mSignature;
770
771    /*package*/ static class SendOrSaveMessage {
772        final Account mAccount;
773        final Account mSelectedAccount;
774        final ContentValues mValues;
775        final String mRefMessageId;
776        final boolean mSave;
777        final int mRequestId;
778
779        public SendOrSaveMessage(Account account, Account selectedAccount, ContentValues values,
780                String refMessageId, boolean save) {
781            mAccount = account;
782            mSelectedAccount = selectedAccount;
783            mValues = values;
784            mRefMessageId = refMessageId;
785            mSave = save;
786            mRequestId = mValues.hashCode() ^ hashCode();
787        }
788
789        int requestId() {
790            return mRequestId;
791        }
792    }
793
794    /**
795     * Get the to recipients.
796     */
797    public String[] getToAddresses() {
798        return getAddressesFromList(mTo);
799    }
800
801    /**
802     * Get the cc recipients.
803     */
804    public String[] getCcAddresses() {
805        return getAddressesFromList(mCc);
806    }
807
808    /**
809     * Get the bcc recipients.
810     */
811    public String[] getBccAddresses() {
812        return getAddressesFromList(mBcc);
813    }
814
815    public String[] getAddressesFromList(RecipientEditTextView list) {
816        if (list == null) {
817            return new String[0];
818        }
819        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
820        int count = tokens.length;
821        String[] result = new String[count];
822        for (int i = 0; i < count; i++) {
823            result[i] = tokens[i].toString();
824        }
825        return result;
826    }
827
828    /**
829     * Check for invalid email addresses.
830     * @param to String array of email addresses to check.
831     * @param wrongEmailsOut Emails addresses that were invalid.
832     */
833    public void checkInvalidEmails(String[] to, List<String> wrongEmailsOut) {
834        for (String email : to) {
835            if (!mValidator.isValid(email)) {
836                wrongEmailsOut.add(email);
837            }
838        }
839    }
840
841    /**
842     * Show an error because the user has entered an invalid recipient.
843     * @param message
844     */
845    public void showRecipientErrorDialog(String message) {
846        // Only 1 invalid recipients error dialog should be allowed up at a
847        // time.
848        if (mRecipientErrorDialog != null) {
849            mRecipientErrorDialog.dismiss();
850        }
851        mRecipientErrorDialog = new AlertDialog.Builder(this).setMessage(message).setTitle(
852                R.string.recipient_error_dialog_title)
853                .setIconAttribute(android.R.attr.alertDialogIcon)
854                .setCancelable(false)
855                .setPositiveButton(
856                        R.string.ok, new Dialog.OnClickListener() {
857                            public void onClick(DialogInterface dialog, int which) {
858                                // after the user dismisses the recipient error
859                                // dialog we want to make sure to refocus the
860                                // recipient to field so they can fix the issue
861                                // easily
862                                if (mTo != null) {
863                                    mTo.requestFocus();
864                                }
865                                mRecipientErrorDialog = null;
866                            }
867                        }).show();
868    }
869
870    /**
871     * Update the state of the UI based on whether or not the current draft
872     * needs to be saved and the message is not empty.
873     */
874    public void updateSaveUi() {
875        if (mSave != null) {
876            mSave.setEnabled((shouldSave() && !isBlank()));
877        }
878    }
879
880    /**
881     * Returns true if we need to save the current draft.
882     */
883    private boolean shouldSave() {
884        synchronized (mDraftLock) {
885            // The message should only be saved if:
886            // It hasn't been sent AND
887            // Some text has been added to the message OR
888            // an attachment has been added or removed
889            return (mTextChanged || mAttachmentsChanged ||
890                    (mReplyFromChanged && !isBlank()));
891        }
892    }
893
894    /**
895     * Check if all fields are blank.
896     * @return boolean
897     */
898    public boolean isBlank() {
899        return mSubject.getText().length() == 0
900                && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
901                        mBodyView.getText().toString()) == 0)
902                && mTo.length() == 0
903                && mCc.length() == 0 && mBcc.length() == 0
904                && mAttachmentsView.getAttachments().size() == 0;
905    }
906
907    @VisibleForTesting
908    protected int getSignatureStartPosition(String signature, String bodyText) {
909        int startPos = -1;
910
911        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
912            return startPos;
913        }
914
915        int bodyLength = bodyText.length();
916        int signatureLength = signature.length();
917        String printableVersion = convertToPrintableSignature(signature);
918        int printableLength = printableVersion.length();
919
920        if (bodyLength >= printableLength
921                && bodyText.substring(bodyLength - printableLength)
922                .equals(printableVersion)) {
923            startPos = bodyLength - printableLength;
924        } else if (bodyLength >= signatureLength
925                && bodyText.substring(bodyLength - signatureLength)
926                .equals(signature)) {
927            startPos = bodyLength - signatureLength;
928        }
929        return startPos;
930    }
931
932    /**
933     * Allows any changes made by the user to be ignored. Called when the user
934     * decides to discard a draft.
935     */
936    private void discardChanges() {
937        mTextChanged = false;
938        mAttachmentsChanged = false;
939        mReplyFromChanged = false;
940    }
941
942    /**
943     * @param body
944     * @param save
945     * @param showToast
946     * @return Whether the send or save succeeded.
947     */
948    protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
949            final boolean orientationChanged) {
950        String[] to, cc, bcc;
951        Editable body = mBodyView.getEditableText();
952
953        if (orientationChanged) {
954            to = cc = bcc = new String[0];
955        } else {
956            to = getToAddresses();
957            cc = getCcAddresses();
958            bcc = getBccAddresses();
959        }
960
961        // Don't let the user send to nobody (but it's okay to save a message
962        // with no recipients)
963        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
964            showRecipientErrorDialog(getString(R.string.recipient_needed));
965            return false;
966        }
967
968        List<String> wrongEmails = new ArrayList<String>();
969        if (!save) {
970            checkInvalidEmails(to, wrongEmails);
971            checkInvalidEmails(cc, wrongEmails);
972            checkInvalidEmails(bcc, wrongEmails);
973        }
974
975        // Don't let the user send an email with invalid recipients
976        if (wrongEmails.size() > 0) {
977            String errorText = String.format(getString(R.string.invalid_recipient),
978                    wrongEmails.get(0));
979            showRecipientErrorDialog(errorText);
980            return false;
981        }
982
983        DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() {
984            public void onClick(DialogInterface dialog, int which) {
985                sendOrSave(mBodyView.getEditableText(), save, showToast, orientationChanged);
986            }
987        };
988
989        // Show a warning before sending only if there are no attachments.
990        if (!save) {
991            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
992                boolean warnAboutEmptySubject = isSubjectEmpty();
993                boolean emptyBody = TextUtils.getTrimmedLength(body) == 0;
994
995                // A warning about an empty body may not be warranted when
996                // forwarding mails, since a common use case is to forward
997                // quoted text and not append any more text.
998                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
999
1000                // When we bring up a dialog warning the user about a send,
1001                // assume that they accept sending the message. If they do not,
1002                // the dialog listener is required to enable sending again.
1003                if (warnAboutEmptySubject) {
1004                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, listener);
1005                    return true;
1006                }
1007
1008                if (warnAboutEmptyBody) {
1009                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body, listener);
1010                    return true;
1011                }
1012            }
1013            // Ask for confirmation to send (if always required)
1014            if (showSendConfirmation()) {
1015                showSendConfirmDialog(R.string.confirm_send_message, listener);
1016                return true;
1017            }
1018        }
1019
1020        sendOrSave(body, save, showToast, false);
1021        return true;
1022    }
1023
1024    /**
1025     * Returns a boolean indicating whether warnings should be shown for empty
1026     * subject and body fields
1027     *
1028     * @return True if a warning should be shown for empty text fields
1029     */
1030    protected boolean showEmptyTextWarnings() {
1031        return mAttachmentsView.getAttachments().size() == 0;
1032    }
1033
1034    /**
1035     * Returns a boolean indicating whether the user should confirm each send
1036     *
1037     * @return True if a warning should be on each send
1038     */
1039    protected boolean showSendConfirmation() {
1040        return mCachedSettings != null ? mCachedSettings.confirmSend : false;
1041    }
1042
1043    private void showSendConfirmDialog(int messageId, DialogInterface.OnClickListener listener) {
1044        if (mSendConfirmDialog != null) {
1045            mSendConfirmDialog.dismiss();
1046            mSendConfirmDialog = null;
1047        }
1048        mSendConfirmDialog = new AlertDialog.Builder(this).setMessage(messageId)
1049                .setTitle(R.string.confirm_send_title)
1050                .setIconAttribute(android.R.attr.alertDialogIcon)
1051                .setPositiveButton(R.string.send, listener)
1052                .setNegativeButton(R.string.cancel, this).setCancelable(false).show();
1053    }
1054
1055    /**
1056     * Returns whether the ComposeArea believes there is any text in the body of
1057     * the composition. TODO: When ComposeArea controls the Body as well, add
1058     * that here.
1059     */
1060    public boolean isBodyEmpty() {
1061        return !mQuotedTextView.isTextIncluded();
1062    }
1063
1064    /**
1065     * Test to see if the subject is empty.
1066     *
1067     * @return boolean.
1068     */
1069    // TODO: this will likely go away when composeArea.focus() is implemented
1070    // after all the widget control is moved over.
1071    public boolean isSubjectEmpty() {
1072        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
1073    }
1074
1075    /* package */
1076    static int sendOrSaveInternal(Context context, final Account account,
1077            final Account selectedAccount, String fromAddress, final Spanned body,
1078            final String[] to, final String[] cc, final String[] bcc, final String subject,
1079            final CharSequence quotedText, final List<Attachment> attachments,
1080            final String refMessageId, SendOrSaveCallback callback, Handler handler, boolean save,
1081            boolean forward) {
1082        ContentValues values = new ContentValues();
1083
1084        MessageModification.putToAddresses(values, to);
1085        MessageModification.putCcAddresses(values, cc);
1086        MessageModification.putBccAddresses(values, bcc);
1087
1088        MessageModification.putSubject(values, subject);
1089        String htmlBody = Html.toHtml(body);
1090        boolean includeQuotedText = !TextUtils.isEmpty(quotedText);
1091        StringBuilder fullBody = new StringBuilder(htmlBody);
1092        if (includeQuotedText) {
1093            if (forward) {
1094                // forwarded messages get full text in HTML from client
1095                fullBody.append(quotedText);
1096                MessageModification.putForward(values, forward);
1097            } else {
1098                // replies get full quoted text from server - HTMl gets
1099                // converted to text for now
1100                final String text = quotedText.toString();
1101                if (QuotedTextView.containsQuotedText(text)) {
1102                    int pos = QuotedTextView.getQuotedTextOffset(text);
1103                    fullBody.append(text.substring(0, pos));
1104                    MessageModification.putForward(values, forward);
1105                    MessageModification.putAppendRefMessageContent(values, includeQuotedText);
1106                } else {
1107                    LogUtils.w(LOG_TAG, "Couldn't find quoted text");
1108                    // This shouldn't happen, but just use what we have,
1109                    // and don't do server-side expansion
1110                    fullBody.append(text);
1111                }
1112            }
1113        }
1114        MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString());
1115        MessageModification.putBodyHtml(values, fullBody.toString());
1116        MessageModification.putAttachments(values, attachments);
1117
1118        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(account, selectedAccount,
1119                values, refMessageId, save);
1120        SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback);
1121
1122        callback.initializeSendOrSave(sendOrSaveTask);
1123
1124        // Do the send/save action on the specified handler to avoid possible
1125        // ANRs
1126        handler.post(sendOrSaveTask);
1127
1128        return sendOrSaveMessage.requestId();
1129    }
1130
1131    private void sendOrSave(Spanned body, boolean save, boolean showToast,
1132            boolean orientationChanged) {
1133        // Check if user is a monkey. Monkeys can compose and hit send
1134        // button but are not allowed to send anything off the device.
1135        if (!save && ActivityManager.isUserAMonkey()) {
1136            return;
1137        }
1138
1139        String[] to, cc, bcc;
1140        if (orientationChanged) {
1141            to = cc = bcc = new String[0];
1142        } else {
1143            to = getToAddresses();
1144            cc = getCcAddresses();
1145            bcc = getBccAddresses();
1146        }
1147
1148        SendOrSaveCallback callback = new SendOrSaveCallback() {
1149            private int mRestoredRequestId;
1150
1151            public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
1152                synchronized (mActiveTasks) {
1153                    int numTasks = mActiveTasks.size();
1154                    if (numTasks == 0) {
1155                        // Start service so we won't be killed if this app is
1156                        // put in the background.
1157                        startService(new Intent(ComposeActivity.this, EmptyService.class));
1158                    }
1159
1160                    mActiveTasks.add(sendOrSaveTask);
1161                }
1162                if (sTestSendOrSaveCallback != null) {
1163                    sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
1164                }
1165            }
1166
1167            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
1168                    Message message) {
1169                synchronized (mDraftLock) {
1170                    mDraftId = message.id;
1171                    mDraft = message;
1172                    if (sRequestMessageIdMap != null) {
1173                        sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
1174                    }
1175                    // Cache request message map, in case the process is killed
1176                    saveRequestMap();
1177                }
1178                if (sTestSendOrSaveCallback != null) {
1179                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
1180                }
1181            }
1182
1183            public Message getMessage() {
1184                synchronized (mDraftLock) {
1185                    return mDraft;
1186                }
1187            }
1188
1189            public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
1190                if (success) {
1191                    // Successfully sent or saved so reset change markers
1192                    discardChanges();
1193                } else {
1194                    // A failure happened with saving/sending the draft
1195                    // TODO(pwestbro): add a better string that should be used
1196                    // when failing to send or save
1197                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
1198                            .show();
1199                }
1200
1201                int numTasks;
1202                synchronized (mActiveTasks) {
1203                    // Remove the task from the list of active tasks
1204                    mActiveTasks.remove(task);
1205                    numTasks = mActiveTasks.size();
1206                }
1207
1208                if (numTasks == 0) {
1209                    // Stop service so we can be killed.
1210                    stopService(new Intent(ComposeActivity.this, EmptyService.class));
1211                }
1212                if (sTestSendOrSaveCallback != null) {
1213                    sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
1214                }
1215            }
1216        };
1217
1218        // Get the selected account if the from spinner has been setup.
1219        Account selectedAccount = mAccount;
1220        String fromAddress = selectedAccount.name;
1221        if (selectedAccount == null || fromAddress == null) {
1222            // We don't have either the selected account or from address,
1223            // use mAccount.
1224            selectedAccount = mAccount;
1225            fromAddress = mAccount.name;
1226        }
1227
1228        if (mSendSaveTaskHandler == null) {
1229            HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
1230            handlerThread.start();
1231
1232            mSendSaveTaskHandler = new Handler(handlerThread.getLooper());
1233        }
1234
1235        mRequestId = sendOrSaveInternal(this, mAccount, selectedAccount, fromAddress, body, to, cc,
1236                bcc, mSubject.getText().toString(), mQuotedTextView.getQuotedText(),
1237                mAttachmentsView.getAttachments(), mRefMessageId, callback, mSendSaveTaskHandler,
1238                save, mForward);
1239
1240        if (mRecipient != null && mRecipient.equals(mAccount.name)) {
1241            mRecipient = selectedAccount.name;
1242        }
1243        mAccount = selectedAccount;
1244
1245        // Don't display the toast if the user is just changing the orientation,
1246        // but we still need to save the draft to the cursor because this is how we restore
1247        // the attachments when the configuration change completes.
1248        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
1249            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
1250                    Toast.LENGTH_LONG).show();
1251        }
1252
1253        // Need to update variables here because the send or save completes
1254        // asynchronously even though the toast shows right away.
1255        discardChanges();
1256        updateSaveUi();
1257
1258        // If we are sending, finish the activity
1259        if (!save) {
1260            finish();
1261        }
1262    }
1263
1264    /**
1265     * Save the state of the request messageid map. This allows for the Gmail
1266     * process to be killed, but and still allow for ComposeActivity instances
1267     * to be recreated correctly.
1268     */
1269    private void saveRequestMap() {
1270        // TODO: store the request map in user preferences.
1271    }
1272
1273    public void doAttach() {
1274        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
1275        i.addCategory(Intent.CATEGORY_OPENABLE);
1276        if (android.provider.Settings.System.getInt(getContentResolver(),
1277                UIProvider.getAttachmentTypeSetting(), 0) != 0) {
1278            i.setType("*/*");
1279        } else {
1280            i.setType("image/*");
1281        }
1282        mAddingAttachment = true;
1283        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
1284                RESULT_PICK_ATTACHMENT);
1285    }
1286
1287    private void showCcBccViews() {
1288        mCcBccView.show(true, true, true);
1289        if (mCcBccButton != null) {
1290            mCcBccButton.setVisibility(View.GONE);
1291        }
1292    }
1293
1294    @Override
1295    public boolean onNavigationItemSelected(int position, long itemId) {
1296        int initialComposeMode = mComposeMode;
1297        if (position == ComposeActivity.REPLY) {
1298            mComposeMode = ComposeActivity.REPLY;
1299        } else if (position == ComposeActivity.REPLY_ALL) {
1300            mComposeMode = ComposeActivity.REPLY_ALL;
1301        } else if (position == ComposeActivity.FORWARD) {
1302            mComposeMode = ComposeActivity.FORWARD;
1303        }
1304        if (initialComposeMode != mComposeMode) {
1305            resetMessageForModeChange();
1306            initFromRefMessage(mComposeMode, mAccount.name);
1307        }
1308        return true;
1309    }
1310
1311    private void resetMessageForModeChange() {
1312        // When switching between reply, reply all, forward,
1313        // follow the behavior of webview.
1314        // The contents of the following fields are cleared
1315        // so that they can be populated directly from the
1316        // ref message:
1317        // 1) Any recipient fields
1318        // 2) The subject
1319        mTo.setText("");
1320        mCc.setText("");
1321        mBcc.setText("");
1322        // Any edits to the subject are replaced with the original subject.
1323        mSubject.setText("");
1324
1325        // Any changes to the contents of the following fields are kept:
1326        // 1) Body
1327        // 2) Attachments
1328        // If the user made changes to attachments, keep their changes.
1329        if (!mAttachmentsChanged) {
1330            mAttachmentsView.deleteAllAttachments();
1331        }
1332    }
1333
1334    private class ComposeModeAdapter extends ArrayAdapter<String> {
1335
1336        private LayoutInflater mInflater;
1337
1338        public ComposeModeAdapter(Context context) {
1339            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
1340                    .getStringArray(R.array.compose_modes));
1341        }
1342
1343        private LayoutInflater getInflater() {
1344            if (mInflater == null) {
1345                mInflater = LayoutInflater.from(getContext());
1346            }
1347            return mInflater;
1348        }
1349
1350        @Override
1351        public View getView(int position, View convertView, ViewGroup parent) {
1352            if (convertView == null) {
1353                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
1354            }
1355            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
1356            return super.getView(position, convertView, parent);
1357        }
1358    }
1359
1360    @Override
1361    public void onRespondInline(String text) {
1362        appendToBody(text, false);
1363    }
1364
1365    /**
1366     * Append text to the body of the message. If there is no existing body
1367     * text, just sets the body to text.
1368     *
1369     * @param text
1370     * @param withSignature True to append a signature.
1371     */
1372    public void appendToBody(CharSequence text, boolean withSignature) {
1373        Editable bodyText = mBodyView.getEditableText();
1374        if (bodyText != null && bodyText.length() > 0) {
1375            bodyText.append(text);
1376        } else {
1377            setBody(text, withSignature);
1378        }
1379    }
1380
1381    /**
1382     * Set the body of the message.
1383     *
1384     * @param text
1385     * @param withSignature True to append a signature.
1386     */
1387    public void setBody(CharSequence text, boolean withSignature) {
1388        mBodyView.setText(text);
1389        if (withSignature) {
1390            appendSignature();
1391        }
1392    }
1393
1394    private void appendSignature() {
1395        mSignature = mCachedSettings != null ? mCachedSettings.signature : null;
1396        if (!TextUtils.isEmpty(mSignature)) {
1397            // Appending a signature does not count as changing text.
1398            mBodyView.removeTextChangedListener(this);
1399            mBodyView.append(convertToPrintableSignature(mSignature));
1400            mBodyView.addTextChangedListener(this);
1401        }
1402    }
1403
1404    private String convertToPrintableSignature(String signature) {
1405        String signatureResource = getResources().getString(R.string.signature);
1406        if (signature == null) {
1407            signature = "";
1408        }
1409        return String.format(signatureResource, signature);
1410    }
1411
1412    @Override
1413    public void onAccountChanged() {
1414        Account selectedAccountInfo = mFromSpinner.getCurrentAccount();
1415        if (!mAccount.equals(selectedAccountInfo)) {
1416            mAccount = selectedAccountInfo;
1417            mCachedSettings = null;
1418            getLoaderManager().restartLoader(ACCOUNT_SETTINGS_LOADER, null, this);
1419            // TODO: handle discarding attachments when switching accounts.
1420            // Only enable save for this draft if there is any other content
1421            // in the message.
1422            if (!isBlank()) {
1423                enableSave(true);
1424            }
1425            mReplyFromChanged = true;
1426            initRecipients();
1427        }
1428    }
1429
1430    public void enableSave(boolean enabled) {
1431        if (mSave != null) {
1432            mSave.setEnabled(enabled);
1433        }
1434    }
1435
1436    public void enableSend(boolean enabled) {
1437        if (mSend != null) {
1438            mSend.setEnabled(enabled);
1439        }
1440    }
1441
1442    /**
1443     * Handles button clicks from any error dialogs dealing with sending
1444     * a message.
1445     */
1446    @Override
1447    public void onClick(DialogInterface dialog, int which) {
1448        switch (which) {
1449            case DialogInterface.BUTTON_POSITIVE: {
1450                doDiscardWithoutConfirmation(true /* show toast */ );
1451                break;
1452            }
1453            case DialogInterface.BUTTON_NEGATIVE: {
1454                // If the user cancels the send, re-enable the send button.
1455                enableSend(true);
1456                break;
1457            }
1458        }
1459
1460    }
1461
1462    private void doDiscard() {
1463        new AlertDialog.Builder(this).setMessage(R.string.confirm_discard_text)
1464                .setPositiveButton(R.string.ok, this)
1465                .setNegativeButton(R.string.cancel, null)
1466                .create().show();
1467    }
1468    /**
1469     * Effectively discard the current message.
1470     *
1471     * This method is either invoked from the menu or from the dialog
1472     * once the user has confirmed that they want to discard the message.
1473     * @param showToast show "Message discarded" toast if true
1474     */
1475    private void doDiscardWithoutConfirmation(boolean showToast) {
1476        synchronized (mDraftLock) {
1477            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
1478                ContentValues values = new ContentValues();
1479                values.put(MessageColumns.SERVER_ID, mDraftId);
1480                if (mAccount.expungeMessageUri != null) {
1481                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
1482                } else {
1483                    // TODO(mindyp): call delete on this conversation instead.
1484                }
1485                // This is not strictly necessary (since we should not try to
1486                // save the draft after calling this) but it ensures that if we
1487                // do save again for some reason we make a new draft rather than
1488                // trying to resave an expunged draft.
1489                mDraftId = UIProvider.INVALID_MESSAGE_ID;
1490            }
1491        }
1492
1493        if (showToast) {
1494            // Display a toast to let the user know
1495            Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
1496        }
1497
1498        // This prevents the draft from being saved in onPause().
1499        discardChanges();
1500        finish();
1501    }
1502
1503    private void saveIfNeeded() {
1504        if (mAccount == null) {
1505            // We have not chosen an account yet so there's no way that we can save. This is ok,
1506            // though, since we are saving our state before AccountsActivity is activated. Thus, the
1507            // user has not interacted with us yet and there is no real state to save.
1508            return;
1509        }
1510
1511        if (shouldSave()) {
1512            doSave(!mAddingAttachment /* show toast */, true /* reset IME */);
1513        }
1514    }
1515
1516    private void saveIfNeededOnOrientationChanged() {
1517        if (mAccount == null) {
1518            // We have not chosen an account yet so there's no way that we can save. This is ok,
1519            // though, since we are saving our state before AccountsActivity is activated. Thus, the
1520            // user has not interacted with us yet and there is no real state to save.
1521            return;
1522        }
1523
1524        if (shouldSave()) {
1525            doSaveOrientationChanged(!mAddingAttachment /* show toast */, true /* reset IME */);
1526        }
1527    }
1528
1529    /**
1530     * Save a draft if a draft already exists or the message is not empty.
1531     */
1532    public void doSaveOrientationChanged(boolean showToast, boolean resetIME) {
1533        saveOnOrientationChanged();
1534        if (resetIME) {
1535            // Clear the IME composing suggestions from the body.
1536            BaseInputConnection.removeComposingSpans(mBodyView.getEditableText());
1537        }
1538    }
1539
1540    protected boolean saveOnOrientationChanged() {
1541        return sendOrSaveWithSanityChecks(true, false, true);
1542    }
1543
1544    @Override
1545    public void onAttachmentDeleted() {
1546        mAttachmentsChanged = true;
1547        updateSaveUi();
1548    }
1549
1550
1551    /**
1552     * This is called any time one of our text fields changes.
1553     */
1554    public void afterTextChanged(Editable s) {
1555        mTextChanged = true;
1556        updateSaveUi();
1557    }
1558
1559    @Override
1560    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
1561        // Do nothing.
1562    }
1563
1564    public void onTextChanged(CharSequence s, int start, int before, int count) {
1565        // Do nothing.
1566    }
1567
1568
1569    // There is a big difference between the text associated with an address changing
1570    // to add the display name or to format properly and a recipient being added or deleted.
1571    // Make sure we only notify of changes when a recipient has been added or deleted.
1572    private class RecipientTextWatcher implements TextWatcher {
1573        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
1574
1575        private RecipientEditTextView mView;
1576
1577        private TextWatcher mListener;
1578
1579        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
1580            mView = view;
1581            mListener = listener;
1582        }
1583
1584        @Override
1585        public void afterTextChanged(Editable s) {
1586            if (hasChanged()) {
1587                mListener.afterTextChanged(s);
1588            }
1589        }
1590
1591        private boolean hasChanged() {
1592            String[] currRecips = tokenizeRecips(getAddressesFromList(mView));
1593            int totalCount = currRecips.length;
1594            int totalPrevCount = 0;
1595            for (Entry<String, Integer> entry : mContent.entrySet()) {
1596                totalPrevCount += entry.getValue();
1597            }
1598            if (totalCount != totalPrevCount) {
1599                return true;
1600            }
1601
1602            for (String recip : currRecips) {
1603                if (!mContent.containsKey(recip)) {
1604                    return true;
1605                } else {
1606                    int count = mContent.get(recip) - 1;
1607                    if (count < 0) {
1608                        return true;
1609                    } else {
1610                        mContent.put(recip, count);
1611                    }
1612                }
1613            }
1614            return false;
1615        }
1616
1617        private String[] tokenizeRecips(String[] recips) {
1618            // Tokenize them all and put them in the list.
1619            String[] recipAddresses = new String[recips.length];
1620            for (int i = 0; i < recips.length; i++) {
1621                recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress();
1622            }
1623            return recipAddresses;
1624        }
1625
1626        @Override
1627        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
1628            String[] recips = tokenizeRecips(getAddressesFromList(mView));
1629            for (String recip : recips) {
1630                if (!mContent.containsKey(recip)) {
1631                    mContent.put(recip, 1);
1632                } else {
1633                    mContent.put(recip, (mContent.get(recip)) + 1);
1634                }
1635            }
1636        }
1637
1638        @Override
1639        public void onTextChanged(CharSequence s, int start, int before, int count) {
1640            // Do nothing.
1641        }
1642    }
1643
1644    @Override
1645    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
1646        if (id == ACCOUNT_SETTINGS_LOADER) {
1647            if (mAccount.settingsQueryUri != null) {
1648                return new CursorLoader(this, mAccount.settingsQueryUri,
1649                        UIProvider.SETTINGS_PROJECTION, null, null, null);
1650            }
1651        }
1652        return null;
1653    }
1654
1655    @Override
1656    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
1657        if (loader.getId() == ACCOUNT_SETTINGS_LOADER) {
1658            if (data != null) {
1659                data.moveToFirst();
1660                mCachedSettings = new Settings(data);
1661                appendSignature();
1662            }
1663        }
1664    }
1665
1666    @Override
1667    public void onLoaderReset(Loader<Cursor> loader) {
1668        // Do nothing.
1669    }
1670}