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