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