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