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