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