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