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