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