ComposeActivity.java revision 002ff5244800c32f2d70c96cc1110657020ee65c
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.ActionBar.OnNavigationListener;
21import android.app.Activity;
22import android.app.ActivityManager;
23import android.app.AlertDialog;
24import android.app.Dialog;
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.os.Parcelable;
37import android.provider.BaseColumns;
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.View.OnClickListener;
51import android.view.ViewGroup;
52import android.view.inputmethod.BaseInputConnection;
53import android.widget.ArrayAdapter;
54import android.widget.Button;
55import android.widget.EditText;
56import android.widget.ImageView;
57import android.widget.TextView;
58import android.widget.Toast;
59
60import com.android.common.Rfc822Validator;
61import com.android.ex.chips.RecipientEditTextView;
62import com.android.mail.R;
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.ReplyFromAccount;
73import com.android.mail.providers.Settings;
74import com.android.mail.providers.UIProvider;
75import com.android.mail.providers.UIProvider.AccountCapabilities;
76import com.android.mail.providers.UIProvider.DraftType;
77import com.android.mail.utils.AccountUtils;
78import com.android.mail.utils.LogUtils;
79import com.android.mail.utils.Utils;
80import com.google.common.annotations.VisibleForTesting;
81import com.google.common.collect.Lists;
82import com.google.common.collect.Sets;
83
84import org.json.JSONException;
85
86import java.io.UnsupportedEncodingException;
87import java.net.URLDecoder;
88import java.util.ArrayList;
89import java.util.Arrays;
90import java.util.Collection;
91import java.util.HashMap;
92import java.util.HashSet;
93import java.util.List;
94import java.util.Map.Entry;
95import java.util.Set;
96import java.util.concurrent.ConcurrentHashMap;
97
98public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
99        RespondInlineListener, DialogInterface.OnClickListener, TextWatcher,
100        AttachmentDeletedListener, OnAccountChangedListener {
101    // Identifiers for which type of composition this is
102    static final int COMPOSE = -1;
103    static final int REPLY = 0;
104    static final int REPLY_ALL = 1;
105    static final int FORWARD = 2;
106    static final int EDIT_DRAFT = 3;
107
108    // Integer extra holding one of the above compose action
109    private static final String EXTRA_ACTION = "action";
110
111    private static final String EXTRA_SHOW_CC = "showCc";
112    private static final String EXTRA_SHOW_BCC = "showBcc";
113
114    private static final String UTF8_ENCODING_NAME = "UTF-8";
115
116    private static final String MAIL_TO = "mailto";
117
118    private static final String EXTRA_SUBJECT = "subject";
119
120    private static final String EXTRA_BODY = "body";
121
122    private static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString";
123
124    // Extra that we can get passed from other activities
125    private static final String EXTRA_TO = "to";
126    private static final String EXTRA_CC = "cc";
127    private static final String EXTRA_BCC = "bcc";
128
129    // List of all the fields
130    static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC };
131
132    private static SendOrSaveCallback sTestSendOrSaveCallback = null;
133    // Map containing information about requests to create new messages, and the id of the
134    // messages that were the result of those requests.
135    //
136    // This map is used when the activity that initiated the save a of a new message, is killed
137    // before the save has completed (and when we know the id of the newly created message).  When
138    // a save is completed, the service that is running in the background, will update the map
139    //
140    // When a new ComposeActivity instance is created, it will attempt to use the information in
141    // the previously instantiated map.  If ComposeActivity.onCreate() is called, with a bundle
142    // (restoring data from a previous instance), and the map hasn't been created, we will attempt
143    // to populate the map with data stored in shared preferences.
144    private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null;
145    // Key used to store the above map
146    private static final String CACHED_MESSAGE_REQUEST_IDS_KEY = "cache-message-request-ids";
147    /**
148     * Notifies the {@code Activity} that the caller is an Email
149     * {@code Activity}, so that the back behavior may be modified accordingly.
150     *
151     * @see #onAppUpPressed
152     */
153    private static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
154
155    static final String EXTRA_ATTACHMENTS = "attachments";
156
157    //  If this is a reply/forward then this extra will hold the original message
158    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message";
159    // If this is an action to edit an existing draft messagge, this extra will hold the
160    // draft message
161    private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message";
162    private static final String END_TOKEN = ", ";
163    private static final String LOG_TAG = new LogUtils().getLogTag();
164    // Request numbers for activities we start
165    private static final int RESULT_PICK_ATTACHMENT = 1;
166    private static final int RESULT_CREATE_ACCOUNT = 2;
167    // TODO(mindyp) set mime-type for auto send?
168    private static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND";
169
170    // Max size for attachments (5 megs). Will be overridden by account settings if found.
171    // TODO(mindyp): read this from account settings?
172    private static final int DEFAULT_MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024;
173    private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount";
174    private static final String EXTRA_REQUEST_ID = "requestId";
175    private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart";
176    private static final String EXTRA_FOCUS_SELECTION_END = null;
177    private static final String EXTRA_MESSAGE = "extraMessage";
178
179    /**
180     * A single thread for running tasks in the background.
181     */
182    private Handler mSendSaveTaskHandler = null;
183    private RecipientEditTextView mTo;
184    private RecipientEditTextView mCc;
185    private RecipientEditTextView mBcc;
186    private Button mCcBccButton;
187    private CcBccView mCcBccView;
188    private AttachmentsView mAttachmentsView;
189    private Account mAccount;
190    private ReplyFromAccount mReplyFromAccount;
191    private Settings mCachedSettings;
192    private Rfc822Validator mValidator;
193    private TextView mSubject;
194
195    private ComposeModeAdapter mComposeModeAdapter;
196    private int mComposeMode = -1;
197    private boolean mForward;
198    private String mRecipient;
199    private QuotedTextView mQuotedTextView;
200    private EditText mBodyView;
201    private View mFromStatic;
202    private TextView mFromStaticText;
203    private View mFromSpinnerWrapper;
204    private FromAddressSpinner mFromSpinner;
205    private boolean mAddingAttachment;
206    private boolean mAttachmentsChanged;
207    private boolean mTextChanged;
208    private boolean mReplyFromChanged;
209    private MenuItem mSave;
210    private MenuItem mSend;
211    private AlertDialog mRecipientErrorDialog;
212    private AlertDialog mSendConfirmDialog;
213    private Message mRefMessage;
214    private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
215    private Message mDraft;
216    private Object mDraftLock = new Object();
217    private ImageView mAttachmentsButton;
218
219    /**
220     * Can be called from a non-UI thread.
221     */
222    public static void editDraft(Context launcher, Account account, Message message) {
223        launch(launcher, account, message, EDIT_DRAFT);
224    }
225
226    /**
227     * Can be called from a non-UI thread.
228     */
229    public static void compose(Context launcher, Account account) {
230        launch(launcher, account, null, COMPOSE);
231    }
232
233    /**
234     * Can be called from a non-UI thread.
235     */
236    public static void reply(Context launcher, Account account, Message message) {
237        launch(launcher, account, message, REPLY);
238    }
239
240    /**
241     * Can be called from a non-UI thread.
242     */
243    public static void replyAll(Context launcher, Account account, Message message) {
244        launch(launcher, account, message, REPLY_ALL);
245    }
246
247    /**
248     * Can be called from a non-UI thread.
249     */
250    public static void forward(Context launcher, Account account, Message message) {
251        launch(launcher, account, message, FORWARD);
252    }
253
254    private static void launch(Context launcher, Account account, Message message, int action) {
255        Intent intent = new Intent(launcher, ComposeActivity.class);
256        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
257        intent.putExtra(EXTRA_ACTION, action);
258        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
259        if (action == EDIT_DRAFT) {
260            intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message);
261        } else {
262            intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message);
263        }
264        launcher.startActivity(intent);
265    }
266
267    @Override
268    public void onCreate(Bundle savedInstanceState) {
269        super.onCreate(savedInstanceState);
270        setContentView(R.layout.compose);
271        findViews();
272        Intent intent = getIntent();
273        Account account;
274        Message message;
275        boolean showQuotedText = false;
276
277        int action;
278        if (savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE)) {
279            action = savedInstanceState.getInt(EXTRA_ACTION, COMPOSE);
280            account = savedInstanceState.getParcelable(Utils.EXTRA_ACCOUNT);
281            message = (Message) savedInstanceState.getParcelable(EXTRA_MESSAGE);
282            mRefMessage = (Message) savedInstanceState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE);
283        } else {
284            account = (Account)intent.getParcelableExtra(Utils.EXTRA_ACCOUNT);
285            action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
286            // Initialize the message from the message in the intent
287            message = (Message) intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE);
288            mRefMessage = (Message) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
289        }
290        if (account == null) {
291            final Account[] syncingAccounts = AccountUtils.getSyncingAccounts(this);
292            if (syncingAccounts.length > 0) {
293                account = syncingAccounts[0];
294            }
295        }
296
297        setAccount(account);
298        if (mAccount == null) {
299            return;
300        }
301
302        if (message != null && action != EDIT_DRAFT) {
303            initFromDraftMessage(message);
304            initQuotedTextFromRefMessage(mRefMessage, action);
305            showCcBcc(savedInstanceState);
306            showQuotedText = message.appendRefMessageContent;
307        } else if (action == EDIT_DRAFT) {
308            initFromDraftMessage(message);
309            showCcBcc(message);
310            // Update the action to the draft type of the previous draft
311            switch (message.draftType) {
312                case UIProvider.DraftType.REPLY:
313                    action = REPLY;
314                    break;
315                case UIProvider.DraftType.REPLY_ALL:
316                    action = REPLY_ALL;
317                    break;
318                case UIProvider.DraftType.FORWARD:
319                    action = FORWARD;
320                    break;
321                case UIProvider.DraftType.COMPOSE:
322                default:
323                    action = COMPOSE;
324                    break;
325            }
326            initQuotedTextFromRefMessage(mRefMessage, action);
327            showQuotedText = message.appendRefMessageContent;
328        } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
329            if (mRefMessage != null) {
330                initFromRefMessage(action, mAccount.name);
331                showCcBcc(mRefMessage);
332                showQuotedText = true;
333            }
334        } else {
335            initFromExtras(intent);
336        }
337
338        if (action == COMPOSE) {
339            mQuotedTextView.setVisibility(View.GONE);
340        }
341        initRecipients();
342        initAttachmentsFromIntent(intent);
343        initActionBar(action);
344        initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(),
345                action);
346        initChangeListeners();
347        setFocus(action);
348        updateHideOrShowCcBcc();
349        updateHideOrShowQuotedText(showQuotedText);
350    }
351
352    private void updateHideOrShowQuotedText(boolean showQuotedText) {
353        mQuotedTextView.updateCheckedState(showQuotedText);
354    }
355
356    private void setFocus(int action) {
357        if (action == EDIT_DRAFT) {
358            int type = mDraft.draftType;
359            switch (type) {
360                case UIProvider.DraftType.COMPOSE:
361                case UIProvider.DraftType.FORWARD:
362                    action = COMPOSE;
363                    break;
364                case UIProvider.DraftType.REPLY:
365                case UIProvider.DraftType.REPLY_ALL:
366                default:
367                    action = REPLY;
368                    break;
369            }
370        }
371        switch (action) {
372            case FORWARD:
373            case COMPOSE:
374                mTo.requestFocus();
375                break;
376            case REPLY:
377            case REPLY_ALL:
378            default:
379                focusBody();
380                break;
381        }
382    }
383
384    /**
385     * Focus the body of the message.
386     */
387    public void focusBody() {
388        mBodyView.requestFocus();
389        int length = mBodyView.getText().length();
390
391        int signatureStartPos = getSignatureStartPosition(
392                mSignature, mBodyView.getText().toString());
393        if (signatureStartPos > -1) {
394            // In case the user deleted the newlines...
395            mBodyView.setSelection(signatureStartPos);
396        } else if (length > 0) {
397            // Move cursor to the end.
398            mBodyView.setSelection(length);
399        }
400    }
401
402    @Override
403    protected void onResume() {
404        super.onResume();
405        // Update the from spinner as other accounts
406        // may now be available.
407        if (mFromSpinner != null && mAccount != null) {
408            mFromSpinner.asyncInitFromSpinner(mComposeMode, mAccount);
409        }
410    }
411
412    @Override
413    protected void onPause() {
414        super.onPause();
415
416        if (mSendConfirmDialog != null) {
417            mSendConfirmDialog.dismiss();
418        }
419        if (mRecipientErrorDialog != null) {
420            mRecipientErrorDialog.dismiss();
421        }
422        saveIfNeeded();
423    }
424
425    @Override
426    protected final void onActivityResult(int request, int result, Intent data) {
427        mAddingAttachment = false;
428
429        if (result == RESULT_OK && request == RESULT_PICK_ATTACHMENT) {
430            addAttachmentAndUpdateView(data);
431        }
432    }
433
434    @Override
435    public final void onRestoreInstanceState(Bundle savedInstanceState) {
436        super.onRestoreInstanceState(savedInstanceState);
437        if (savedInstanceState != null) {
438            if (savedInstanceState.containsKey(EXTRA_FOCUS_SELECTION_START)) {
439                int selectionStart = savedInstanceState.getInt(EXTRA_FOCUS_SELECTION_START);
440                int selectionEnd = savedInstanceState.getInt(EXTRA_FOCUS_SELECTION_END);
441                // There should be a focus and it should be an EditText since we
442                // only save these extras if these conditions are true.
443                EditText focusEditText = (EditText) getCurrentFocus();
444                final int length = focusEditText.getText().length();
445                if (selectionStart < length && selectionEnd < length) {
446                    focusEditText.setSelection(selectionStart, selectionEnd);
447                }
448            }
449        }
450    }
451
452    @Override
453    public final void onSaveInstanceState(Bundle state) {
454        super.onSaveInstanceState(state);
455        // The framework is happy to save and restore the selection but only if it also saves and
456        // restores the contents of the edit text. That's a lot of text to put in a bundle so we do
457        // this manually.
458        View focus = getCurrentFocus();
459        if (focus != null && focus instanceof EditText) {
460            EditText focusEditText = (EditText) focus;
461            state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart());
462            state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd());
463        }
464
465        final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
466        final int selectedPos = mFromSpinner.getSelectedItemPosition();
467        final ReplyFromAccount selectedReplyFromAccount =
468                (replyFromAccounts.size() > 0 && replyFromAccounts.size() > selectedPos) ?
469                        replyFromAccounts.get(selectedPos) :
470                        null;
471        if (selectedReplyFromAccount != null) {
472            state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize()
473                    .toString());
474            state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account);
475        } else {
476            state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount);
477        }
478
479        if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId !=0) {
480            // We don't have a draft id, and we have a request id,
481            // save the request id.
482            state.putInt(EXTRA_REQUEST_ID, mRequestId);
483        }
484
485        // We want to restore the current mode after a pause
486        // or rotation.
487        int mode = getMode();
488        state.putInt(EXTRA_ACTION, mode);
489
490        Message message = createMessage(selectedReplyFromAccount, mode);
491        state.putParcelable(EXTRA_MESSAGE, message);
492
493        if (mRefMessage != null) {
494            state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage);
495        }
496        state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible());
497        state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible());
498    }
499
500    private int getMode() {
501        int mode = ComposeActivity.COMPOSE;
502        ActionBar actionBar = getActionBar();
503        if (actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) {
504            mode = actionBar.getSelectedNavigationIndex();
505        }
506        return mode;
507    }
508
509    private Message createMessage(ReplyFromAccount selectedReplyFromAccount, int mode) {
510        Message message = new Message();
511        message.id = UIProvider.INVALID_MESSAGE_ID;
512        message.serverId =UIProvider.INVALID_MESSAGE_ID;
513        message.uri = null;
514        message.conversationUri = null;
515        message.subject = mSubject.getText().toString();
516        message.snippet = null;
517        message.from = selectedReplyFromAccount != null ?
518                selectedReplyFromAccount.name : mAccount.name;
519        message.to = mTo.getText().toString();
520        message.cc = mCc.getText().toString();
521        message.bcc = mBcc.getText().toString();
522        message.replyTo = null;
523        message.dateReceivedMs = 0;
524        String htmlBody = Html.toHtml(mBodyView.getText());
525        StringBuilder fullBody = new StringBuilder(htmlBody);
526        message.bodyHtml = fullBody.toString();
527        message.bodyText = mBodyView.getText().toString();
528        message.embedsExternalResources = false;
529        message.refMessageId = mRefMessage != null ? mRefMessage.uri.toString() : null;
530        message.draftType = mode;
531        message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null;
532        ArrayList<Attachment> attachments = mAttachmentsView.getAttachments();
533        message.hasAttachments = attachments != null && attachments.size() > 0;
534        message.attachmentListUri = null;
535        message.messageFlags = 0;
536        message.saveUri = null;
537        message.sendUri = null;
538        message.alwaysShowImages = false;
539        message.attachmentsJson = Attachment.toJSONArray(attachments);
540        CharSequence quotedText = mQuotedTextView.getQuotedText();
541        message.quotedTextOffset = !TextUtils.isEmpty(quotedText) ? QuotedTextView
542                .getQuotedTextOffset(quotedText.toString()) : -1;
543        message.accountUri = null;
544        return message;
545    }
546
547    @VisibleForTesting
548    void setAccount(Account account) {
549        if (account == null) {
550            return;
551        }
552        if (!account.equals(mAccount)) {
553            mAccount = account;
554            mCachedSettings = mAccount.settings;
555            appendSignature();
556        }
557    }
558
559    private void initFromSpinner(Bundle bundle, int action) {
560        String accountString = null;
561        if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) {
562            action = COMPOSE;
563        }
564        mFromSpinner.asyncInitFromSpinner(action, mAccount);
565        if (bundle != null) {
566            if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) {
567                mReplyFromAccount = ReplyFromAccount.deserialize(mAccount,
568                        bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT));
569            } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) {
570                accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING);
571                mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString);
572            }
573        }
574        if (mReplyFromAccount == null) {
575            if (mDraft != null) {
576                mReplyFromAccount = getReplyFromAccountFromDraft(mAccount, mDraft);
577            } else if (mRefMessage != null) {
578                mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage);
579            }
580        }
581        if (mReplyFromAccount == null) {
582            mReplyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name,
583                    mAccount.name, true, false);
584        }
585
586        mFromSpinner.setCurrentAccount(mReplyFromAccount);
587
588        if (mFromSpinner.getCount() > 1) {
589            // If there is only 1 account, just show that account.
590            // Otherwise, give the user the ability to choose which account to
591            // send mail from / save drafts to.
592            mFromStatic.setVisibility(View.GONE);
593            mFromStaticText.setText(mAccount.name);
594            mFromSpinnerWrapper.setVisibility(View.VISIBLE);
595        } else {
596            mFromStatic.setVisibility(View.VISIBLE);
597            mFromStaticText.setText(mAccount.name);
598            mFromSpinnerWrapper.setVisibility(View.GONE);
599        }
600    }
601
602    private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) {
603        if (refMessage.accountUri != null) {
604            // This must be from combined inbox.
605            List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
606            for (ReplyFromAccount from : replyFromAccounts) {
607                if (from.account.uri.equals(refMessage.accountUri)) {
608                    return from;
609                }
610            }
611            return null;
612        } else {
613            return getReplyFromAccount(account, refMessage);
614        }
615    }
616
617    /**
618     * Given an account and which email address the message was sent to,
619     * return who the message should be sent from.
620     * @param account Account in which the message arrived.
621     * @param sentTo Email address to which the message was sent.
622     * @return the address from which to reply.
623     */
624    public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) {
625        // First see if we are supposed to use the default address or
626        // the address it was sentTo.
627        if (mCachedSettings.forceReplyFromDefault) {
628            return getDefaultReplyFromAccount(account);
629        } else {
630            // If we aren't explicityly told which account to look for, look at
631            // all the message recipients and find one that matches
632            // a custom from or account.
633            List<String> allRecipients = new ArrayList<String>();
634            allRecipients.addAll(Arrays.asList(Utils.splitCommaSeparatedString(refMessage.to)));
635            allRecipients.addAll(Arrays.asList(Utils.splitCommaSeparatedString(refMessage.cc)));
636            return getMatchingRecipient(account, allRecipients);
637        }
638    }
639
640    /**
641     * Compare all the recipients of an email to the current account and all
642     * custom addresses associated with that account. Return the match if there
643     * is one, or the default account if there isn't.
644     */
645    protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) {
646        // Tokenize the list and place in a hashmap.
647        ReplyFromAccount matchingReplyFrom = null;
648        Rfc822Token[] tokens;
649        HashSet<String> recipientsMap = new HashSet<String>();
650        for (String address : sentTo) {
651            tokens = Rfc822Tokenizer.tokenize(address);
652            for (int i = 0; i < tokens.length; i++) {
653                recipientsMap.add(tokens[i].getAddress());
654            }
655        }
656
657        int matchingAddressCount = 0;
658        List<ReplyFromAccount> customFroms;
659        try {
660            customFroms = FromAddressSpinner.getAccountSpecificFroms(account);
661            if (customFroms != null) {
662                for (ReplyFromAccount entry : customFroms) {
663                    if (recipientsMap.contains(entry.address)) {
664                        matchingReplyFrom = entry;
665                        matchingAddressCount++;
666                    }
667                }
668            }
669        } catch (JSONException e) {
670            LogUtils.wtf(LOG_TAG, "Exception parsing from addresses for account %s",
671                    account.name);
672        }
673        if (matchingAddressCount > 1) {
674            matchingReplyFrom = getDefaultReplyFromAccount(account);
675        }
676        return matchingReplyFrom;
677    }
678
679    private ReplyFromAccount getDefaultReplyFromAccount(Account account) {
680        List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
681        for (ReplyFromAccount from : replyFromAccounts) {
682            if (from.isDefault) {
683                return from;
684            }
685        }
686        return new ReplyFromAccount(account, account.uri, account.name, account.name, true, false);
687    }
688
689    private ReplyFromAccount getReplyFromAccountFromDraft(Account account, Message msg) {
690        String sender = msg.from;
691        ReplyFromAccount replyFromAccount = null;
692        List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
693        if (TextUtils.equals(account.name, sender)) {
694            replyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name,
695                    mAccount.name, true, false);
696        } else {
697            for (ReplyFromAccount fromAccount : replyFromAccounts) {
698                if (TextUtils.equals(fromAccount.name, sender)) {
699                    replyFromAccount = fromAccount;
700                    break;
701                }
702            }
703        }
704        return replyFromAccount;
705    }
706
707    private void findViews() {
708        mCcBccButton = (Button) findViewById(R.id.add_cc_bcc);
709        if (mCcBccButton != null) {
710            mCcBccButton.setOnClickListener(this);
711        }
712        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
713        mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments);
714        mAttachmentsButton = (ImageView) findViewById(R.id.add_attachment);
715        if (mAttachmentsButton != null) {
716            mAttachmentsButton.setOnClickListener(this);
717        }
718        mTo = (RecipientEditTextView) findViewById(R.id.to);
719        mCc = (RecipientEditTextView) findViewById(R.id.cc);
720        mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
721        // TODO: add special chips text change watchers before adding
722        // this as a text changed watcher to the to, cc, bcc fields.
723        mSubject = (TextView) findViewById(R.id.subject);
724        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
725        mQuotedTextView.setRespondInlineListener(this);
726        mBodyView = (EditText) findViewById(R.id.body);
727        mFromStatic = findViewById(R.id.static_from_content);
728        mFromStaticText = (TextView) findViewById(R.id.from_account_name);
729        mFromSpinnerWrapper = findViewById(R.id.spinner_from_content);
730        mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker);
731    }
732
733    // Now that the message has been initialized from any existing draft or
734    // ref message data, set up listeners for any changes that occur to the
735    // message.
736    private void initChangeListeners() {
737        mSubject.addTextChangedListener(this);
738        mBodyView.addTextChangedListener(this);
739        mTo.addTextChangedListener(new RecipientTextWatcher(mTo, this));
740        mCc.addTextChangedListener(new RecipientTextWatcher(mCc, this));
741        mBcc.addTextChangedListener(new RecipientTextWatcher(mBcc, this));
742        mFromSpinner.setOnAccountChangedListener(this);
743        mAttachmentsView.setAttachmentChangesListener(this);
744    }
745
746    private void initActionBar(int action) {
747        mComposeMode = action;
748        ActionBar actionBar = getActionBar();
749        if (action == ComposeActivity.COMPOSE) {
750            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
751            actionBar.setTitle(R.string.compose);
752        } else {
753            actionBar.setTitle(null);
754            if (mComposeModeAdapter == null) {
755                mComposeModeAdapter = new ComposeModeAdapter(this);
756            }
757            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
758            actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
759            switch (action) {
760                case ComposeActivity.REPLY:
761                    actionBar.setSelectedNavigationItem(0);
762                    break;
763                case ComposeActivity.REPLY_ALL:
764                    actionBar.setSelectedNavigationItem(1);
765                    break;
766                case ComposeActivity.FORWARD:
767                    actionBar.setSelectedNavigationItem(2);
768                    break;
769            }
770        }
771        actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME,
772                ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME);
773        actionBar.setHomeButtonEnabled(true);
774    }
775
776    private void initFromRefMessage(int action, String recipientAddress) {
777        setSubject(mRefMessage, action);
778        // Setup recipients
779        if (action == FORWARD) {
780            mForward = true;
781        }
782        initRecipientsFromRefMessage(recipientAddress, mRefMessage, action);
783        initQuotedTextFromRefMessage(mRefMessage, action);
784        if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
785            initAttachments(mRefMessage);
786        }
787    }
788
789    private void initFromDraftMessage(Message message) {
790        LogUtils.d(LOG_TAG, "Intializing draft from previous draft message");
791
792        mDraft = message;
793        mDraftId = message.id;
794        mSubject.setText(message.subject);
795        mForward = message.draftType == UIProvider.DraftType.FORWARD;
796        final List<String> toAddresses = Arrays.asList(message.getToAddresses());
797        addToAddresses(toAddresses);
798        addCcAddresses(Arrays.asList(message.getCcAddresses()), toAddresses);
799        addBccAddresses(Arrays.asList(message.getBccAddresses()));
800        if (message.hasAttachments) {
801            List<Attachment> attachments = message.getAttachments();
802            for (Attachment a : attachments) {
803                addAttachmentAndUpdateView(a);
804            }
805        }
806        int quotedTextIndex = message.appendRefMessageContent && !mForward ?
807                message.quotedTextOffset : -1;
808        // Set the body
809        CharSequence quotedText = null;
810        if (!TextUtils.isEmpty(message.bodyHtml)) {
811            CharSequence htmlText = Html.fromHtml(message.bodyHtml);
812            if (quotedTextIndex > -1) {
813                htmlText = htmlText.subSequence(0, quotedTextIndex);
814                quotedText = message.bodyHtml.subSequence(quotedTextIndex,
815                        message.bodyHtml.length());
816            }
817            mBodyView.setText(htmlText);
818        } else {
819            CharSequence bodyText = quotedTextIndex > -1 ?
820                    message.bodyText.substring(0, quotedTextIndex) : message.bodyText;
821            if (quotedTextIndex > -1) {
822                quotedText = message.bodyText.substring(quotedTextIndex);
823            }
824            mBodyView.setText(bodyText);
825        }
826        if (quotedTextIndex > -1 && quotedText != null) {
827            mQuotedTextView.setQuotedTextFromDraft(quotedText);
828        }
829    }
830
831    /**
832     * Fill all the widgets with the content found in the Intent Extra, if any.
833     * Also apply the same style to all widgets. Note: if initFromExtras is
834     * called as a result of switching between reply, reply all, and forward per
835     * the latest revision of Gmail, and the user has already made changes to
836     * attachments on a previous incarnation of the message (as a reply, reply
837     * all, or forward), the original attachments from the message will not be
838     * re-instantiated. The user's changes will be respected. This follows the
839     * web gmail interaction.
840     */
841    public void initFromExtras(Intent intent) {
842        // If we were invoked with a SENDTO intent, the value
843        // should take precedence
844        final Uri dataUri = intent.getData();
845        if (dataUri != null) {
846            if (MAIL_TO.equals(dataUri.getScheme())) {
847                initFromMailTo(dataUri.toString());
848            } else {
849                if (!mAccount.composeIntentUri.equals(dataUri)) {
850                    String toText = dataUri.getSchemeSpecificPart();
851                    if (toText != null) {
852                        mTo.setText("");
853                        addToAddresses(Arrays.asList(TextUtils.split(toText, ",")));
854                    }
855                }
856            }
857        }
858
859        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
860        if (extraStrings != null) {
861            addToAddresses(Arrays.asList(extraStrings));
862        }
863        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
864        if (extraStrings != null) {
865            addCcAddresses(Arrays.asList(extraStrings), null);
866        }
867        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
868        if (extraStrings != null) {
869            addBccAddresses(Arrays.asList(extraStrings));
870        }
871
872        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
873        if (extraString != null) {
874            mSubject.setText(extraString);
875        }
876
877        for (String extra : ALL_EXTRAS) {
878            if (intent.hasExtra(extra)) {
879                String value = intent.getStringExtra(extra);
880                if (EXTRA_TO.equals(extra)) {
881                    addToAddresses(Arrays.asList(TextUtils.split(value, ",")));
882                } else if (EXTRA_CC.equals(extra)) {
883                    addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null);
884                } else if (EXTRA_BCC.equals(extra)) {
885                    addBccAddresses(Arrays.asList(TextUtils.split(value, ",")));
886                } else if (EXTRA_SUBJECT.equals(extra)) {
887                    mSubject.setText(value);
888                } else if (EXTRA_BODY.equals(extra)) {
889                    setBody(value, true /* with signature */);
890                }
891            }
892        }
893
894        Bundle extras = intent.getExtras();
895        if (extras != null) {
896            CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT);
897            if (text != null) {
898                setBody(text, true /* with signature */);
899            }
900        }
901    }
902
903    @VisibleForTesting
904    protected String decodeEmailInUri(String s) throws UnsupportedEncodingException {
905        // TODO: handle the case where there are spaces in the display name as well as the email
906        // such as "Guy with spaces <guy+with+spaces@gmail.com>" as they it could be encoded
907        // ambiguously.
908
909        // Since URLDecode.decode changes + into ' ', and + is a valid
910        // email character, we need to find/ replace these ourselves before
911        // decoding.
912        String replacePlus = s.replace("+", "%2B");
913        return URLDecoder.decode(replacePlus, UTF8_ENCODING_NAME);
914    }
915
916    /**
917     * Initialize the compose view from a String representing a mailTo uri.
918     * @param mailToString The uri as a string.
919     */
920    public void initFromMailTo(String mailToString) {
921        // We need to disguise this string as a URI in order to parse it
922        // TODO:  Remove this hack when http://b/issue?id=1445295 gets fixed
923        Uri uri = Uri.parse("foo://" + mailToString);
924        int index = mailToString.indexOf("?");
925        int length = "mailto".length() + 1;
926        String to;
927        try {
928            // Extract the recipient after mailto:
929            if (index == -1) {
930                to = decodeEmailInUri(mailToString.substring(length));
931            } else {
932                to = decodeEmailInUri(mailToString.substring(length, index));
933            }
934            addToAddresses(Arrays.asList(TextUtils.split(to, ",")));
935        } catch (UnsupportedEncodingException e) {
936            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
937                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString);
938            } else {
939                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
940            }
941        }
942
943        List<String> cc = uri.getQueryParameters("cc");
944        addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null);
945
946        List<String> otherTo = uri.getQueryParameters("to");
947        addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()])));
948
949        List<String> bcc = uri.getQueryParameters("bcc");
950        addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()])));
951
952        List<String> subject = uri.getQueryParameters("subject");
953        if (subject.size() > 0) {
954            try {
955                mSubject.setText(URLDecoder.decode(subject.get(0), UTF8_ENCODING_NAME));
956            } catch (UnsupportedEncodingException e) {
957                LogUtils.e(LOG_TAG, "%s while decoding subject '%s'",
958                        e.getMessage(), subject);
959            }
960        }
961
962        List<String> body = uri.getQueryParameters("body");
963        if (body.size() > 0) {
964            try {
965                setBody(URLDecoder.decode(body.get(0), UTF8_ENCODING_NAME),
966                        true /* with signature */);
967            } catch (UnsupportedEncodingException e) {
968                LogUtils.e(LOG_TAG, "%s while decoding body '%s'", e.getMessage(), body);
969            }
970        }
971    }
972
973    private void initAttachments(Message refMessage) {
974        mAttachmentsView.addAttachments(mAccount, refMessage);
975    }
976
977    private void initAttachmentsFromIntent(Intent intent) {
978        Bundle extras = intent.getExtras();
979        if (extras == null) {
980            extras = Bundle.EMPTY;
981        }
982        final String action = intent.getAction();
983        if (!mAttachmentsChanged) {
984            long totalSize = 0;
985            if (extras.containsKey(EXTRA_ATTACHMENTS)) {
986                String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS);
987                for (String uriString : uris) {
988                    final Uri uri = Uri.parse(uriString);
989                    long size = 0;
990                    try {
991                        size =  mAttachmentsView.addAttachment(mAccount, uri);
992                    } catch (AttachmentFailureException e) {
993                        // A toast has already been shown to the user,
994                        // just break out of the loop.
995                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
996                    }
997                    totalSize += size;
998                }
999            }
1000            if (Intent.ACTION_SEND.equals(action) && extras.containsKey(Intent.EXTRA_STREAM)) {
1001                final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM);
1002                long size = 0;
1003                try {
1004                    size =  mAttachmentsView.addAttachment(mAccount, uri);
1005                } catch (AttachmentFailureException e) {
1006                    // A toast has already been shown to the user, so just
1007                    // exit.
1008                    LogUtils.e(LOG_TAG, e, "Error adding attachment");
1009                }
1010                totalSize += size;
1011            }
1012
1013            if (Intent.ACTION_SEND_MULTIPLE.equals(action)
1014                    && extras.containsKey(Intent.EXTRA_STREAM)) {
1015                ArrayList<Parcelable> uris = extras.getParcelableArrayList(Intent.EXTRA_STREAM);
1016                for (Parcelable uri : uris) {
1017                    long size = 0;
1018                    try {
1019                        size = mAttachmentsView.addAttachment(mAccount, (Uri) uri);
1020                    } catch (AttachmentFailureException e) {
1021                        // A toast has already been shown to the user,
1022                        // just break out of the loop.
1023                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1024                    }
1025                    totalSize += size;
1026                }
1027            }
1028
1029            if (totalSize > 0) {
1030                mAttachmentsChanged = true;
1031                updateSaveUi();
1032            }
1033        }
1034    }
1035
1036
1037    private void initQuotedTextFromRefMessage(Message refMessage, int action) {
1038        if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) {
1039            mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD);
1040        }
1041    }
1042
1043    private void updateHideOrShowCcBcc() {
1044        // Its possible there is a menu item OR a button.
1045        boolean ccVisible = mCcBccView.isCcVisible();
1046        boolean bccVisible = mCcBccView.isBccVisible();
1047        if (mCcBccButton != null) {
1048            if (!ccVisible || !bccVisible) {
1049                mCcBccButton.setVisibility(View.VISIBLE);
1050                mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label
1051                        : R.string.add_bcc_label));
1052            } else {
1053                mCcBccButton.setVisibility(View.GONE);
1054            }
1055        }
1056    }
1057
1058    private void showCcBcc(Bundle state) {
1059        if (state != null && state.containsKey(EXTRA_SHOW_CC)) {
1060            boolean showCc = state.getBoolean(EXTRA_SHOW_CC);
1061            boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC);
1062            if (showCc || showBcc) {
1063                mCcBccView.show(false, showCc, showBcc);
1064            }
1065        }
1066    }
1067
1068    private void showCcBcc(Message refMessage) {
1069        if (refMessage != null) {
1070            boolean showCc = !TextUtils.isEmpty(refMessage.cc);
1071            boolean showBcc = !TextUtils.isEmpty(refMessage.bcc);
1072            if (showCc || showBcc) {
1073                mCcBccView.show(false, showCc, showBcc);
1074            }
1075        }
1076        updateHideOrShowCcBcc();
1077    }
1078
1079    /**
1080     * Add attachment and update the compose area appropriately.
1081     * @param data
1082     */
1083    public void addAttachmentAndUpdateView(Intent data) {
1084        addAttachmentAndUpdateView(data != null ? data.getData() : (Uri) null);
1085    }
1086
1087    public void addAttachmentAndUpdateView(Uri contentUri) {
1088        if (contentUri == null) {
1089            return;
1090        }
1091        try {
1092            addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri));
1093        } catch (AttachmentFailureException e) {
1094            // A toast has already been shown to the user, no need to do
1095            // anything.
1096            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1097        }
1098    }
1099
1100    public void addAttachmentAndUpdateView(Attachment attachment) {
1101        try {
1102            long size =  mAttachmentsView.addAttachment(mAccount, attachment);
1103            if (size > 0) {
1104                mAttachmentsChanged = true;
1105                updateSaveUi();
1106            }
1107        } catch (AttachmentFailureException e) {
1108            // A toast has already been shown to the user, no need to do
1109            // anything.
1110            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1111        }
1112    }
1113
1114    void initRecipientsFromRefMessage(String recipientAddress, Message refMessage,
1115            int action) {
1116        // Don't populate the address if this is a forward.
1117        if (action == ComposeActivity.FORWARD) {
1118            return;
1119        }
1120        initReplyRecipients(mAccount.name, refMessage, action);
1121    }
1122
1123    @VisibleForTesting
1124    void initReplyRecipients(String account, Message refMessage, int action) {
1125        // This is the email address of the current user, i.e. the one composing
1126        // the reply.
1127        final String accountEmail = Address.getEmailAddress(account).getAddress();
1128        String fromAddress = refMessage.from;
1129        String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage.to);
1130        String replytoAddress = refMessage.replyTo;
1131        final Collection<String> toAddresses;
1132
1133        // If this is a reply, the Cc list is empty. If this is a reply-all, the
1134        // Cc list is the union of the To and Cc recipients of the original
1135        // message, excluding the current user's email address and any addresses
1136        // already on the To list.
1137        if (action == ComposeActivity.REPLY) {
1138            toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress,
1139                    new String[0]);
1140            addToAddresses(toAddresses);
1141        } else if (action == ComposeActivity.REPLY_ALL) {
1142            final Set<String> ccAddresses = Sets.newHashSet();
1143            toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress,
1144                    new String[0]);
1145            addToAddresses(toAddresses);
1146            addRecipients(accountEmail, ccAddresses, sentToAddresses);
1147            addRecipients(accountEmail, ccAddresses,
1148                    Utils.splitCommaSeparatedString(refMessage.cc));
1149            addCcAddresses(ccAddresses, toAddresses);
1150        }
1151    }
1152
1153    private void addToAddresses(Collection<String> addresses) {
1154        addAddressesToList(addresses, mTo);
1155    }
1156
1157    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
1158        addCcAddressesToList(tokenizeAddressList(addresses),
1159                toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc);
1160    }
1161
1162    private void addBccAddresses(Collection<String> addresses) {
1163        addAddressesToList(addresses, mBcc);
1164    }
1165
1166    @VisibleForTesting
1167    protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
1168            List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
1169        String address;
1170
1171        if (compareToList == null) {
1172            for (Rfc822Token[] tokens : addresses) {
1173                for (int i = 0; i < tokens.length; i++) {
1174                    address = tokens[i].toString();
1175                    list.append(address + END_TOKEN);
1176                }
1177            }
1178        } else {
1179            HashSet<String> compareTo = convertToHashSet(compareToList);
1180            for (Rfc822Token[] tokens : addresses) {
1181                for (int i = 0; i < tokens.length; i++) {
1182                    address = tokens[i].toString();
1183                    // Check if this is a duplicate:
1184                    if (!compareTo.contains(tokens[i].getAddress())) {
1185                        // Get the address here
1186                        list.append(address + END_TOKEN);
1187                    }
1188                }
1189            }
1190        }
1191    }
1192
1193    private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) {
1194        HashSet<String> hash = new HashSet<String>();
1195        for (Rfc822Token[] tokens : list) {
1196            for (int i = 0; i < tokens.length; i++) {
1197                hash.add(tokens[i].getAddress());
1198            }
1199        }
1200        return hash;
1201    }
1202
1203    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
1204        @VisibleForTesting
1205        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
1206
1207        for (String address: addresses) {
1208            tokenized.add(Rfc822Tokenizer.tokenize(address));
1209        }
1210        return tokenized;
1211    }
1212
1213    @VisibleForTesting
1214    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
1215        for (String address : addresses) {
1216            addAddressToList(address, list);
1217        }
1218    }
1219
1220    private void addAddressToList(String address, RecipientEditTextView list) {
1221        if (address == null || list == null)
1222            return;
1223
1224        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
1225
1226        for (int i = 0; i < tokens.length; i++) {
1227            list.append(tokens[i] + END_TOKEN);
1228        }
1229    }
1230
1231    @VisibleForTesting
1232    protected Collection<String> initToRecipients(String account, String accountEmail,
1233            String senderAddress, String replyToAddress, String[] inToAddresses) {
1234        // The To recipient is the reply-to address specified in the original
1235        // message, unless it is:
1236        // the current user OR a custom from of the current user, in which case
1237        // it's the To recipient list of the original message.
1238        // OR missing, in which case use the sender of the original message
1239        Set<String> toAddresses = Sets.newHashSet();
1240        if (!TextUtils.isEmpty(replyToAddress)) {
1241            toAddresses.add(replyToAddress);
1242        } else {
1243            toAddresses.add(senderAddress);
1244        }
1245        return toAddresses;
1246    }
1247
1248    private static void addRecipients(String account, Set<String> recipients, String[] addresses) {
1249        for (String email : addresses) {
1250            // Do not add this account, or any of the custom froms, to the list
1251            // of recipients.
1252            final String recipientAddress = Address.getEmailAddress(email).getAddress();
1253            if (!account.equalsIgnoreCase(recipientAddress)) {
1254                recipients.add(email.replace("\"\"", ""));
1255            }
1256        }
1257    }
1258
1259    private void setSubject(Message refMessage, int action) {
1260        String subject = refMessage.subject;
1261        String prefix;
1262        String correctedSubject = null;
1263        if (action == ComposeActivity.COMPOSE) {
1264            prefix = "";
1265        } else if (action == ComposeActivity.FORWARD) {
1266            prefix = getString(R.string.forward_subject_label);
1267        } else {
1268            prefix = getString(R.string.reply_subject_label);
1269        }
1270
1271        // Don't duplicate the prefix
1272        if (subject.toLowerCase().startsWith(prefix.toLowerCase())) {
1273            correctedSubject = subject;
1274        } else {
1275            correctedSubject = String
1276                    .format(getString(R.string.formatted_subject), prefix, subject);
1277        }
1278        mSubject.setText(correctedSubject);
1279    }
1280
1281    private void initRecipients() {
1282        setupRecipients(mTo);
1283        setupRecipients(mCc);
1284        setupRecipients(mBcc);
1285    }
1286
1287    private void setupRecipients(RecipientEditTextView view) {
1288        view.setAdapter(new RecipientAdapter(this, mAccount));
1289        view.setTokenizer(new Rfc822Tokenizer());
1290        if (mValidator == null) {
1291            final String accountName = mAccount.name;
1292            int offset = accountName.indexOf("@") + 1;
1293            String account = accountName;
1294            if (offset > -1) {
1295                account = account.substring(accountName.indexOf("@") + 1);
1296            }
1297            mValidator = new Rfc822Validator(account);
1298        }
1299        view.setValidator(mValidator);
1300    }
1301
1302    @Override
1303    public void onClick(View v) {
1304        int id = v.getId();
1305        switch (id) {
1306            case R.id.add_cc_bcc:
1307                // Verify that cc/ bcc aren't showing.
1308                // Animate in cc/bcc.
1309                showCcBccViews();
1310                break;
1311            case R.id.add_attachment:
1312                doAttach();
1313                break;
1314        }
1315    }
1316
1317    @Override
1318    public boolean onCreateOptionsMenu(Menu menu) {
1319        super.onCreateOptionsMenu(menu);
1320        MenuInflater inflater = getMenuInflater();
1321        inflater.inflate(R.menu.compose_menu, menu);
1322        mSave = menu.findItem(R.id.save);
1323        mSend = menu.findItem(R.id.send);
1324        MenuItem helpItem = menu.findItem(R.id.help_info_menu_item);
1325        MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item);
1326        if (helpItem != null) {
1327            helpItem.setVisible(mAccount != null
1328                    && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT));
1329        }
1330        if (sendFeedbackItem != null) {
1331            sendFeedbackItem.setVisible(mAccount != null
1332                    && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK));
1333        }
1334        return true;
1335    }
1336
1337    @Override
1338    public boolean onPrepareOptionsMenu(Menu menu) {
1339        MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
1340        if (ccBcc != null && mCc != null) {
1341            // Its possible there is a menu item OR a button.
1342            boolean ccFieldVisible = mCc.isShown();
1343            boolean bccFieldVisible = mBcc.isShown();
1344            if (!ccFieldVisible || !bccFieldVisible) {
1345                ccBcc.setVisible(true);
1346                ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
1347                        : R.string.add_bcc_label));
1348            } else {
1349                ccBcc.setVisible(false);
1350            }
1351        }
1352        if (mSave != null) {
1353            mSave.setEnabled(shouldSave());
1354        }
1355        return true;
1356    }
1357
1358    @Override
1359    public boolean onOptionsItemSelected(MenuItem item) {
1360        int id = item.getItemId();
1361        boolean handled = true;
1362        switch (id) {
1363            case R.id.add_attachment:
1364                doAttach();
1365                break;
1366            case R.id.add_cc_bcc:
1367                showCcBccViews();
1368                break;
1369            case R.id.save:
1370                doSave(true, false);
1371                break;
1372            case R.id.send:
1373                doSend();
1374                break;
1375            case R.id.discard:
1376                doDiscard();
1377                break;
1378            case R.id.settings:
1379                Utils.showSettings(this, mAccount);
1380                break;
1381            case android.R.id.home:
1382                finish();
1383                break;
1384            case R.id.help_info_menu_item:
1385                // TODO: enable context sensitive help
1386                Utils.showHelp(this, mAccount, null);
1387                break;
1388            case R.id.feedback_menu_item:
1389                Utils.sendFeedback(this, mAccount);
1390                break;
1391            default:
1392                handled = false;
1393                break;
1394        }
1395        return !handled ? super.onOptionsItemSelected(item) : handled;
1396    }
1397
1398    private void doSend() {
1399        sendOrSaveWithSanityChecks(false, true, false);
1400    }
1401
1402    private void doSave(boolean showToast, boolean resetIME) {
1403        sendOrSaveWithSanityChecks(true, showToast, false);
1404        if (resetIME) {
1405            // Clear the IME composing suggestions from the body.
1406            BaseInputConnection.removeComposingSpans(mBodyView.getEditableText());
1407        }
1408    }
1409
1410    /*package*/ interface SendOrSaveCallback {
1411        public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask);
1412        public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
1413        public Message getMessage();
1414        public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success);
1415    }
1416
1417    /*package*/ static class SendOrSaveTask implements Runnable {
1418        private final Context mContext;
1419        private final SendOrSaveCallback mSendOrSaveCallback;
1420        @VisibleForTesting
1421        final SendOrSaveMessage mSendOrSaveMessage;
1422
1423        public SendOrSaveTask(Context context, SendOrSaveMessage message,
1424                SendOrSaveCallback callback) {
1425            mContext = context;
1426            mSendOrSaveCallback = callback;
1427            mSendOrSaveMessage = message;
1428        }
1429
1430        @Override
1431        public void run() {
1432            final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage;
1433
1434            final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount;
1435            Message message = mSendOrSaveCallback.getMessage();
1436            long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID;
1437            // If a previous draft has been saved, in an account that is different
1438            // than what the user wants to send from, remove the old draft, and treat this
1439            // as a new message
1440            if (!selectedAccount.equals(sendOrSaveMessage.mAccount)) {
1441                if (messageId != UIProvider.INVALID_MESSAGE_ID) {
1442                    ContentResolver resolver = mContext.getContentResolver();
1443                    ContentValues values = new ContentValues();
1444                    values.put(BaseColumns._ID, messageId);
1445                    if (selectedAccount.account.expungeMessageUri != null) {
1446                        resolver.update(selectedAccount.account.expungeMessageUri, values, null,
1447                                null);
1448                    } else {
1449                        // TODO(mindyp) delete the conversation.
1450                    }
1451                    // reset messageId to 0, so a new message will be created
1452                    messageId = UIProvider.INVALID_MESSAGE_ID;
1453                }
1454            }
1455
1456            final long messageIdToSave = messageId;
1457            if (messageIdToSave != UIProvider.INVALID_MESSAGE_ID) {
1458                sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
1459                mContext.getContentResolver().update(
1460                        Uri.parse(sendOrSaveMessage.mSave ? message.saveUri : message.sendUri),
1461                        sendOrSaveMessage.mValues, null, null);
1462            } else {
1463                ContentResolver resolver = mContext.getContentResolver();
1464                Uri messageUri = resolver
1465                        .insert(sendOrSaveMessage.mSave ? selectedAccount.account.saveDraftUri
1466                                : selectedAccount.account.sendMessageUri,
1467                                sendOrSaveMessage.mValues);
1468                if (sendOrSaveMessage.mSave && messageUri != null) {
1469                    Cursor messageCursor = resolver.query(messageUri,
1470                            UIProvider.MESSAGE_PROJECTION, null, null, null);
1471                    if (messageCursor != null) {
1472                        try {
1473                            if (messageCursor.moveToFirst()) {
1474                                // Broadcast notification that a new message has
1475                                // been allocated
1476                                mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
1477                                        new Message(messageCursor));
1478                            }
1479                        } finally {
1480                            messageCursor.close();
1481                        }
1482                    }
1483                }
1484            }
1485
1486            if (!sendOrSaveMessage.mSave) {
1487                UIProvider.incrementRecipientsTimesContacted(mContext,
1488                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO));
1489                UIProvider.incrementRecipientsTimesContacted(mContext,
1490                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC));
1491                UIProvider.incrementRecipientsTimesContacted(mContext,
1492                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
1493            }
1494            mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
1495        }
1496    }
1497
1498    // Array of the outstanding send or save tasks.  Access is synchronized
1499    // with the object itself
1500    /* package for testing */
1501    ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList();
1502    private int mRequestId;
1503    private String mSignature;
1504
1505    /*package*/ static class SendOrSaveMessage {
1506        final ReplyFromAccount mAccount;
1507        final ContentValues mValues;
1508        final String mRefMessageId;
1509        final boolean mSave;
1510        final int mRequestId;
1511
1512        public SendOrSaveMessage(ReplyFromAccount account, ContentValues values,
1513                String refMessageId, boolean save) {
1514            mAccount = account;
1515            mValues = values;
1516            mRefMessageId = refMessageId;
1517            mSave = save;
1518            mRequestId = mValues.hashCode() ^ hashCode();
1519        }
1520
1521        int requestId() {
1522            return mRequestId;
1523        }
1524    }
1525
1526    /**
1527     * Get the to recipients.
1528     */
1529    public String[] getToAddresses() {
1530        return getAddressesFromList(mTo);
1531    }
1532
1533    /**
1534     * Get the cc recipients.
1535     */
1536    public String[] getCcAddresses() {
1537        return getAddressesFromList(mCc);
1538    }
1539
1540    /**
1541     * Get the bcc recipients.
1542     */
1543    public String[] getBccAddresses() {
1544        return getAddressesFromList(mBcc);
1545    }
1546
1547    public String[] getAddressesFromList(RecipientEditTextView list) {
1548        if (list == null) {
1549            return new String[0];
1550        }
1551        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
1552        int count = tokens.length;
1553        String[] result = new String[count];
1554        for (int i = 0; i < count; i++) {
1555            result[i] = tokens[i].toString();
1556        }
1557        return result;
1558    }
1559
1560    /**
1561     * Check for invalid email addresses.
1562     * @param to String array of email addresses to check.
1563     * @param wrongEmailsOut Emails addresses that were invalid.
1564     */
1565    public void checkInvalidEmails(String[] to, List<String> wrongEmailsOut) {
1566        for (String email : to) {
1567            if (!mValidator.isValid(email)) {
1568                wrongEmailsOut.add(email);
1569            }
1570        }
1571    }
1572
1573    /**
1574     * Show an error because the user has entered an invalid recipient.
1575     * @param message
1576     */
1577    public void showRecipientErrorDialog(String message) {
1578        // Only 1 invalid recipients error dialog should be allowed up at a
1579        // time.
1580        if (mRecipientErrorDialog != null) {
1581            mRecipientErrorDialog.dismiss();
1582        }
1583        mRecipientErrorDialog = new AlertDialog.Builder(this).setMessage(message).setTitle(
1584                R.string.recipient_error_dialog_title)
1585                .setIconAttribute(android.R.attr.alertDialogIcon)
1586                .setCancelable(false)
1587                .setPositiveButton(
1588                        R.string.ok, new Dialog.OnClickListener() {
1589                            @Override
1590                            public void onClick(DialogInterface dialog, int which) {
1591                                // after the user dismisses the recipient error
1592                                // dialog we want to make sure to refocus the
1593                                // recipient to field so they can fix the issue
1594                                // easily
1595                                if (mTo != null) {
1596                                    mTo.requestFocus();
1597                                }
1598                                mRecipientErrorDialog = null;
1599                            }
1600                        }).show();
1601    }
1602
1603    /**
1604     * Update the state of the UI based on whether or not the current draft
1605     * needs to be saved and the message is not empty.
1606     */
1607    public void updateSaveUi() {
1608        if (mSave != null) {
1609            mSave.setEnabled((shouldSave() && !isBlank()));
1610        }
1611    }
1612
1613    /**
1614     * Returns true if we need to save the current draft.
1615     */
1616    private boolean shouldSave() {
1617        synchronized (mDraftLock) {
1618            // The message should only be saved if:
1619            // It hasn't been sent AND
1620            // Some text has been added to the message OR
1621            // an attachment has been added or removed
1622            return (mTextChanged || mAttachmentsChanged ||
1623                    (mReplyFromChanged && !isBlank()));
1624        }
1625    }
1626
1627    /**
1628     * Check if all fields are blank.
1629     * @return boolean
1630     */
1631    public boolean isBlank() {
1632        return mSubject.getText().length() == 0
1633                && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
1634                        mBodyView.getText().toString()) == 0)
1635                && mTo.length() == 0
1636                && mCc.length() == 0 && mBcc.length() == 0
1637                && mAttachmentsView.getAttachments().size() == 0;
1638    }
1639
1640    @VisibleForTesting
1641    protected int getSignatureStartPosition(String signature, String bodyText) {
1642        int startPos = -1;
1643
1644        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
1645            return startPos;
1646        }
1647
1648        int bodyLength = bodyText.length();
1649        int signatureLength = signature.length();
1650        String printableVersion = convertToPrintableSignature(signature);
1651        int printableLength = printableVersion.length();
1652
1653        if (bodyLength >= printableLength
1654                && bodyText.substring(bodyLength - printableLength)
1655                .equals(printableVersion)) {
1656            startPos = bodyLength - printableLength;
1657        } else if (bodyLength >= signatureLength
1658                && bodyText.substring(bodyLength - signatureLength)
1659                .equals(signature)) {
1660            startPos = bodyLength - signatureLength;
1661        }
1662        return startPos;
1663    }
1664
1665    /**
1666     * Allows any changes made by the user to be ignored. Called when the user
1667     * decides to discard a draft.
1668     */
1669    private void discardChanges() {
1670        mTextChanged = false;
1671        mAttachmentsChanged = false;
1672        mReplyFromChanged = false;
1673    }
1674
1675    /**
1676     * @param body
1677     * @param save
1678     * @param showToast
1679     * @return Whether the send or save succeeded.
1680     */
1681    protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
1682            final boolean orientationChanged) {
1683        String[] to, cc, bcc;
1684        Editable body = mBodyView.getEditableText();
1685
1686        if (orientationChanged) {
1687            to = cc = bcc = new String[0];
1688        } else {
1689            to = getToAddresses();
1690            cc = getCcAddresses();
1691            bcc = getBccAddresses();
1692        }
1693
1694        // Don't let the user send to nobody (but it's okay to save a message
1695        // with no recipients)
1696        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
1697            showRecipientErrorDialog(getString(R.string.recipient_needed));
1698            return false;
1699        }
1700
1701        List<String> wrongEmails = new ArrayList<String>();
1702        if (!save) {
1703            checkInvalidEmails(to, wrongEmails);
1704            checkInvalidEmails(cc, wrongEmails);
1705            checkInvalidEmails(bcc, wrongEmails);
1706        }
1707
1708        // Don't let the user send an email with invalid recipients
1709        if (wrongEmails.size() > 0) {
1710            String errorText = String.format(getString(R.string.invalid_recipient),
1711                    wrongEmails.get(0));
1712            showRecipientErrorDialog(errorText);
1713            return false;
1714        }
1715
1716        DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() {
1717            @Override
1718            public void onClick(DialogInterface dialog, int which) {
1719                sendOrSave(mBodyView.getEditableText(), save, showToast, orientationChanged);
1720            }
1721        };
1722
1723        // Show a warning before sending only if there are no attachments.
1724        if (!save) {
1725            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
1726                boolean warnAboutEmptySubject = isSubjectEmpty();
1727                boolean emptyBody = TextUtils.getTrimmedLength(body) == 0;
1728
1729                // A warning about an empty body may not be warranted when
1730                // forwarding mails, since a common use case is to forward
1731                // quoted text and not append any more text.
1732                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
1733
1734                // When we bring up a dialog warning the user about a send,
1735                // assume that they accept sending the message. If they do not,
1736                // the dialog listener is required to enable sending again.
1737                if (warnAboutEmptySubject) {
1738                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, listener);
1739                    return true;
1740                }
1741
1742                if (warnAboutEmptyBody) {
1743                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body, listener);
1744                    return true;
1745                }
1746            }
1747            // Ask for confirmation to send (if always required)
1748            if (showSendConfirmation()) {
1749                showSendConfirmDialog(R.string.confirm_send_message, listener);
1750                return true;
1751            }
1752        }
1753
1754        sendOrSave(body, save, showToast, false);
1755        return true;
1756    }
1757
1758    /**
1759     * Returns a boolean indicating whether warnings should be shown for empty
1760     * subject and body fields
1761     *
1762     * @return True if a warning should be shown for empty text fields
1763     */
1764    protected boolean showEmptyTextWarnings() {
1765        return mAttachmentsView.getAttachments().size() == 0;
1766    }
1767
1768    /**
1769     * Returns a boolean indicating whether the user should confirm each send
1770     *
1771     * @return True if a warning should be on each send
1772     */
1773    protected boolean showSendConfirmation() {
1774        return mCachedSettings != null ? mCachedSettings.confirmSend : false;
1775    }
1776
1777    private void showSendConfirmDialog(int messageId, DialogInterface.OnClickListener listener) {
1778        if (mSendConfirmDialog != null) {
1779            mSendConfirmDialog.dismiss();
1780            mSendConfirmDialog = null;
1781        }
1782        mSendConfirmDialog = new AlertDialog.Builder(this).setMessage(messageId)
1783                .setTitle(R.string.confirm_send_title)
1784                .setIconAttribute(android.R.attr.alertDialogIcon)
1785                .setPositiveButton(R.string.send, listener)
1786                .setNegativeButton(R.string.cancel, this).setCancelable(false).show();
1787    }
1788
1789    /**
1790     * Returns whether the ComposeArea believes there is any text in the body of
1791     * the composition. TODO: When ComposeArea controls the Body as well, add
1792     * that here.
1793     */
1794    public boolean isBodyEmpty() {
1795        return !mQuotedTextView.isTextIncluded();
1796    }
1797
1798    /**
1799     * Test to see if the subject is empty.
1800     *
1801     * @return boolean.
1802     */
1803    // TODO: this will likely go away when composeArea.focus() is implemented
1804    // after all the widget control is moved over.
1805    public boolean isSubjectEmpty() {
1806        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
1807    }
1808
1809    /* package */
1810    static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount,
1811            Message message, final Message refMessage, Spanned body, final CharSequence quotedText,
1812            SendOrSaveCallback callback, Handler handler, boolean save, int composeMode) {
1813        ContentValues values = new ContentValues();
1814
1815        String refMessageId = refMessage != null ? refMessage.uri.toString() : "";
1816
1817        MessageModification.putToAddresses(values, message.getToAddresses());
1818        MessageModification.putCcAddresses(values, message.getCcAddresses());
1819        MessageModification.putBccAddresses(values, message.getBccAddresses());
1820
1821        MessageModification.putCustomFromAddress(values, message.from);
1822
1823        MessageModification.putSubject(values, message.subject);
1824        String htmlBody = Html.toHtml(body);
1825
1826        boolean includeQuotedText = !TextUtils.isEmpty(quotedText);
1827        StringBuilder fullBody = new StringBuilder(htmlBody);
1828        if (includeQuotedText) {
1829            // HTML gets converted to text for now
1830            final String text = quotedText.toString();
1831            if (QuotedTextView.containsQuotedText(text)) {
1832                int pos = QuotedTextView.getQuotedTextOffset(text);
1833                final int quoteStartPos = fullBody.length() + pos;
1834                fullBody.append(text);
1835                MessageModification.putQuoteStartPos(values, quoteStartPos);
1836                MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
1837                MessageModification.putAppendRefMessageContent(values, includeQuotedText);
1838            } else {
1839                LogUtils.w(LOG_TAG, "Couldn't find quoted text");
1840                // This shouldn't happen, but just use what we have,
1841                // and don't do server-side expansion
1842                fullBody.append(text);
1843            }
1844        }
1845        int draftType = getDraftType(composeMode);
1846        MessageModification.putDraftType(values, draftType);
1847        if (refMessage != null) {
1848            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
1849                MessageModification.putBodyHtml(values, fullBody.toString());
1850            }
1851            if (!TextUtils.isEmpty(refMessage.bodyText)) {
1852                MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString());
1853            }
1854        } else {
1855            MessageModification.putBodyHtml(values, fullBody.toString());
1856            MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString());
1857        }
1858        MessageModification.putAttachments(values, message.getAttachments());
1859        if (!TextUtils.isEmpty(refMessageId)) {
1860            MessageModification.putRefMessageId(values, refMessageId);
1861        }
1862
1863        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(replyFromAccount,
1864                values, refMessageId, save);
1865        SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback);
1866
1867        callback.initializeSendOrSave(sendOrSaveTask);
1868
1869        // Do the send/save action on the specified handler to avoid possible
1870        // ANRs
1871        handler.post(sendOrSaveTask);
1872
1873        return sendOrSaveMessage.requestId();
1874    }
1875
1876    private static int getDraftType(int mode) {
1877        int draftType = -1;
1878        switch (mode) {
1879            case ComposeActivity.COMPOSE:
1880                draftType = DraftType.COMPOSE;
1881                break;
1882            case ComposeActivity.REPLY:
1883                draftType = DraftType.REPLY;
1884                break;
1885            case ComposeActivity.REPLY_ALL:
1886                draftType = DraftType.REPLY_ALL;
1887                break;
1888            case ComposeActivity.FORWARD:
1889                draftType = DraftType.FORWARD;
1890                break;
1891        }
1892        return draftType;
1893    }
1894
1895    private void sendOrSave(Spanned body, boolean save, boolean showToast,
1896            boolean orientationChanged) {
1897        // Check if user is a monkey. Monkeys can compose and hit send
1898        // button but are not allowed to send anything off the device.
1899        if (ActivityManager.isUserAMonkey()) {
1900            return;
1901        }
1902
1903        String[] to, cc, bcc;
1904        if (orientationChanged) {
1905            to = cc = bcc = new String[0];
1906        } else {
1907            to = getToAddresses();
1908            cc = getCcAddresses();
1909            bcc = getBccAddresses();
1910        }
1911
1912        SendOrSaveCallback callback = new SendOrSaveCallback() {
1913            private int mRestoredRequestId;
1914
1915            @Override
1916            public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
1917                synchronized (mActiveTasks) {
1918                    int numTasks = mActiveTasks.size();
1919                    if (numTasks == 0) {
1920                        // Start service so we won't be killed if this app is
1921                        // put in the background.
1922                        startService(new Intent(ComposeActivity.this, EmptyService.class));
1923                    }
1924
1925                    mActiveTasks.add(sendOrSaveTask);
1926                }
1927                if (sTestSendOrSaveCallback != null) {
1928                    sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
1929                }
1930            }
1931
1932            @Override
1933            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
1934                    Message message) {
1935                synchronized (mDraftLock) {
1936                    mDraftId = message.id;
1937                    mDraft = message;
1938                    if (sRequestMessageIdMap != null) {
1939                        sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
1940                    }
1941                    // Cache request message map, in case the process is killed
1942                    saveRequestMap();
1943                }
1944                if (sTestSendOrSaveCallback != null) {
1945                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
1946                }
1947            }
1948
1949            @Override
1950            public Message getMessage() {
1951                synchronized (mDraftLock) {
1952                    return mDraft;
1953                }
1954            }
1955
1956            @Override
1957            public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
1958                if (success) {
1959                    // Successfully sent or saved so reset change markers
1960                    discardChanges();
1961                } else {
1962                    // A failure happened with saving/sending the draft
1963                    // TODO(pwestbro): add a better string that should be used
1964                    // when failing to send or save
1965                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
1966                            .show();
1967                }
1968
1969                int numTasks;
1970                synchronized (mActiveTasks) {
1971                    // Remove the task from the list of active tasks
1972                    mActiveTasks.remove(task);
1973                    numTasks = mActiveTasks.size();
1974                }
1975
1976                if (numTasks == 0) {
1977                    // Stop service so we can be killed.
1978                    stopService(new Intent(ComposeActivity.this, EmptyService.class));
1979                }
1980                if (sTestSendOrSaveCallback != null) {
1981                    sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
1982                }
1983            }
1984        };
1985
1986        // Get the selected account if the from spinner has been setup.
1987        ReplyFromAccount selectedAccount = mReplyFromAccount;
1988        String fromAddress = selectedAccount.name;
1989        if (selectedAccount == null || fromAddress == null) {
1990            // We don't have either the selected account or from address,
1991            // use mAccount.
1992            selectedAccount = mReplyFromAccount;
1993            fromAddress = mAccount.name;
1994        }
1995
1996        if (mSendSaveTaskHandler == null) {
1997            HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
1998            handlerThread.start();
1999
2000            mSendSaveTaskHandler = new Handler(handlerThread.getLooper());
2001        }
2002
2003        Message msg = createMessage(mReplyFromAccount, getMode());
2004        mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body,
2005                mQuotedTextView.getQuotedTextIfIncluded(), callback,
2006                mSendSaveTaskHandler, save, mComposeMode);
2007
2008        if (mRecipient != null && mRecipient.equals(mAccount.name)) {
2009            mRecipient = selectedAccount.name;
2010        }
2011        setAccount(selectedAccount.account);
2012
2013        // Don't display the toast if the user is just changing the orientation,
2014        // but we still need to save the draft to the cursor because this is how we restore
2015        // the attachments when the configuration change completes.
2016        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
2017            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
2018                    Toast.LENGTH_LONG).show();
2019        }
2020
2021        // Need to update variables here because the send or save completes
2022        // asynchronously even though the toast shows right away.
2023        discardChanges();
2024        updateSaveUi();
2025
2026        // If we are sending, finish the activity
2027        if (!save) {
2028            finish();
2029        }
2030    }
2031
2032    /**
2033     * Save the state of the request messageid map. This allows for the Gmail
2034     * process to be killed, but and still allow for ComposeActivity instances
2035     * to be recreated correctly.
2036     */
2037    private void saveRequestMap() {
2038        // TODO: store the request map in user preferences.
2039    }
2040
2041    public void doAttach() {
2042        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
2043        i.addCategory(Intent.CATEGORY_OPENABLE);
2044        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
2045        if (android.provider.Settings.System.getInt(getContentResolver(),
2046                UIProvider.getAttachmentTypeSetting(), 0) != 0) {
2047            i.setType("*/*");
2048        } else {
2049            i.setType("image/*");
2050        }
2051        mAddingAttachment = true;
2052        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
2053                RESULT_PICK_ATTACHMENT);
2054    }
2055
2056    private void showCcBccViews() {
2057        mCcBccView.show(true, true, true);
2058        if (mCcBccButton != null) {
2059            mCcBccButton.setVisibility(View.GONE);
2060        }
2061    }
2062
2063    @Override
2064    public boolean onNavigationItemSelected(int position, long itemId) {
2065        int initialComposeMode = mComposeMode;
2066        if (position == ComposeActivity.REPLY) {
2067            mComposeMode = ComposeActivity.REPLY;
2068        } else if (position == ComposeActivity.REPLY_ALL) {
2069            mComposeMode = ComposeActivity.REPLY_ALL;
2070        } else if (position == ComposeActivity.FORWARD) {
2071            mComposeMode = ComposeActivity.FORWARD;
2072        }
2073        if (initialComposeMode != mComposeMode) {
2074            resetMessageForModeChange();
2075            if (mRefMessage != null) {
2076                initFromRefMessage(mComposeMode, mAccount.name);
2077            }
2078        }
2079        return true;
2080    }
2081
2082    private void resetMessageForModeChange() {
2083        // When switching between reply, reply all, forward,
2084        // follow the behavior of webview.
2085        // The contents of the following fields are cleared
2086        // so that they can be populated directly from the
2087        // ref message:
2088        // 1) Any recipient fields
2089        // 2) The subject
2090        mTo.setText("");
2091        mCc.setText("");
2092        mBcc.setText("");
2093        // Any edits to the subject are replaced with the original subject.
2094        mSubject.setText("");
2095
2096        // Any changes to the contents of the following fields are kept:
2097        // 1) Body
2098        // 2) Attachments
2099        // If the user made changes to attachments, keep their changes.
2100        if (!mAttachmentsChanged) {
2101            mAttachmentsView.deleteAllAttachments();
2102        }
2103    }
2104
2105    private class ComposeModeAdapter extends ArrayAdapter<String> {
2106
2107        private LayoutInflater mInflater;
2108
2109        public ComposeModeAdapter(Context context) {
2110            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
2111                    .getStringArray(R.array.compose_modes));
2112        }
2113
2114        private LayoutInflater getInflater() {
2115            if (mInflater == null) {
2116                mInflater = LayoutInflater.from(getContext());
2117            }
2118            return mInflater;
2119        }
2120
2121        @Override
2122        public View getView(int position, View convertView, ViewGroup parent) {
2123            if (convertView == null) {
2124                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
2125            }
2126            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
2127            return super.getView(position, convertView, parent);
2128        }
2129    }
2130
2131    @Override
2132    public void onRespondInline(String text) {
2133        appendToBody(text, false);
2134    }
2135
2136    /**
2137     * Append text to the body of the message. If there is no existing body
2138     * text, just sets the body to text.
2139     *
2140     * @param text
2141     * @param withSignature True to append a signature.
2142     */
2143    public void appendToBody(CharSequence text, boolean withSignature) {
2144        Editable bodyText = mBodyView.getEditableText();
2145        if (bodyText != null && bodyText.length() > 0) {
2146            bodyText.append(text);
2147        } else {
2148            setBody(text, withSignature);
2149        }
2150    }
2151
2152    /**
2153     * Set the body of the message.
2154     *
2155     * @param text
2156     * @param withSignature True to append a signature.
2157     */
2158    public void setBody(CharSequence text, boolean withSignature) {
2159        mBodyView.setText(text);
2160        if (withSignature) {
2161            appendSignature();
2162        }
2163    }
2164
2165    private void appendSignature() {
2166        String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
2167        boolean hasFocus = mBodyView.hasFocus();
2168        if (!TextUtils.equals(newSignature, mSignature)) {
2169            mSignature = newSignature;
2170            if (!TextUtils.isEmpty(mSignature)
2171                    && getSignatureStartPosition(mSignature,
2172                            mBodyView.getText().toString()) < 0) {
2173                // Appending a signature does not count as changing text.
2174                mBodyView.removeTextChangedListener(this);
2175                mBodyView.append(convertToPrintableSignature(mSignature));
2176                mBodyView.addTextChangedListener(this);
2177            }
2178            if (hasFocus) {
2179                focusBody();
2180            }
2181        }
2182    }
2183
2184    private String convertToPrintableSignature(String signature) {
2185        String signatureResource = getResources().getString(R.string.signature);
2186        if (signature == null) {
2187            signature = "";
2188        }
2189        return String.format(signatureResource, signature);
2190    }
2191
2192    @Override
2193    public void onAccountChanged() {
2194        mReplyFromAccount = mFromSpinner.getCurrentAccount();
2195        if (!mAccount.equals(mReplyFromAccount.account)) {
2196            setAccount(mReplyFromAccount.account);
2197
2198            // TODO: handle discarding attachments when switching accounts.
2199            // Only enable save for this draft if there is any other content
2200            // in the message.
2201            if (!isBlank()) {
2202                enableSave(true);
2203            }
2204            mReplyFromChanged = true;
2205            initRecipients();
2206        }
2207    }
2208
2209    public void enableSave(boolean enabled) {
2210        if (mSave != null) {
2211            mSave.setEnabled(enabled);
2212        }
2213    }
2214
2215    public void enableSend(boolean enabled) {
2216        if (mSend != null) {
2217            mSend.setEnabled(enabled);
2218        }
2219    }
2220
2221    /**
2222     * Handles button clicks from any error dialogs dealing with sending
2223     * a message.
2224     */
2225    @Override
2226    public void onClick(DialogInterface dialog, int which) {
2227        switch (which) {
2228            case DialogInterface.BUTTON_POSITIVE: {
2229                doDiscardWithoutConfirmation(true /* show toast */ );
2230                break;
2231            }
2232            case DialogInterface.BUTTON_NEGATIVE: {
2233                // If the user cancels the send, re-enable the send button.
2234                enableSend(true);
2235                break;
2236            }
2237        }
2238
2239    }
2240
2241    private void doDiscard() {
2242        new AlertDialog.Builder(this).setMessage(R.string.confirm_discard_text)
2243                .setPositiveButton(R.string.ok, this)
2244                .setNegativeButton(R.string.cancel, null)
2245                .create().show();
2246    }
2247    /**
2248     * Effectively discard the current message.
2249     *
2250     * This method is either invoked from the menu or from the dialog
2251     * once the user has confirmed that they want to discard the message.
2252     * @param showToast show "Message discarded" toast if true
2253     */
2254    private void doDiscardWithoutConfirmation(boolean showToast) {
2255        synchronized (mDraftLock) {
2256            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
2257                ContentValues values = new ContentValues();
2258                values.put(BaseColumns._ID, mDraftId);
2259                if (mAccount.expungeMessageUri != null) {
2260                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
2261                } else {
2262                    getContentResolver().delete(mDraft.uri, null, null);
2263                }
2264                // This is not strictly necessary (since we should not try to
2265                // save the draft after calling this) but it ensures that if we
2266                // do save again for some reason we make a new draft rather than
2267                // trying to resave an expunged draft.
2268                mDraftId = UIProvider.INVALID_MESSAGE_ID;
2269            }
2270        }
2271
2272        if (showToast) {
2273            // Display a toast to let the user know
2274            Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
2275        }
2276
2277        // This prevents the draft from being saved in onPause().
2278        discardChanges();
2279        finish();
2280    }
2281
2282    private void saveIfNeeded() {
2283        if (mAccount == null) {
2284            // We have not chosen an account yet so there's no way that we can save. This is ok,
2285            // though, since we are saving our state before AccountsActivity is activated. Thus, the
2286            // user has not interacted with us yet and there is no real state to save.
2287            return;
2288        }
2289
2290        if (shouldSave()) {
2291            doSave(!mAddingAttachment /* show toast */, true /* reset IME */);
2292        }
2293    }
2294
2295    @Override
2296    public void onAttachmentDeleted() {
2297        mAttachmentsChanged = true;
2298        updateSaveUi();
2299    }
2300
2301
2302    /**
2303     * This is called any time one of our text fields changes.
2304     */
2305    @Override
2306    public void afterTextChanged(Editable s) {
2307        mTextChanged = true;
2308        updateSaveUi();
2309    }
2310
2311    @Override
2312    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
2313        // Do nothing.
2314    }
2315
2316    @Override
2317    public void onTextChanged(CharSequence s, int start, int before, int count) {
2318        // Do nothing.
2319    }
2320
2321
2322    // There is a big difference between the text associated with an address changing
2323    // to add the display name or to format properly and a recipient being added or deleted.
2324    // Make sure we only notify of changes when a recipient has been added or deleted.
2325    private class RecipientTextWatcher implements TextWatcher {
2326        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
2327
2328        private RecipientEditTextView mView;
2329
2330        private TextWatcher mListener;
2331
2332        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
2333            mView = view;
2334            mListener = listener;
2335        }
2336
2337        @Override
2338        public void afterTextChanged(Editable s) {
2339            if (hasChanged()) {
2340                mListener.afterTextChanged(s);
2341            }
2342        }
2343
2344        private boolean hasChanged() {
2345            String[] currRecips = tokenizeRecips(getAddressesFromList(mView));
2346            int totalCount = currRecips.length;
2347            int totalPrevCount = 0;
2348            for (Entry<String, Integer> entry : mContent.entrySet()) {
2349                totalPrevCount += entry.getValue();
2350            }
2351            if (totalCount != totalPrevCount) {
2352                return true;
2353            }
2354
2355            for (String recip : currRecips) {
2356                if (!mContent.containsKey(recip)) {
2357                    return true;
2358                } else {
2359                    int count = mContent.get(recip) - 1;
2360                    if (count < 0) {
2361                        return true;
2362                    } else {
2363                        mContent.put(recip, count);
2364                    }
2365                }
2366            }
2367            return false;
2368        }
2369
2370        private String[] tokenizeRecips(String[] recips) {
2371            // Tokenize them all and put them in the list.
2372            String[] recipAddresses = new String[recips.length];
2373            for (int i = 0; i < recips.length; i++) {
2374                recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress();
2375            }
2376            return recipAddresses;
2377        }
2378
2379        @Override
2380        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
2381            String[] recips = tokenizeRecips(getAddressesFromList(mView));
2382            for (String recip : recips) {
2383                if (!mContent.containsKey(recip)) {
2384                    mContent.put(recip, 1);
2385                } else {
2386                    mContent.put(recip, (mContent.get(recip)) + 1);
2387                }
2388            }
2389        }
2390
2391        @Override
2392        public void onTextChanged(CharSequence s, int start, int before, int count) {
2393            // Do nothing.
2394        }
2395    }
2396}
2397