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