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