ComposeActivity.java revision e003b4cc4a23e6d394998bebe26430a361c5ac29
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.ActionBar.OnNavigationListener;
21import android.app.Activity;
22import android.app.ActivityManager;
23import android.app.AlertDialog;
24import android.app.Dialog;
25import android.app.DialogFragment;
26import android.app.Fragment;
27import android.app.FragmentTransaction;
28import android.app.LoaderManager;
29import android.content.ContentResolver;
30import android.content.ContentValues;
31import android.content.Context;
32import android.content.CursorLoader;
33import android.content.DialogInterface;
34import android.content.Intent;
35import android.content.Loader;
36import android.content.pm.ActivityInfo;
37import android.content.res.Resources;
38import android.database.Cursor;
39import android.net.Uri;
40import android.os.Bundle;
41import android.os.Handler;
42import android.os.HandlerThread;
43import android.os.ParcelFileDescriptor;
44import android.os.Parcelable;
45import android.provider.BaseColumns;
46import android.text.Editable;
47import android.text.Html;
48import android.text.SpannableString;
49import android.text.Spanned;
50import android.text.TextUtils;
51import android.text.TextWatcher;
52import android.text.util.Rfc822Token;
53import android.text.util.Rfc822Tokenizer;
54import android.view.Gravity;
55import android.view.KeyEvent;
56import android.view.LayoutInflater;
57import android.view.Menu;
58import android.view.MenuInflater;
59import android.view.MenuItem;
60import android.view.View;
61import android.view.View.OnClickListener;
62import android.view.ViewGroup;
63import android.view.inputmethod.BaseInputConnection;
64import android.view.inputmethod.EditorInfo;
65import android.widget.ArrayAdapter;
66import android.widget.Button;
67import android.widget.EditText;
68import android.widget.TextView;
69import android.widget.Toast;
70
71import com.android.common.Rfc822Validator;
72import com.android.common.contacts.DataUsageStatUpdater;
73import com.android.ex.chips.RecipientEditTextView;
74import com.android.mail.MailIntentService;
75import com.android.mail.R;
76import com.android.mail.analytics.Analytics;
77import com.android.mail.browse.MessageHeaderView;
78import com.android.mail.compose.AttachmentsView.AttachmentAddedOrDeletedListener;
79import com.android.mail.compose.AttachmentsView.AttachmentFailureException;
80import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener;
81import com.android.mail.compose.QuotedTextView.RespondInlineListener;
82import com.android.mail.providers.Account;
83import com.android.mail.providers.Address;
84import com.android.mail.providers.Attachment;
85import com.android.mail.providers.Folder;
86import com.android.mail.providers.MailAppProvider;
87import com.android.mail.providers.Message;
88import com.android.mail.providers.MessageModification;
89import com.android.mail.providers.ReplyFromAccount;
90import com.android.mail.providers.Settings;
91import com.android.mail.providers.UIProvider;
92import com.android.mail.providers.UIProvider.AccountCapabilities;
93import com.android.mail.providers.UIProvider.DraftType;
94import com.android.mail.ui.AttachmentTile.AttachmentPreview;
95import com.android.mail.ui.FeedbackEnabledActivity;
96import com.android.mail.ui.MailActivity;
97import com.android.mail.ui.WaitFragment;
98import com.android.mail.utils.AccountUtils;
99import com.android.mail.utils.AttachmentUtils;
100import com.android.mail.utils.ContentProviderTask;
101import com.android.mail.utils.LogTag;
102import com.android.mail.utils.LogUtils;
103import com.android.mail.utils.Utils;
104import com.google.common.annotations.VisibleForTesting;
105import com.google.common.collect.Lists;
106import com.google.common.collect.Sets;
107
108import java.io.FileNotFoundException;
109import java.io.IOException;
110import java.io.UnsupportedEncodingException;
111import java.net.URLDecoder;
112import java.util.ArrayList;
113import java.util.Arrays;
114import java.util.Collection;
115import java.util.HashMap;
116import java.util.HashSet;
117import java.util.List;
118import java.util.Map.Entry;
119import java.util.Set;
120import java.util.concurrent.ConcurrentHashMap;
121
122public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
123        RespondInlineListener, TextWatcher,
124        AttachmentAddedOrDeletedListener, OnAccountChangedListener,
125        LoaderManager.LoaderCallbacks<Cursor>, TextView.OnEditorActionListener,
126        FeedbackEnabledActivity {
127    // Identifiers for which type of composition this is
128    public static final int COMPOSE = -1;
129    public static final int REPLY = 0;
130    public static final int REPLY_ALL = 1;
131    public static final int FORWARD = 2;
132    public static final int EDIT_DRAFT = 3;
133
134    // Integer extra holding one of the above compose action
135    protected static final String EXTRA_ACTION = "action";
136
137    private static final String EXTRA_SHOW_CC = "showCc";
138    private static final String EXTRA_SHOW_BCC = "showBcc";
139    private static final String EXTRA_RESPONDED_INLINE = "respondedInline";
140    private static final String EXTRA_SAVE_ENABLED = "saveEnabled";
141
142    private static final String UTF8_ENCODING_NAME = "UTF-8";
143
144    private static final String MAIL_TO = "mailto";
145
146    private static final String EXTRA_SUBJECT = "subject";
147
148    private static final String EXTRA_BODY = "body";
149
150    /**
151     * Expected to be html formatted text.
152     */
153    private static final String EXTRA_QUOTED_TEXT = "quotedText";
154
155    protected static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString";
156
157    private static final String EXTRA_ATTACHMENT_PREVIEWS = "attachmentPreviews";
158
159    // Extra that we can get passed from other activities
160    private static final String EXTRA_TO = "to";
161    private static final String EXTRA_CC = "cc";
162    private static final String EXTRA_BCC = "bcc";
163
164    /**
165     * An optional extra containing a {@link ContentValues} of values to be added to
166     * {@link SendOrSaveMessage#mValues}.
167     */
168    public static final String EXTRA_VALUES = "extra-values";
169
170    // List of all the fields
171    static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC,
172            EXTRA_QUOTED_TEXT };
173
174    private static SendOrSaveCallback sTestSendOrSaveCallback = null;
175    // Map containing information about requests to create new messages, and the id of the
176    // messages that were the result of those requests.
177    //
178    // This map is used when the activity that initiated the save a of a new message, is killed
179    // before the save has completed (and when we know the id of the newly created message).  When
180    // a save is completed, the service that is running in the background, will update the map
181    //
182    // When a new ComposeActivity instance is created, it will attempt to use the information in
183    // the previously instantiated map.  If ComposeActivity.onCreate() is called, with a bundle
184    // (restoring data from a previous instance), and the map hasn't been created, we will attempt
185    // to populate the map with data stored in shared preferences.
186    // FIXME: values in this map are never read.
187    private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null;
188    /**
189     * Notifies the {@code Activity} that the caller is an Email
190     * {@code Activity}, so that the back behavior may be modified accordingly.
191     *
192     * @see #onAppUpPressed
193     */
194    public static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
195
196    public static final String EXTRA_ATTACHMENTS = "attachments";
197
198    /** If set, we will clear notifications for this folder. */
199    public static final String EXTRA_NOTIFICATION_FOLDER = "extra-notification-folder";
200
201    //  If this is a reply/forward then this extra will hold the original message
202    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message";
203    // If this is a reply/forward then this extra will hold a uri we must query
204    // to get the original message.
205    protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri";
206    // If this is an action to edit an existing draft message, this extra will hold the
207    // draft message
208    private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message";
209    private static final String END_TOKEN = ", ";
210    private static final String LOG_TAG = LogTag.getLogTag();
211    // Request numbers for activities we start
212    private static final int RESULT_PICK_ATTACHMENT = 1;
213    private static final int RESULT_CREATE_ACCOUNT = 2;
214    // TODO(mindyp) set mime-type for auto send?
215    public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND";
216
217    private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount";
218    private static final String EXTRA_REQUEST_ID = "requestId";
219    private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart";
220    private static final String EXTRA_FOCUS_SELECTION_END = "focusSelectionEnd";
221    private static final String EXTRA_MESSAGE = "extraMessage";
222    private static final int REFERENCE_MESSAGE_LOADER = 0;
223    private static final int LOADER_ACCOUNT_CURSOR = 1;
224    private static final int INIT_DRAFT_USING_REFERENCE_MESSAGE = 2;
225    private static final String EXTRA_SELECTED_ACCOUNT = "selectedAccount";
226    private static final String TAG_WAIT = "wait-fragment";
227    private static final String MIME_TYPE_PHOTO = "image/*";
228    private static final String MIME_TYPE_VIDEO = "video/*";
229
230    private static final String KEY_INNER_SAVED_STATE = "compose_state";
231
232    /**
233     * A single thread for running tasks in the background.
234     */
235    private Handler mSendSaveTaskHandler = null;
236    private RecipientEditTextView mTo;
237    private RecipientEditTextView mCc;
238    private RecipientEditTextView mBcc;
239    private Button mCcBccButton;
240    private CcBccView mCcBccView;
241    private AttachmentsView mAttachmentsView;
242    private Account mAccount;
243    protected ReplyFromAccount mReplyFromAccount;
244    private Settings mCachedSettings;
245    private Rfc822Validator mValidator;
246    private TextView mSubject;
247
248    private ComposeModeAdapter mComposeModeAdapter;
249    private int mComposeMode = -1;
250    private boolean mForward;
251    private QuotedTextView mQuotedTextView;
252    protected EditText mBodyView;
253    private View mFromStatic;
254    private TextView mFromStaticText;
255    private View mFromSpinnerWrapper;
256    @VisibleForTesting
257    protected FromAddressSpinner mFromSpinner;
258    private boolean mAddingAttachment;
259    private boolean mAttachmentsChanged;
260    private boolean mTextChanged;
261    private boolean mReplyFromChanged;
262    private MenuItem mSave;
263    private MenuItem mSend;
264    @VisibleForTesting
265    protected Message mRefMessage;
266    private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
267    private Message mDraft;
268    private ReplyFromAccount mDraftAccount;
269    private Object mDraftLock = new Object();
270    private View mPhotoAttachmentsButton;
271    private View mVideoAttachmentsButton;
272
273    /**
274     * Boolean indicating whether ComposeActivity was launched from a Gmail controlled view.
275     */
276    private boolean mLaunchedFromEmail = false;
277    private RecipientTextWatcher mToListener;
278    private RecipientTextWatcher mCcListener;
279    private RecipientTextWatcher mBccListener;
280    private Uri mRefMessageUri;
281    private boolean mShowQuotedText = false;
282    private Bundle mInnerSavedState;
283    private ContentValues mExtraValues = null;
284
285    // Array of the outstanding send or save tasks.  Access is synchronized
286    // with the object itself
287    /* package for testing */
288    @VisibleForTesting
289    public ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList();
290    // FIXME: this variable is never read. related to sRequestMessageIdMap.
291    private int mRequestId;
292    private String mSignature;
293    private Account[] mAccounts;
294    private boolean mRespondedInline;
295    private boolean mPerformedSendOrDiscard = false;
296
297    /**
298     * Can be called from a non-UI thread.
299     */
300    public static void editDraft(Context launcher, Account account, Message message) {
301        launch(launcher, account, message, EDIT_DRAFT, null, null, null, null,
302                null /* extraValues */);
303    }
304
305    /**
306     * Can be called from a non-UI thread.
307     */
308    public static void compose(Context launcher, Account account) {
309        launch(launcher, account, null, COMPOSE, null, null, null, null, null /* extraValues */);
310    }
311
312    /**
313     * Can be called from a non-UI thread.
314     */
315    public static void composeToAddress(Context launcher, Account account, String toAddress) {
316        launch(launcher, account, null, COMPOSE, toAddress, null, null, null,
317                null /* extraValues */);
318    }
319
320    /**
321     * Can be called from a non-UI thread.
322     */
323    public static void composeWithQuotedText(Context launcher, Account account,
324            String quotedText, String subject, final ContentValues extraValues) {
325        launch(launcher, account, null, COMPOSE, null, null, quotedText, subject, extraValues);
326    }
327
328    /**
329     * Can be called from a non-UI thread.
330     */
331    public static Intent createReplyIntent(final Context launcher, final Account account,
332            final Uri messageUri, final boolean isReplyAll) {
333        return createActionIntent(launcher, account, messageUri, isReplyAll ? REPLY_ALL : REPLY);
334    }
335
336    /**
337     * Can be called from a non-UI thread.
338     */
339    public static Intent createForwardIntent(final Context launcher, final Account account,
340            final Uri messageUri) {
341        return createActionIntent(launcher, account, messageUri, FORWARD);
342    }
343
344    private static Intent createActionIntent(final Context launcher, final Account account,
345            final Uri messageUri, final int action) {
346        final Intent intent = new Intent(launcher, ComposeActivity.class);
347
348        updateActionIntent(account, messageUri, action, intent);
349
350        return intent;
351    }
352
353    @VisibleForTesting
354    static Intent updateActionIntent(Account account, Uri messageUri, int action, Intent intent) {
355        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
356        intent.putExtra(EXTRA_ACTION, action);
357        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
358        intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, messageUri);
359
360        return intent;
361    }
362
363    /**
364     * Can be called from a non-UI thread.
365     */
366    public static void reply(Context launcher, Account account, Message message) {
367        launch(launcher, account, message, REPLY, null, null, null, null, null /* extraValues */);
368    }
369
370    /**
371     * Can be called from a non-UI thread.
372     */
373    public static void replyAll(Context launcher, Account account, Message message) {
374        launch(launcher, account, message, REPLY_ALL, null, null, null, null,
375                null /* extraValues */);
376    }
377
378    /**
379     * Can be called from a non-UI thread.
380     */
381    public static void forward(Context launcher, Account account, Message message) {
382        launch(launcher, account, message, FORWARD, null, null, null, null, null /* extraValues */);
383    }
384
385    public static void reportRenderingFeedback(Context launcher, Account account, Message message,
386            String body) {
387        launch(launcher, account, message, FORWARD,
388                "android-gmail-readability@google.com", body, null, null, null /* extraValues */);
389    }
390
391    private static void launch(Context launcher, Account account, Message message, int action,
392            String toAddress, String body, String quotedText, String subject,
393            final ContentValues extraValues) {
394        Intent intent = new Intent(launcher, ComposeActivity.class);
395        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
396        intent.putExtra(EXTRA_ACTION, action);
397        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
398        if (action == EDIT_DRAFT) {
399            intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message);
400        } else {
401            intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message);
402        }
403        if (toAddress != null) {
404            intent.putExtra(EXTRA_TO, toAddress);
405        }
406        if (body != null) {
407            intent.putExtra(EXTRA_BODY, body);
408        }
409        if (quotedText != null) {
410            intent.putExtra(EXTRA_QUOTED_TEXT, quotedText);
411        }
412        if (subject != null) {
413            intent.putExtra(EXTRA_SUBJECT, subject);
414        }
415        if (extraValues != null) {
416            LogUtils.d(LOG_TAG, "Launching with extraValues: %s", extraValues.toString());
417            intent.putExtra(EXTRA_VALUES, extraValues);
418        }
419        launcher.startActivity(intent);
420    }
421
422    @Override
423    protected void onCreate(Bundle savedInstanceState) {
424        super.onCreate(savedInstanceState);
425        setContentView(R.layout.compose);
426        mInnerSavedState = (savedInstanceState != null) ?
427                savedInstanceState.getBundle(KEY_INNER_SAVED_STATE) : null;
428        checkValidAccounts();
429    }
430
431    private void finishCreate() {
432        final Bundle savedState = mInnerSavedState;
433        findViews();
434        Intent intent = getIntent();
435        Message message;
436        ArrayList<AttachmentPreview> previews;
437        mShowQuotedText = false;
438        CharSequence quotedText = null;
439        int action;
440        // Check for any of the possibly supplied accounts.;
441        Account account = null;
442        if (hadSavedInstanceStateMessage(savedState)) {
443            action = savedState.getInt(EXTRA_ACTION, COMPOSE);
444            account = savedState.getParcelable(Utils.EXTRA_ACCOUNT);
445            message = (Message) savedState.getParcelable(EXTRA_MESSAGE);
446
447            previews = savedState.getParcelableArrayList(EXTRA_ATTACHMENT_PREVIEWS);
448            mRefMessage = (Message) savedState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE);
449            quotedText = savedState.getCharSequence(EXTRA_QUOTED_TEXT);
450        } else {
451            account = obtainAccount(intent);
452            action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
453            // Initialize the message from the message in the intent
454            message = (Message) intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE);
455            previews = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_PREVIEWS);
456            mRefMessage = (Message) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
457            mRefMessageUri = (Uri) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI);
458        }
459        mAttachmentsView.setAttachmentPreviews(previews);
460
461        setAccount(account);
462        if (mAccount == null) {
463            return;
464        }
465
466        initRecipients();
467
468        // Clear the notification and mark the conversation as seen, if necessary
469        final Folder notificationFolder =
470                intent.getParcelableExtra(EXTRA_NOTIFICATION_FOLDER);
471        if (notificationFolder != null) {
472            final Intent clearNotifIntent =
473                    new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
474            clearNotifIntent.setPackage(getPackageName());
475            clearNotifIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
476            clearNotifIntent.putExtra(Utils.EXTRA_FOLDER, notificationFolder);
477
478            startService(clearNotifIntent);
479        }
480
481        if (intent.getBooleanExtra(EXTRA_FROM_EMAIL_TASK, false)) {
482            mLaunchedFromEmail = true;
483        } else if (Intent.ACTION_SEND.equals(intent.getAction())) {
484            final Uri dataUri = intent.getData();
485            if (dataUri != null) {
486                final String dataScheme = intent.getData().getScheme();
487                final String accountScheme = mAccount.composeIntentUri.getScheme();
488                mLaunchedFromEmail = TextUtils.equals(dataScheme, accountScheme);
489            }
490        }
491
492        if (mRefMessageUri != null) {
493            mShowQuotedText = true;
494            mComposeMode = action;
495            getLoaderManager().initLoader(INIT_DRAFT_USING_REFERENCE_MESSAGE, null, this);
496            return;
497        } else if (message != null && action != EDIT_DRAFT) {
498            initFromDraftMessage(message);
499            initQuotedTextFromRefMessage(mRefMessage, action);
500            showCcBcc(savedState);
501            mShowQuotedText = message.appendRefMessageContent;
502            // if we should be showing quoted text but mRefMessage is null
503            // and we have some quotedText, display that
504            if (mShowQuotedText && quotedText != null && mRefMessage == null) {
505                initQuotedText(quotedText, false /* shouldQuoteText */);
506            }
507        } else if (action == EDIT_DRAFT) {
508            initFromDraftMessage(message);
509            boolean showBcc = !TextUtils.isEmpty(message.getBcc());
510            boolean showCc = showBcc || !TextUtils.isEmpty(message.getCc());
511            mCcBccView.show(false, showCc, showBcc);
512            // Update the action to the draft type of the previous draft
513            switch (message.draftType) {
514                case UIProvider.DraftType.REPLY:
515                    action = REPLY;
516                    break;
517                case UIProvider.DraftType.REPLY_ALL:
518                    action = REPLY_ALL;
519                    break;
520                case UIProvider.DraftType.FORWARD:
521                    action = FORWARD;
522                    break;
523                case UIProvider.DraftType.COMPOSE:
524                default:
525                    action = COMPOSE;
526                    break;
527            }
528            LogUtils.d(LOG_TAG, "Previous draft had action type: %d", action);
529
530            mShowQuotedText = message.appendRefMessageContent;
531            if (message.refMessageUri != null) {
532                // If we're editing an existing draft that was in reference to an existing message,
533                // still need to load that original message since we might need to refer to the
534                // original sender and recipients if user switches "reply <-> reply-all".
535                mRefMessageUri = message.refMessageUri;
536                mComposeMode = action;
537                getLoaderManager().initLoader(REFERENCE_MESSAGE_LOADER, null, this);
538                return;
539            }
540        } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
541            if (mRefMessage != null) {
542                initFromRefMessage(action);
543                mShowQuotedText = true;
544            }
545        } else {
546            initFromExtras(intent);
547        }
548
549        mComposeMode = action;
550        finishSetup(action, intent, savedState);
551    }
552
553    private void checkValidAccounts() {
554        final Account[] allAccounts = AccountUtils.getAccounts(this);
555        if (allAccounts == null || allAccounts.length == 0) {
556            final Intent noAccountIntent = MailAppProvider.getNoAccountIntent(this);
557            if (noAccountIntent != null) {
558                mAccounts = null;
559                startActivityForResult(noAccountIntent, RESULT_CREATE_ACCOUNT);
560            }
561        } else {
562            // If none of the accounts are syncing, setup a watcher.
563            boolean anySyncing = false;
564            for (Account a : allAccounts) {
565                if (a.isAccountReady()) {
566                    anySyncing = true;
567                    break;
568                }
569            }
570            if (!anySyncing) {
571                // There are accounts, but none are sync'd, which is just like having no accounts.
572                mAccounts = null;
573                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
574                return;
575            }
576            mAccounts = AccountUtils.getSyncingAccounts(this);
577            finishCreate();
578        }
579    }
580
581    private Account obtainAccount(Intent intent) {
582        Account account = null;
583        Object accountExtra = null;
584        if (intent != null && intent.getExtras() != null) {
585            accountExtra = intent.getExtras().get(Utils.EXTRA_ACCOUNT);
586            if (accountExtra instanceof Account) {
587                return (Account) accountExtra;
588            } else if (accountExtra instanceof String) {
589                // This is the Account attached to the widget compose intent.
590                account = Account.newinstance((String)accountExtra);
591                if (account != null) {
592                    return account;
593                }
594            }
595            accountExtra = intent.hasExtra(Utils.EXTRA_ACCOUNT) ?
596                    intent.getStringExtra(Utils.EXTRA_ACCOUNT) :
597                        intent.getStringExtra(EXTRA_SELECTED_ACCOUNT);
598        }
599        if (account == null) {
600            MailAppProvider provider = MailAppProvider.getInstance();
601            String lastAccountUri = provider.getLastSentFromAccount();
602            if (TextUtils.isEmpty(lastAccountUri)) {
603                lastAccountUri = provider.getLastViewedAccount();
604            }
605            if (!TextUtils.isEmpty(lastAccountUri)) {
606                accountExtra = Uri.parse(lastAccountUri);
607            }
608        }
609        if (mAccounts != null && mAccounts.length > 0) {
610            if (accountExtra instanceof String && !TextUtils.isEmpty((String) accountExtra)) {
611                // For backwards compatibility, we need to check account
612                // names.
613                for (Account a : mAccounts) {
614                    if (a.name.equals(accountExtra)) {
615                        account = a;
616                    }
617                }
618            } else if (accountExtra instanceof Uri) {
619                // The uri of the last viewed account is what is stored in
620                // the current code base.
621                for (Account a : mAccounts) {
622                    if (a.uri.equals(accountExtra)) {
623                        account = a;
624                    }
625                }
626            }
627            if (account == null) {
628                account = mAccounts[0];
629            }
630        }
631        return account;
632    }
633
634    private void finishSetup(int action, Intent intent, Bundle savedInstanceState) {
635        setFocus(action);
636        // Don't bother with the intent if we have procured a message from the
637        // intent already.
638        if (!hadSavedInstanceStateMessage(savedInstanceState)) {
639            initAttachmentsFromIntent(intent);
640        }
641        initActionBar();
642        initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(),
643                action);
644
645        // If this is a draft message, the draft account is whatever account was
646        // used to open the draft message in Compose.
647        if (mDraft != null) {
648            mDraftAccount = mReplyFromAccount;
649        }
650
651        initChangeListeners();
652        updateHideOrShowCcBcc();
653        updateHideOrShowQuotedText(mShowQuotedText);
654
655        mRespondedInline = mInnerSavedState != null ?
656                mInnerSavedState.getBoolean(EXTRA_RESPONDED_INLINE) : false;
657        if (mRespondedInline) {
658            mQuotedTextView.setVisibility(View.GONE);
659        }
660    }
661
662    private static boolean hadSavedInstanceStateMessage(final Bundle savedInstanceState) {
663        return savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE);
664    }
665
666    private void updateHideOrShowQuotedText(boolean showQuotedText) {
667        mQuotedTextView.updateCheckedState(showQuotedText);
668        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
669    }
670
671    private void setFocus(int action) {
672        if (action == EDIT_DRAFT) {
673            int type = mDraft.draftType;
674            switch (type) {
675                case UIProvider.DraftType.COMPOSE:
676                case UIProvider.DraftType.FORWARD:
677                    action = COMPOSE;
678                    break;
679                case UIProvider.DraftType.REPLY:
680                case UIProvider.DraftType.REPLY_ALL:
681                default:
682                    action = REPLY;
683                    break;
684            }
685        }
686        switch (action) {
687            case FORWARD:
688            case COMPOSE:
689                if (TextUtils.isEmpty(mTo.getText())) {
690                    mTo.requestFocus();
691                    break;
692                }
693                //$FALL-THROUGH$
694            case REPLY:
695            case REPLY_ALL:
696            default:
697                focusBody();
698                break;
699        }
700    }
701
702    /**
703     * Focus the body of the message.
704     */
705    public void focusBody() {
706        mBodyView.requestFocus();
707        int length = mBodyView.getText().length();
708
709        int signatureStartPos = getSignatureStartPosition(
710                mSignature, mBodyView.getText().toString());
711        if (signatureStartPos > -1) {
712            // In case the user deleted the newlines...
713            mBodyView.setSelection(signatureStartPos);
714        } else if (length >= 0) {
715            // Move cursor to the end.
716            mBodyView.setSelection(length);
717        }
718    }
719
720    @Override
721    protected void onStart() {
722        super.onStart();
723
724        Analytics.getInstance().activityStart(this);
725    }
726
727    @Override
728    protected void onStop() {
729        super.onStop();
730
731        Analytics.getInstance().activityStop(this);
732    }
733
734    @Override
735    protected void onResume() {
736        super.onResume();
737        // Update the from spinner as other accounts
738        // may now be available.
739        if (mFromSpinner != null && mAccount != null) {
740            mFromSpinner.initialize(mComposeMode, mAccount, mAccounts, mRefMessage);
741        }
742    }
743
744    @Override
745    protected void onPause() {
746        super.onPause();
747
748        // When the user exits the compose view, see if this draft needs saving.
749        // Don't save unnecessary drafts if we are only changing the orientation.
750        if (!isChangingConfigurations()) {
751            saveIfNeeded();
752
753            if (isFinishing() && !mPerformedSendOrDiscard && !isBlank()) {
754                // log saving upon backing out of activity. (we avoid logging every sendOrSave()
755                // because that method can be invoked many times in a single compose session.)
756                logSendOrSave(true /* save */);
757            }
758        }
759    }
760
761    @Override
762    protected final void onActivityResult(int request, int result, Intent data) {
763        if (request == RESULT_PICK_ATTACHMENT && result == RESULT_OK) {
764            addAttachmentAndUpdateView(data);
765            mAddingAttachment = false;
766        } else if (request == RESULT_CREATE_ACCOUNT) {
767            // We were waiting for the user to create an account
768            if (result != RESULT_OK) {
769                finish();
770            } else {
771                // Watch for accounts to show up!
772                // restart the loader to get the updated list of accounts
773                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
774                showWaitFragment(null);
775            }
776        }
777    }
778
779    @Override
780    protected final void onRestoreInstanceState(Bundle savedInstanceState) {
781        final boolean hasAccounts = mAccounts != null && mAccounts.length > 0;
782        if (hasAccounts) {
783            clearChangeListeners();
784        }
785        super.onRestoreInstanceState(savedInstanceState);
786        if (mInnerSavedState != null) {
787            if (mInnerSavedState.containsKey(EXTRA_FOCUS_SELECTION_START)) {
788                int selectionStart = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_START);
789                int selectionEnd = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_END);
790                // There should be a focus and it should be an EditText since we
791                // only save these extras if these conditions are true.
792                EditText focusEditText = (EditText) getCurrentFocus();
793                final int length = focusEditText.getText().length();
794                if (selectionStart < length && selectionEnd < length) {
795                    focusEditText.setSelection(selectionStart, selectionEnd);
796                }
797            }
798        }
799        if (hasAccounts) {
800            initChangeListeners();
801        }
802    }
803
804    @Override
805    protected final void onSaveInstanceState(Bundle state) {
806        super.onSaveInstanceState(state);
807        final Bundle inner = new Bundle();
808        saveState(inner);
809        state.putBundle(KEY_INNER_SAVED_STATE, inner);
810    }
811
812    private void saveState(Bundle state) {
813        // We have no accounts so there is nothing to compose, and therefore, nothing to save.
814        if (mAccounts == null || mAccounts.length == 0) {
815            return;
816        }
817        // The framework is happy to save and restore the selection but only if it also saves and
818        // restores the contents of the edit text. That's a lot of text to put in a bundle so we do
819        // this manually.
820        View focus = getCurrentFocus();
821        if (focus != null && focus instanceof EditText) {
822            EditText focusEditText = (EditText) focus;
823            state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart());
824            state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd());
825        }
826
827        final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
828        final int selectedPos = mFromSpinner.getSelectedItemPosition();
829        final ReplyFromAccount selectedReplyFromAccount = (replyFromAccounts != null
830                && replyFromAccounts.size() > 0 && replyFromAccounts.size() > selectedPos) ?
831                        replyFromAccounts.get(selectedPos) : null;
832        if (selectedReplyFromAccount != null) {
833            state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize()
834                    .toString());
835            state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account);
836        } else {
837            state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount);
838        }
839
840        if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId !=0) {
841            // We don't have a draft id, and we have a request id,
842            // save the request id.
843            state.putInt(EXTRA_REQUEST_ID, mRequestId);
844        }
845
846        // We want to restore the current mode after a pause
847        // or rotation.
848        int mode = getMode();
849        state.putInt(EXTRA_ACTION, mode);
850
851        final Message message = createMessage(selectedReplyFromAccount, mode);
852        if (mDraft != null) {
853            message.id = mDraft.id;
854            message.serverId = mDraft.serverId;
855            message.uri = mDraft.uri;
856        }
857        state.putParcelable(EXTRA_MESSAGE, message);
858
859        if (mRefMessage != null) {
860            state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage);
861        } else if (message.appendRefMessageContent) {
862            // If we have no ref message but should be appending
863            // ref message content, we have orphaned quoted text. Save it.
864            state.putCharSequence(EXTRA_QUOTED_TEXT, mQuotedTextView.getQuotedTextIfIncluded());
865        }
866        state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible());
867        state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible());
868        state.putBoolean(EXTRA_RESPONDED_INLINE, mRespondedInline);
869        state.putBoolean(EXTRA_SAVE_ENABLED, mSave != null && mSave.isEnabled());
870        state.putParcelableArrayList(
871                EXTRA_ATTACHMENT_PREVIEWS, mAttachmentsView.getAttachmentPreviews());
872    }
873
874    private int getMode() {
875        int mode = ComposeActivity.COMPOSE;
876        ActionBar actionBar = getActionBar();
877        if (actionBar != null
878                && actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) {
879            mode = actionBar.getSelectedNavigationIndex();
880        }
881        return mode;
882    }
883
884    private Message createMessage(ReplyFromAccount selectedReplyFromAccount, int mode) {
885        Message message = new Message();
886        message.id = UIProvider.INVALID_MESSAGE_ID;
887        message.serverId = null;
888        message.uri = null;
889        message.conversationUri = null;
890        message.subject = mSubject.getText().toString();
891        message.snippet = null;
892        message.setTo(formatSenders(mTo.getText().toString()));
893        message.setCc(formatSenders(mCc.getText().toString()));
894        message.setBcc(formatSenders(mBcc.getText().toString()));
895        message.setReplyTo(null);
896        message.dateReceivedMs = 0;
897        final String htmlBody = Html.toHtml(removeComposingSpans(mBodyView.getText()));
898        final StringBuilder fullBody = new StringBuilder(htmlBody);
899        message.bodyHtml = fullBody.toString();
900        message.bodyText = mBodyView.getText().toString();
901        message.embedsExternalResources = false;
902        message.refMessageUri = mRefMessage != null ? mRefMessage.uri : null;
903        message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null;
904        ArrayList<Attachment> attachments = mAttachmentsView.getAttachments();
905        message.hasAttachments = attachments != null && attachments.size() > 0;
906        message.attachmentListUri = null;
907        message.messageFlags = 0;
908        message.alwaysShowImages = false;
909        message.attachmentsJson = Attachment.toJSONArray(attachments);
910        CharSequence quotedText = mQuotedTextView.getQuotedText();
911        message.quotedTextOffset = !TextUtils.isEmpty(quotedText) ? QuotedTextView
912                .getQuotedTextOffset(quotedText.toString()) : -1;
913        message.accountUri = null;
914        message.setFrom(selectedReplyFromAccount != null ? selectedReplyFromAccount.address
915                : mAccount != null ? mAccount.name : null);
916        message.draftType = getDraftType(mode);
917        return message;
918    }
919
920    private static String formatSenders(final String string) {
921        if (!TextUtils.isEmpty(string) && string.charAt(string.length() - 1) == ',') {
922            return string.substring(0, string.length() - 1);
923        }
924        return string;
925    }
926
927    @VisibleForTesting
928    void setAccount(Account account) {
929        if (account == null) {
930            return;
931        }
932        if (!account.equals(mAccount)) {
933            mAccount = account;
934            mCachedSettings = mAccount.settings;
935            appendSignature();
936        }
937        if (mAccount != null) {
938            MailActivity.setNfcMessage(mAccount.name);
939        }
940    }
941
942    private void initFromSpinner(Bundle bundle, int action) {
943        if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) {
944            action = COMPOSE;
945        }
946        mFromSpinner.initialize(action, mAccount, mAccounts, mRefMessage);
947
948        if (bundle != null) {
949            if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) {
950                mReplyFromAccount = ReplyFromAccount.deserialize(mAccount,
951                        bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT));
952            } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) {
953                final String accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING);
954                mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString);
955            }
956        }
957        if (mReplyFromAccount == null) {
958            if (mDraft != null) {
959                mReplyFromAccount = getReplyFromAccountFromDraft(mAccount, mDraft);
960            } else if (mRefMessage != null) {
961                mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage);
962            }
963        }
964        if (mReplyFromAccount == null) {
965            mReplyFromAccount = getDefaultReplyFromAccount(mAccount);
966        }
967
968        mFromSpinner.setCurrentAccount(mReplyFromAccount);
969
970        if (mFromSpinner.getCount() > 1) {
971            // If there is only 1 account, just show that account.
972            // Otherwise, give the user the ability to choose which account to
973            // send mail from / save drafts to.
974            mFromStatic.setVisibility(View.GONE);
975            mFromStaticText.setText(mReplyFromAccount.name);
976            mFromSpinnerWrapper.setVisibility(View.VISIBLE);
977        } else {
978            mFromStatic.setVisibility(View.VISIBLE);
979            mFromStaticText.setText(mReplyFromAccount.name);
980            mFromSpinnerWrapper.setVisibility(View.GONE);
981        }
982    }
983
984    private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) {
985        if (refMessage.accountUri != null) {
986            // This must be from combined inbox.
987            List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
988            for (ReplyFromAccount from : replyFromAccounts) {
989                if (from.account.uri.equals(refMessage.accountUri)) {
990                    return from;
991                }
992            }
993            return null;
994        } else {
995            return getReplyFromAccount(account, refMessage);
996        }
997    }
998
999    /**
1000     * Given an account and the message we're replying to,
1001     * return who the message should be sent from.
1002     * @param account Account in which the message arrived.
1003     * @param refMessage Message to analyze for account selection
1004     * @return the address from which to reply.
1005     */
1006    public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) {
1007        // First see if we are supposed to use the default address or
1008        // the address it was sentTo.
1009        if (mCachedSettings.forceReplyFromDefault) {
1010            return getDefaultReplyFromAccount(account);
1011        } else {
1012            // If we aren't explicitly told which account to look for, look at
1013            // all the message recipients and find one that matches
1014            // a custom from or account.
1015            List<String> allRecipients = new ArrayList<String>();
1016            allRecipients.addAll(Arrays.asList(refMessage.getToAddressesUnescaped()));
1017            allRecipients.addAll(Arrays.asList(refMessage.getCcAddressesUnescaped()));
1018            return getMatchingRecipient(account, allRecipients);
1019        }
1020    }
1021
1022    /**
1023     * Compare all the recipients of an email to the current account and all
1024     * custom addresses associated with that account. Return the match if there
1025     * is one, or the default account if there isn't.
1026     */
1027    protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) {
1028        // Tokenize the list and place in a hashmap.
1029        ReplyFromAccount matchingReplyFrom = null;
1030        Rfc822Token[] tokens;
1031        HashSet<String> recipientsMap = new HashSet<String>();
1032        for (String address : sentTo) {
1033            tokens = Rfc822Tokenizer.tokenize(address);
1034            for (int i = 0; i < tokens.length; i++) {
1035                recipientsMap.add(tokens[i].getAddress());
1036            }
1037        }
1038
1039        int matchingAddressCount = 0;
1040        List<ReplyFromAccount> customFroms;
1041        customFroms = account.getReplyFroms();
1042        if (customFroms != null) {
1043            for (ReplyFromAccount entry : customFroms) {
1044                if (recipientsMap.contains(entry.address)) {
1045                    matchingReplyFrom = entry;
1046                    matchingAddressCount++;
1047                }
1048            }
1049        }
1050        if (matchingAddressCount > 1) {
1051            matchingReplyFrom = getDefaultReplyFromAccount(account);
1052        }
1053        return matchingReplyFrom;
1054    }
1055
1056    private static ReplyFromAccount getDefaultReplyFromAccount(final Account account) {
1057        for (final ReplyFromAccount from : account.getReplyFroms()) {
1058            if (from.isDefault) {
1059                return from;
1060            }
1061        }
1062        return new ReplyFromAccount(account, account.uri, account.name, account.name, account.name,
1063                true, false);
1064    }
1065
1066    private ReplyFromAccount getReplyFromAccountFromDraft(Account account, Message msg) {
1067        String sender = msg.getFrom();
1068        ReplyFromAccount replyFromAccount = null;
1069        List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
1070        if (TextUtils.equals(account.name, sender)) {
1071            replyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name,
1072                    mAccount.name, mAccount.name, true, false);
1073        } else {
1074            for (ReplyFromAccount fromAccount : replyFromAccounts) {
1075                if (TextUtils.equals(fromAccount.name, sender)) {
1076                    replyFromAccount = fromAccount;
1077                    break;
1078                }
1079            }
1080        }
1081        return replyFromAccount;
1082    }
1083
1084    private void findViews() {
1085        findViewById(R.id.compose).setVisibility(View.VISIBLE);
1086        mCcBccButton = (Button) findViewById(R.id.add_cc_bcc);
1087        if (mCcBccButton != null) {
1088            mCcBccButton.setOnClickListener(this);
1089        }
1090        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
1091        mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments);
1092        mPhotoAttachmentsButton = findViewById(R.id.add_photo_attachment);
1093        if (mPhotoAttachmentsButton != null) {
1094            mPhotoAttachmentsButton.setOnClickListener(this);
1095        }
1096        mVideoAttachmentsButton = findViewById(R.id.add_video_attachment);
1097        if (mVideoAttachmentsButton != null) {
1098            mVideoAttachmentsButton.setOnClickListener(this);
1099        }
1100        mTo = (RecipientEditTextView) findViewById(R.id.to);
1101        mTo.setTokenizer(new Rfc822Tokenizer());
1102        mCc = (RecipientEditTextView) findViewById(R.id.cc);
1103        mCc.setTokenizer(new Rfc822Tokenizer());
1104        mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
1105        mBcc.setTokenizer(new Rfc822Tokenizer());
1106        // TODO: add special chips text change watchers before adding
1107        // this as a text changed watcher to the to, cc, bcc fields.
1108        mSubject = (TextView) findViewById(R.id.subject);
1109        mSubject.setOnEditorActionListener(this);
1110        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
1111        mQuotedTextView.setRespondInlineListener(this);
1112        mBodyView = (EditText) findViewById(R.id.body);
1113        mFromStatic = findViewById(R.id.static_from_content);
1114        mFromStaticText = (TextView) findViewById(R.id.from_account_name);
1115        mFromSpinnerWrapper = findViewById(R.id.spinner_from_content);
1116        mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker);
1117    }
1118
1119    @Override
1120    public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
1121        if (action == EditorInfo.IME_ACTION_DONE) {
1122            focusBody();
1123            return true;
1124        }
1125        return false;
1126    }
1127
1128    protected TextView getBody() {
1129        return mBodyView;
1130    }
1131
1132    @VisibleForTesting
1133    public Account getFromAccount() {
1134        return mReplyFromAccount != null && mReplyFromAccount.account != null ?
1135                mReplyFromAccount.account : mAccount;
1136    }
1137
1138    private void clearChangeListeners() {
1139        mSubject.removeTextChangedListener(this);
1140        mBodyView.removeTextChangedListener(this);
1141        mTo.removeTextChangedListener(mToListener);
1142        mCc.removeTextChangedListener(mCcListener);
1143        mBcc.removeTextChangedListener(mBccListener);
1144        mFromSpinner.setOnAccountChangedListener(null);
1145        mAttachmentsView.setAttachmentChangesListener(null);
1146    }
1147
1148    // Now that the message has been initialized from any existing draft or
1149    // ref message data, set up listeners for any changes that occur to the
1150    // message.
1151    private void initChangeListeners() {
1152        // Make sure we only add text changed listeners once!
1153        clearChangeListeners();
1154        mSubject.addTextChangedListener(this);
1155        mBodyView.addTextChangedListener(this);
1156        if (mToListener == null) {
1157            mToListener = new RecipientTextWatcher(mTo, this);
1158        }
1159        mTo.addTextChangedListener(mToListener);
1160        if (mCcListener == null) {
1161            mCcListener = new RecipientTextWatcher(mCc, this);
1162        }
1163        mCc.addTextChangedListener(mCcListener);
1164        if (mBccListener == null) {
1165            mBccListener = new RecipientTextWatcher(mBcc, this);
1166        }
1167        mBcc.addTextChangedListener(mBccListener);
1168        mFromSpinner.setOnAccountChangedListener(this);
1169        mAttachmentsView.setAttachmentChangesListener(this);
1170    }
1171
1172    private void initActionBar() {
1173        LogUtils.d(LOG_TAG, "initializing action bar in ComposeActivity");
1174        ActionBar actionBar = getActionBar();
1175        if (actionBar == null) {
1176            return;
1177        }
1178        if (mComposeMode == ComposeActivity.COMPOSE) {
1179            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
1180            actionBar.setTitle(R.string.compose);
1181        } else {
1182            actionBar.setTitle(null);
1183            if (mComposeModeAdapter == null) {
1184                mComposeModeAdapter = new ComposeModeAdapter(this);
1185            }
1186            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
1187            actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
1188            switch (mComposeMode) {
1189                case ComposeActivity.REPLY:
1190                    actionBar.setSelectedNavigationItem(0);
1191                    break;
1192                case ComposeActivity.REPLY_ALL:
1193                    actionBar.setSelectedNavigationItem(1);
1194                    break;
1195                case ComposeActivity.FORWARD:
1196                    actionBar.setSelectedNavigationItem(2);
1197                    break;
1198            }
1199        }
1200        actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME,
1201                ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME);
1202        actionBar.setHomeButtonEnabled(true);
1203    }
1204
1205    private void initFromRefMessage(int action) {
1206        setFieldsFromRefMessage(action);
1207
1208        // Check if To: address and email body needs to be prefilled based on extras.
1209        // This is used for reporting rendering feedback.
1210        if (MessageHeaderView.ENABLE_REPORT_RENDERING_PROBLEM) {
1211            Intent intent = getIntent();
1212            if (intent.getExtras() != null) {
1213                String toAddresses = intent.getStringExtra(EXTRA_TO);
1214                if (toAddresses != null) {
1215                    addToAddresses(Arrays.asList(TextUtils.split(toAddresses, ",")));
1216                }
1217                String body = intent.getStringExtra(EXTRA_BODY);
1218                if (body != null) {
1219                    setBody(body, false /* withSignature */);
1220                }
1221            }
1222        }
1223
1224        if (mRefMessage != null) {
1225            // CC field only gets populated when doing REPLY_ALL.
1226            // BCC never gets auto-populated, unless the user is editing
1227            // a draft with one.
1228            if (!TextUtils.isEmpty(mCc.getText()) && action == REPLY_ALL) {
1229                mCcBccView.show(false, true, false);
1230            }
1231        }
1232        updateHideOrShowCcBcc();
1233    }
1234
1235    private void setFieldsFromRefMessage(int action) {
1236        setSubject(mRefMessage, action);
1237        // Setup recipients
1238        if (action == FORWARD) {
1239            mForward = true;
1240        }
1241        initRecipientsFromRefMessage(mRefMessage, action);
1242        initQuotedTextFromRefMessage(mRefMessage, action);
1243        if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
1244            initAttachments(mRefMessage);
1245        }
1246    }
1247
1248    private void initFromDraftMessage(Message message) {
1249        LogUtils.d(LOG_TAG, "Intializing draft from previous draft message: %s", message);
1250
1251        mDraft = message;
1252        mDraftId = message.id;
1253        mSubject.setText(message.subject);
1254        mForward = message.draftType == UIProvider.DraftType.FORWARD;
1255        final List<String> toAddresses = Arrays.asList(message.getToAddressesUnescaped());
1256        addToAddresses(toAddresses);
1257        addCcAddresses(Arrays.asList(message.getCcAddressesUnescaped()), toAddresses);
1258        addBccAddresses(Arrays.asList(message.getBccAddressesUnescaped()));
1259        if (message.hasAttachments) {
1260            List<Attachment> attachments = message.getAttachments();
1261            for (Attachment a : attachments) {
1262                addAttachmentAndUpdateView(a);
1263            }
1264        }
1265        int quotedTextIndex = message.appendRefMessageContent ?
1266                message.quotedTextOffset : -1;
1267        // Set the body
1268        CharSequence quotedText = null;
1269        if (!TextUtils.isEmpty(message.bodyHtml)) {
1270            CharSequence htmlText = "";
1271            if (quotedTextIndex > -1) {
1272                // Find the offset in the htmltext of the actual quoted text and strip it out.
1273                quotedTextIndex = QuotedTextView.findQuotedTextIndex(message.bodyHtml);
1274                if (quotedTextIndex > -1) {
1275                    htmlText = Utils.convertHtmlToPlainText(message.bodyHtml.substring(0,
1276                            quotedTextIndex));
1277                    quotedText = message.bodyHtml.subSequence(quotedTextIndex,
1278                            message.bodyHtml.length());
1279                }
1280            } else {
1281                htmlText = Utils.convertHtmlToPlainText(message.bodyHtml);
1282            }
1283            mBodyView.setText(htmlText);
1284        } else {
1285            final String body = message.bodyText;
1286            final CharSequence bodyText = !TextUtils.isEmpty(body) ?
1287                    (quotedTextIndex > -1 ?
1288                            message.bodyText.substring(0, quotedTextIndex) : message.bodyText)
1289                            : "";
1290            if (quotedTextIndex > -1) {
1291                quotedText = !TextUtils.isEmpty(body) ? message.bodyText.substring(quotedTextIndex)
1292                        : null;
1293            }
1294            mBodyView.setText(bodyText);
1295        }
1296        if (quotedTextIndex > -1 && quotedText != null) {
1297            mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward);
1298        }
1299    }
1300
1301    /**
1302     * Fill all the widgets with the content found in the Intent Extra, if any.
1303     * Also apply the same style to all widgets. Note: if initFromExtras is
1304     * called as a result of switching between reply, reply all, and forward per
1305     * the latest revision of Gmail, and the user has already made changes to
1306     * attachments on a previous incarnation of the message (as a reply, reply
1307     * all, or forward), the original attachments from the message will not be
1308     * re-instantiated. The user's changes will be respected. This follows the
1309     * web gmail interaction.
1310     */
1311    public void initFromExtras(Intent intent) {
1312        // If we were invoked with a SENDTO intent, the value
1313        // should take precedence
1314        final Uri dataUri = intent.getData();
1315        if (dataUri != null) {
1316            if (MAIL_TO.equals(dataUri.getScheme())) {
1317                initFromMailTo(dataUri.toString());
1318            } else {
1319                if (!mAccount.composeIntentUri.equals(dataUri)) {
1320                    String toText = dataUri.getSchemeSpecificPart();
1321                    if (toText != null) {
1322                        mTo.setText("");
1323                        addToAddresses(Arrays.asList(TextUtils.split(toText, ",")));
1324                    }
1325                }
1326            }
1327        }
1328
1329        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
1330        if (extraStrings != null) {
1331            addToAddresses(Arrays.asList(extraStrings));
1332        }
1333        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
1334        if (extraStrings != null) {
1335            addCcAddresses(Arrays.asList(extraStrings), null);
1336        }
1337        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
1338        if (extraStrings != null) {
1339            addBccAddresses(Arrays.asList(extraStrings));
1340        }
1341
1342        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
1343        if (extraString != null) {
1344            mSubject.setText(extraString);
1345        }
1346
1347        mExtraValues = intent.getParcelableExtra(EXTRA_VALUES);
1348        if (mExtraValues != null) {
1349            LogUtils.d(LOG_TAG, "Launched with extra values: %s", mExtraValues.toString());
1350        }
1351
1352        for (String extra : ALL_EXTRAS) {
1353            if (intent.hasExtra(extra)) {
1354                String value = intent.getStringExtra(extra);
1355                if (EXTRA_TO.equals(extra)) {
1356                    addToAddresses(Arrays.asList(TextUtils.split(value, ",")));
1357                } else if (EXTRA_CC.equals(extra)) {
1358                    addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null);
1359                } else if (EXTRA_BCC.equals(extra)) {
1360                    addBccAddresses(Arrays.asList(TextUtils.split(value, ",")));
1361                } else if (EXTRA_SUBJECT.equals(extra)) {
1362                    mSubject.setText(value);
1363                } else if (EXTRA_BODY.equals(extra)) {
1364                    setBody(value, true /* with signature */);
1365                } else if (EXTRA_QUOTED_TEXT.equals(extra)) {
1366                    initQuotedText(value, true /* shouldQuoteText */);
1367                }
1368            }
1369        }
1370
1371        Bundle extras = intent.getExtras();
1372        if (extras != null) {
1373            CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT);
1374            if (text != null) {
1375                setBody(text, true /* with signature */);
1376            }
1377
1378            // TODO - support EXTRA_HTML_TEXT
1379        }
1380    }
1381
1382
1383    @VisibleForTesting
1384    protected String decodeEmailInUri(String s) throws UnsupportedEncodingException {
1385        // TODO: handle the case where there are spaces in the display name as
1386        // well as the email such as "Guy with spaces <guy+with+spaces@gmail.com>"
1387        // as they could be encoded ambiguously.
1388        // Since URLDecode.decode changes + into ' ', and + is a valid
1389        // email character, we need to find/ replace these ourselves before
1390        // decoding.
1391        try {
1392            return URLDecoder.decode(replacePlus(s), UTF8_ENCODING_NAME);
1393        } catch (IllegalArgumentException e) {
1394            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1395                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s);
1396            } else {
1397                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1398            }
1399            return null;
1400        }
1401    }
1402
1403    /**
1404     * Replaces all occurrences of '+' with "%2B", to prevent URLDecode.decode from
1405     * changing '+' into ' '
1406     *
1407     * @param toReplace Input string
1408     * @return The string with all "+" characters replaced with "%2B"
1409     */
1410    private static String replacePlus(String toReplace) {
1411        return toReplace.replace("+", "%2B");
1412    }
1413
1414    /**
1415     * Initialize the compose view from a String representing a mailTo uri.
1416     * @param mailToString The uri as a string.
1417     */
1418    public void initFromMailTo(String mailToString) {
1419        // We need to disguise this string as a URI in order to parse it
1420        // TODO:  Remove this hack when http://b/issue?id=1445295 gets fixed
1421        Uri uri = Uri.parse("foo://" + mailToString);
1422        int index = mailToString.indexOf("?");
1423        int length = "mailto".length() + 1;
1424        String to;
1425        try {
1426            // Extract the recipient after mailto:
1427            if (index == -1) {
1428                to = decodeEmailInUri(mailToString.substring(length));
1429            } else {
1430                to = decodeEmailInUri(mailToString.substring(length, index));
1431            }
1432            if (!TextUtils.isEmpty(to)) {
1433                addToAddresses(Arrays.asList(TextUtils.split(to, ",")));
1434            }
1435        } catch (UnsupportedEncodingException e) {
1436            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
1437                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString);
1438            } else {
1439                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
1440            }
1441        }
1442
1443        List<String> cc = uri.getQueryParameters("cc");
1444        addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null);
1445
1446        List<String> otherTo = uri.getQueryParameters("to");
1447        addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()])));
1448
1449        List<String> bcc = uri.getQueryParameters("bcc");
1450        addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()])));
1451
1452        List<String> subject = uri.getQueryParameters("subject");
1453        if (subject.size() > 0) {
1454            try {
1455                mSubject.setText(URLDecoder.decode(replacePlus(subject.get(0)),
1456                        UTF8_ENCODING_NAME));
1457            } catch (UnsupportedEncodingException e) {
1458                LogUtils.e(LOG_TAG, "%s while decoding subject '%s'",
1459                        e.getMessage(), subject);
1460            }
1461        }
1462
1463        List<String> body = uri.getQueryParameters("body");
1464        if (body.size() > 0) {
1465            try {
1466                setBody(URLDecoder.decode(replacePlus(body.get(0)), UTF8_ENCODING_NAME),
1467                        true /* with signature */);
1468            } catch (UnsupportedEncodingException e) {
1469                LogUtils.e(LOG_TAG, "%s while decoding body '%s'", e.getMessage(), body);
1470            }
1471        }
1472    }
1473
1474    @VisibleForTesting
1475    protected void initAttachments(Message refMessage) {
1476        addAttachments(refMessage.getAttachments());
1477    }
1478
1479    public long addAttachments(List<Attachment> attachments) {
1480        long size = 0;
1481        AttachmentFailureException error = null;
1482        for (Attachment a : attachments) {
1483            try {
1484                size += mAttachmentsView.addAttachment(mAccount, a);
1485            } catch (AttachmentFailureException e) {
1486                error = e;
1487            }
1488        }
1489        if (error != null) {
1490            LogUtils.e(LOG_TAG, error, "Error adding attachment");
1491            if (attachments.size() > 1) {
1492                showAttachmentTooBigToast(R.string.too_large_to_attach_multiple);
1493            } else {
1494                showAttachmentTooBigToast(error.getErrorRes());
1495            }
1496        }
1497        return size;
1498    }
1499
1500    /**
1501     * When an attachment is too large to be added to a message, show a toast.
1502     * This method also updates the position of the toast so that it is shown
1503     * clearly above they keyboard if it happens to be open.
1504     */
1505    private void showAttachmentTooBigToast(int errorRes) {
1506        String maxSize = AttachmentUtils.convertToHumanReadableSize(
1507                getApplicationContext(), mAccount.settings.getMaxAttachmentSize());
1508        showErrorToast(getString(errorRes, maxSize));
1509    }
1510
1511    private void showErrorToast(String message) {
1512        Toast t = Toast.makeText(this, message, Toast.LENGTH_LONG);
1513        t.setText(message);
1514        t.setGravity(Gravity.CENTER_HORIZONTAL, 0,
1515                getResources().getDimensionPixelSize(R.dimen.attachment_toast_yoffset));
1516        t.show();
1517    }
1518
1519    private void initAttachmentsFromIntent(Intent intent) {
1520        Bundle extras = intent.getExtras();
1521        if (extras == null) {
1522            extras = Bundle.EMPTY;
1523        }
1524        final String action = intent.getAction();
1525        if (!mAttachmentsChanged) {
1526            long totalSize = 0;
1527            if (extras.containsKey(EXTRA_ATTACHMENTS)) {
1528                String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS);
1529                for (String uriString : uris) {
1530                    final Uri uri = Uri.parse(uriString);
1531                    long size = 0;
1532                    try {
1533                        final Attachment a = mAttachmentsView.generateLocalAttachment(uri);
1534                        size = mAttachmentsView.addAttachment(mAccount, a);
1535
1536                        Analytics.getInstance().sendEvent("send_intent_attachment",
1537                                Utils.normalizeMimeType(a.getContentType()), null, size);
1538
1539                    } catch (AttachmentFailureException e) {
1540                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1541                        showAttachmentTooBigToast(e.getErrorRes());
1542                    }
1543                    totalSize += size;
1544                }
1545            }
1546            if (extras.containsKey(Intent.EXTRA_STREAM)) {
1547                if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
1548                    ArrayList<Parcelable> uris = extras
1549                            .getParcelableArrayList(Intent.EXTRA_STREAM);
1550                    ArrayList<Attachment> attachments = new ArrayList<Attachment>();
1551                    for (Parcelable uri : uris) {
1552                        try {
1553                            final Attachment a = mAttachmentsView.generateLocalAttachment(
1554                                    (Uri) uri);
1555                            attachments.add(a);
1556
1557                            Analytics.getInstance().sendEvent("send_intent_attachment",
1558                                    Utils.normalizeMimeType(a.getContentType()), null, a.size);
1559
1560                        } catch (AttachmentFailureException e) {
1561                            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1562                            String maxSize = AttachmentUtils.convertToHumanReadableSize(
1563                                    getApplicationContext(),
1564                                    mAccount.settings.getMaxAttachmentSize());
1565                            showErrorToast(getString
1566                                    (R.string.generic_attachment_problem, maxSize));
1567                        }
1568                    }
1569                    totalSize += addAttachments(attachments);
1570                } else {
1571                    final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM);
1572                    long size = 0;
1573                    try {
1574                        final Attachment a = mAttachmentsView.generateLocalAttachment(uri);
1575                        size = mAttachmentsView.addAttachment(mAccount, a);
1576
1577                        Analytics.getInstance().sendEvent("send_intent_attachment",
1578                                Utils.normalizeMimeType(a.getContentType()), null, size);
1579
1580                    } catch (AttachmentFailureException e) {
1581                        LogUtils.e(LOG_TAG, e, "Error adding attachment");
1582                        showAttachmentTooBigToast(e.getErrorRes());
1583                    }
1584                    totalSize += size;
1585                }
1586            }
1587
1588            if (totalSize > 0) {
1589                mAttachmentsChanged = true;
1590                updateSaveUi();
1591
1592                Analytics.getInstance().sendEvent("send_intent_with_attachments",
1593                        Integer.toString(getAttachments().size()), null, totalSize);
1594            }
1595        }
1596    }
1597
1598    private void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) {
1599        mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText);
1600        mShowQuotedText = true;
1601    }
1602
1603    private void initQuotedTextFromRefMessage(Message refMessage, int action) {
1604        if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) {
1605            mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD);
1606        }
1607    }
1608
1609    private void updateHideOrShowCcBcc() {
1610        // Its possible there is a menu item OR a button.
1611        boolean ccVisible = mCcBccView.isCcVisible();
1612        boolean bccVisible = mCcBccView.isBccVisible();
1613        if (mCcBccButton != null) {
1614            if (!ccVisible || !bccVisible) {
1615                mCcBccButton.setVisibility(View.VISIBLE);
1616                mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label
1617                        : R.string.add_bcc_label));
1618            } else {
1619                mCcBccButton.setVisibility(View.INVISIBLE);
1620            }
1621        }
1622    }
1623
1624    private void showCcBcc(Bundle state) {
1625        if (state != null && state.containsKey(EXTRA_SHOW_CC)) {
1626            boolean showCc = state.getBoolean(EXTRA_SHOW_CC);
1627            boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC);
1628            if (showCc || showBcc) {
1629                mCcBccView.show(false, showCc, showBcc);
1630            }
1631        }
1632    }
1633
1634    /**
1635     * Add attachment and update the compose area appropriately.
1636     * @param data
1637     */
1638    public void addAttachmentAndUpdateView(Intent data) {
1639        addAttachmentAndUpdateView(data != null ? data.getData() : (Uri) null);
1640    }
1641
1642    public void addAttachmentAndUpdateView(Uri contentUri) {
1643        if (contentUri == null) {
1644            return;
1645        }
1646        try {
1647            addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri));
1648        } catch (AttachmentFailureException e) {
1649            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1650            showErrorToast(getResources().getString(
1651                    e.getErrorRes(),
1652                    AttachmentUtils.convertToHumanReadableSize(
1653                            getApplicationContext(), mAccount.settings.getMaxAttachmentSize())));
1654        }
1655    }
1656
1657    public void addAttachmentAndUpdateView(Attachment attachment) {
1658        try {
1659            long size = mAttachmentsView.addAttachment(mAccount, attachment);
1660            if (size > 0) {
1661                mAttachmentsChanged = true;
1662                updateSaveUi();
1663            }
1664        } catch (AttachmentFailureException e) {
1665            LogUtils.e(LOG_TAG, e, "Error adding attachment");
1666            showAttachmentTooBigToast(e.getErrorRes());
1667        }
1668    }
1669
1670    void initRecipientsFromRefMessage(Message refMessage, int action) {
1671        // Don't populate the address if this is a forward.
1672        if (action == ComposeActivity.FORWARD) {
1673            return;
1674        }
1675        initReplyRecipients(refMessage, action);
1676    }
1677
1678    // TODO: This should be private.  This method shouldn't be used by ComposeActivityTests, as
1679    // it doesn't setup the state of the activity correctly
1680    @VisibleForTesting
1681    void initReplyRecipients(final Message refMessage, final int action) {
1682        String[] sentToAddresses = refMessage.getToAddressesUnescaped();
1683        final Collection<String> toAddresses;
1684        final String[] replyToAddresses = refMessage.getReplyToAddressesUnescaped();
1685        String replyToAddress = replyToAddresses.length > 0 ? replyToAddresses[0] : null;
1686        final String[] fromAddresses = refMessage.getFromAddressesUnescaped();
1687        final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null;
1688
1689        // If there is no reply to address, the reply to address is the sender.
1690        if (TextUtils.isEmpty(replyToAddress)) {
1691            replyToAddress = fromAddress;
1692        }
1693
1694        // If this is a reply, the Cc list is empty. If this is a reply-all, the
1695        // Cc list is the union of the To and Cc recipients of the original
1696        // message, excluding the current user's email address and any addresses
1697        // already on the To list.
1698        if (action == ComposeActivity.REPLY) {
1699            toAddresses = initToRecipients(fromAddress, replyToAddress, sentToAddresses);
1700            addToAddresses(toAddresses);
1701        } else if (action == ComposeActivity.REPLY_ALL) {
1702            final Set<String> ccAddresses = Sets.newHashSet();
1703            toAddresses = initToRecipients(fromAddress, replyToAddress, sentToAddresses);
1704            addToAddresses(toAddresses);
1705            addRecipients(ccAddresses, sentToAddresses);
1706            addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped());
1707            addCcAddresses(ccAddresses, toAddresses);
1708        }
1709    }
1710
1711    private void addToAddresses(Collection<String> addresses) {
1712        addAddressesToList(addresses, mTo);
1713    }
1714
1715    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
1716        addCcAddressesToList(tokenizeAddressList(addresses),
1717                toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc);
1718    }
1719
1720    private void addBccAddresses(Collection<String> addresses) {
1721        addAddressesToList(addresses, mBcc);
1722    }
1723
1724    @VisibleForTesting
1725    protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
1726            List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
1727        String address;
1728
1729        if (compareToList == null) {
1730            for (Rfc822Token[] tokens : addresses) {
1731                for (int i = 0; i < tokens.length; i++) {
1732                    address = tokens[i].toString();
1733                    list.append(address + END_TOKEN);
1734                }
1735            }
1736        } else {
1737            HashSet<String> compareTo = convertToHashSet(compareToList);
1738            for (Rfc822Token[] tokens : addresses) {
1739                for (int i = 0; i < tokens.length; i++) {
1740                    address = tokens[i].toString();
1741                    // Check if this is a duplicate:
1742                    if (!compareTo.contains(tokens[i].getAddress())) {
1743                        // Get the address here
1744                        list.append(address + END_TOKEN);
1745                    }
1746                }
1747            }
1748        }
1749    }
1750
1751    private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) {
1752        final HashSet<String> hash = new HashSet<String>();
1753        for (final Rfc822Token[] tokens : list) {
1754            for (int i = 0; i < tokens.length; i++) {
1755                hash.add(tokens[i].getAddress());
1756            }
1757        }
1758        return hash;
1759    }
1760
1761    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
1762        @VisibleForTesting
1763        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();
1764
1765        for (String address: addresses) {
1766            tokenized.add(Rfc822Tokenizer.tokenize(address));
1767        }
1768        return tokenized;
1769    }
1770
1771    @VisibleForTesting
1772    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
1773        for (String address : addresses) {
1774            addAddressToList(address, list);
1775        }
1776    }
1777
1778    private static void addAddressToList(final String address, final RecipientEditTextView list) {
1779        if (address == null || list == null)
1780            return;
1781
1782        final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
1783
1784        for (int i = 0; i < tokens.length; i++) {
1785            list.append(tokens[i] + END_TOKEN);
1786        }
1787    }
1788
1789    @VisibleForTesting
1790    protected Collection<String> initToRecipients(final String fullSenderAddress,
1791            final String replyToAddress, final String[] inToAddresses) {
1792        // The To recipient is the reply-to address specified in the original
1793        // message, unless it is:
1794        // the current user OR a custom from of the current user, in which case
1795        // it's the To recipient list of the original message.
1796        // OR missing, in which case use the sender of the original message
1797        Set<String> toAddresses = Sets.newHashSet();
1798        if (!TextUtils.isEmpty(replyToAddress) && !recipientMatchesThisAccount(replyToAddress)) {
1799            toAddresses.add(replyToAddress);
1800        } else {
1801            // In this case, the user is replying to a message in which their
1802            // current account or one of their custom from addresses is the only
1803            // recipient and they sent the original message.
1804            if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress)
1805                    && recipientMatchesThisAccount(inToAddresses[0])) {
1806                toAddresses.add(inToAddresses[0]);
1807                return toAddresses;
1808            }
1809            // This happens if the user replies to a message they originally
1810            // wrote. In this case, "reply" really means "re-send," so we
1811            // target the original recipients. This works as expected even
1812            // if the user sent the original message to themselves.
1813            for (String address : inToAddresses) {
1814                if (!recipientMatchesThisAccount(address)) {
1815                    toAddresses.add(address);
1816                }
1817            }
1818        }
1819        return toAddresses;
1820    }
1821
1822    private void addRecipients(final Set<String> recipients, final String[] addresses) {
1823        for (final String email : addresses) {
1824            // Do not add this account, or any of its custom from addresses, to
1825            // the list of recipients.
1826            final String recipientAddress = Address.getEmailAddress(email).getAddress();
1827            if (!recipientMatchesThisAccount(recipientAddress)) {
1828                recipients.add(email.replace("\"\"", ""));
1829            }
1830        }
1831    }
1832
1833    /**
1834     * A recipient matches this account if it has the same address as the
1835     * currently selected account OR one of the custom from addresses associated
1836     * with the currently selected account.
1837     * @param recipientAddress address we are comparing with the currently selected account
1838     * @return
1839     */
1840    protected boolean recipientMatchesThisAccount(String recipientAddress) {
1841        return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress,
1842                        mAccount.getReplyFroms());
1843    }
1844
1845    /**
1846     * Returns a formatted subject string with the appropriate prefix for the action type.
1847     * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}.
1848     */
1849    public static String buildFormattedSubject(Resources res, String subject, int action) {
1850        String prefix;
1851        String correctedSubject = null;
1852        if (action == ComposeActivity.COMPOSE) {
1853            prefix = "";
1854        } else if (action == ComposeActivity.FORWARD) {
1855            prefix = res.getString(R.string.forward_subject_label);
1856        } else {
1857            prefix = res.getString(R.string.reply_subject_label);
1858        }
1859
1860        // Don't duplicate the prefix
1861        if (!TextUtils.isEmpty(subject)
1862                && subject.toLowerCase().startsWith(prefix.toLowerCase())) {
1863            correctedSubject = subject;
1864        } else {
1865            correctedSubject = String.format(
1866                    res.getString(R.string.formatted_subject), prefix, subject);
1867        }
1868
1869        return correctedSubject;
1870    }
1871
1872    private void setSubject(Message refMessage, int action) {
1873        mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action));
1874    }
1875
1876    private void initRecipients() {
1877        setupRecipients(mTo);
1878        setupRecipients(mCc);
1879        setupRecipients(mBcc);
1880    }
1881
1882    private void setupRecipients(RecipientEditTextView view) {
1883        view.setAdapter(new RecipientAdapter(this, mAccount));
1884        if (mValidator == null) {
1885            final String accountName = mAccount.name;
1886            int offset = accountName.indexOf("@") + 1;
1887            String account = accountName;
1888            if (offset > -1) {
1889                account = account.substring(accountName.indexOf("@") + 1);
1890            }
1891            mValidator = new Rfc822Validator(account);
1892        }
1893        view.setValidator(mValidator);
1894    }
1895
1896    @Override
1897    public void onClick(View v) {
1898        final int id = v.getId();
1899        if (id == R.id.add_cc_bcc) {
1900            // Verify that cc/ bcc aren't showing.
1901            // Animate in cc/bcc.
1902            showCcBccViews();
1903        } else if (id == R.id.add_photo_attachment) {
1904            doAttach(MIME_TYPE_PHOTO);
1905        } else if (id == R.id.add_video_attachment) {
1906            doAttach(MIME_TYPE_VIDEO);
1907        }
1908    }
1909
1910    @Override
1911    public boolean onCreateOptionsMenu(Menu menu) {
1912        super.onCreateOptionsMenu(menu);
1913        // Don't render any menu items when there are no accounts.
1914        if (mAccounts == null || mAccounts.length == 0) {
1915            return true;
1916        }
1917        MenuInflater inflater = getMenuInflater();
1918        inflater.inflate(R.menu.compose_menu, menu);
1919
1920        /*
1921         * Start save in the correct enabled state.
1922         * 1) If a user launches compose from within gmail, save is disabled
1923         * until they add something, at which point, save is enabled, auto save
1924         * on exit; if the user empties everything, save is disabled, exiting does not
1925         * auto-save
1926         * 2) if a user replies/ reply all/ forwards from within gmail, save is
1927         * disabled until they change something, at which point, save is
1928         * enabled, auto save on exit; if the user empties everything, save is
1929         * disabled, exiting does not auto-save.
1930         * 3) If a user launches compose from another application and something
1931         * gets populated (attachments, recipients, body, subject, etc), save is
1932         * enabled, auto save on exit; if the user empties everything, save is
1933         * disabled, exiting does not auto-save
1934         */
1935        mSave = menu.findItem(R.id.save);
1936        String action = getIntent() != null ? getIntent().getAction() : null;
1937        enableSave(mInnerSavedState != null ?
1938                mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED)
1939                    : (Intent.ACTION_SEND.equals(action)
1940                            || Intent.ACTION_SEND_MULTIPLE.equals(action)
1941                            || Intent.ACTION_SENDTO.equals(action)
1942                            || shouldSave()));
1943
1944        mSend = menu.findItem(R.id.send);
1945        MenuItem helpItem = menu.findItem(R.id.help_info_menu_item);
1946        MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item);
1947        if (helpItem != null) {
1948            helpItem.setVisible(mAccount != null
1949                    && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT));
1950        }
1951        if (sendFeedbackItem != null) {
1952            sendFeedbackItem.setVisible(mAccount != null
1953                    && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK));
1954        }
1955        return true;
1956    }
1957
1958    @Override
1959    public boolean onPrepareOptionsMenu(Menu menu) {
1960        MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
1961        if (ccBcc != null && mCc != null) {
1962            // Its possible there is a menu item OR a button.
1963            boolean ccFieldVisible = mCc.isShown();
1964            boolean bccFieldVisible = mBcc.isShown();
1965            if (!ccFieldVisible || !bccFieldVisible) {
1966                ccBcc.setVisible(true);
1967                ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
1968                        : R.string.add_bcc_label));
1969            } else {
1970                ccBcc.setVisible(false);
1971            }
1972        }
1973        return true;
1974    }
1975
1976    @Override
1977    public boolean onOptionsItemSelected(MenuItem item) {
1978        final int id = item.getItemId();
1979
1980        Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id, null, 0);
1981
1982        boolean handled = true;
1983        if (id == R.id.add_photo_attachment) {
1984            doAttach(MIME_TYPE_PHOTO);
1985        } else if (id == R.id.add_video_attachment) {
1986            doAttach(MIME_TYPE_VIDEO);
1987        } else if (id == R.id.add_cc_bcc) {
1988            showCcBccViews();
1989        } else if (id == R.id.save) {
1990            doSave(true);
1991        } else if (id == R.id.send) {
1992            doSend();
1993        } else if (id == R.id.discard) {
1994            doDiscard();
1995        } else if (id == R.id.settings) {
1996            Utils.showSettings(this, mAccount);
1997        } else if (id == android.R.id.home) {
1998            onAppUpPressed();
1999        } else if (id == R.id.help_info_menu_item) {
2000            Utils.showHelp(this, mAccount, getString(R.string.compose_help_context));
2001        } else if (id == R.id.feedback_menu_item) {
2002            Utils.sendFeedback(this, mAccount, false);
2003        } else {
2004            handled = false;
2005        }
2006        return !handled ? super.onOptionsItemSelected(item) : handled;
2007    }
2008
2009    @Override
2010    public void onBackPressed() {
2011        // If we are showing the wait fragment, just exit.
2012        if (getWaitFragment() != null) {
2013            finish();
2014        } else {
2015            super.onBackPressed();
2016        }
2017    }
2018
2019    /**
2020     * Carries out the "up" action in the action bar.
2021     */
2022    private void onAppUpPressed() {
2023        if (mLaunchedFromEmail) {
2024            // If this was started from Gmail, simply treat app up as the system back button, so
2025            // that the last view is restored.
2026            onBackPressed();
2027            return;
2028        }
2029
2030        // Fire the main activity to ensure it launches the "top" screen of mail.
2031        // Since the main Activity is singleTask, it should revive that task if it was already
2032        // started.
2033        final Intent mailIntent = Utils.createViewInboxIntent(mAccount);
2034        mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK |
2035                Intent.FLAG_ACTIVITY_TASK_ON_HOME);
2036        startActivity(mailIntent);
2037        finish();
2038    }
2039
2040    private void doSend() {
2041        sendOrSaveWithSanityChecks(false, true, false, false);
2042        logSendOrSave(false /* save */);
2043        mPerformedSendOrDiscard = true;
2044    }
2045
2046    private void doSave(boolean showToast) {
2047        sendOrSaveWithSanityChecks(true, showToast, false, false);
2048    }
2049
2050    @VisibleForTesting
2051    public interface SendOrSaveCallback {
2052        public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask);
2053        public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
2054        public Message getMessage();
2055        public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success);
2056    }
2057
2058    @VisibleForTesting
2059    public static class SendOrSaveTask implements Runnable {
2060        private final Context mContext;
2061        @VisibleForTesting
2062        public final SendOrSaveCallback mSendOrSaveCallback;
2063        @VisibleForTesting
2064        public final SendOrSaveMessage mSendOrSaveMessage;
2065        private ReplyFromAccount mExistingDraftAccount;
2066
2067        public SendOrSaveTask(Context context, SendOrSaveMessage message,
2068                SendOrSaveCallback callback, ReplyFromAccount draftAccount) {
2069            mContext = context;
2070            mSendOrSaveCallback = callback;
2071            mSendOrSaveMessage = message;
2072            mExistingDraftAccount = draftAccount;
2073        }
2074
2075        @Override
2076        public void run() {
2077            final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage;
2078
2079            final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount;
2080            Message message = mSendOrSaveCallback.getMessage();
2081            long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID;
2082            // If a previous draft has been saved, in an account that is different
2083            // than what the user wants to send from, remove the old draft, and treat this
2084            // as a new message
2085            if (mExistingDraftAccount != null
2086                    && !selectedAccount.account.uri.equals(mExistingDraftAccount.account.uri)) {
2087                if (messageId != UIProvider.INVALID_MESSAGE_ID) {
2088                    ContentResolver resolver = mContext.getContentResolver();
2089                    ContentValues values = new ContentValues();
2090                    values.put(BaseColumns._ID, messageId);
2091                    if (mExistingDraftAccount.account.expungeMessageUri != null) {
2092                        new ContentProviderTask.UpdateTask()
2093                                .run(resolver, mExistingDraftAccount.account.expungeMessageUri,
2094                                        values, null, null);
2095                    } else {
2096                        // TODO(mindyp) delete the conversation.
2097                    }
2098                    // reset messageId to 0, so a new message will be created
2099                    messageId = UIProvider.INVALID_MESSAGE_ID;
2100                }
2101            }
2102
2103            final long messageIdToSave = messageId;
2104            sendOrSaveMessage(messageIdToSave, sendOrSaveMessage, selectedAccount);
2105
2106            if (!sendOrSaveMessage.mSave) {
2107                incrementRecipientsTimesContacted(mContext,
2108                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO));
2109                incrementRecipientsTimesContacted(mContext,
2110                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC));
2111                incrementRecipientsTimesContacted(mContext,
2112                        (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
2113            }
2114            mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
2115        }
2116
2117        private static void incrementRecipientsTimesContacted(final Context context,
2118                final String addressString) {
2119            if (TextUtils.isEmpty(addressString)) {
2120                return;
2121            }
2122            final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString);
2123            final ArrayList<String> recipients = new ArrayList<String>(tokens.length);
2124            for (int i = 0; i < tokens.length;i++) {
2125                recipients.add(tokens[i].getAddress());
2126            }
2127            final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(context);
2128            statsUpdater.updateWithAddress(recipients);
2129        }
2130
2131        /**
2132         * Send or Save a message.
2133         */
2134        private void sendOrSaveMessage(final long messageIdToSave,
2135                final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) {
2136            final ContentResolver resolver = mContext.getContentResolver();
2137            final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID;
2138
2139            final String accountMethod = sendOrSaveMessage.mSave ?
2140                    UIProvider.AccountCallMethods.SAVE_MESSAGE :
2141                    UIProvider.AccountCallMethods.SEND_MESSAGE;
2142
2143            try {
2144                if (updateExistingMessage) {
2145                    sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
2146
2147                    callAccountSendSaveMethod(resolver,
2148                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2149                } else {
2150                    Uri messageUri = null;
2151                    final Bundle result = callAccountSendSaveMethod(resolver,
2152                            selectedAccount.account, accountMethod, sendOrSaveMessage);
2153                    if (result != null) {
2154                        // If a non-null value was returned, then the provider handled the call
2155                        // method
2156                        messageUri = result.getParcelable(UIProvider.MessageColumns.URI);
2157                    }
2158                    if (sendOrSaveMessage.mSave && messageUri != null) {
2159                        final Cursor messageCursor = resolver.query(messageUri,
2160                                UIProvider.MESSAGE_PROJECTION, null, null, null);
2161                        if (messageCursor != null) {
2162                            try {
2163                                if (messageCursor.moveToFirst()) {
2164                                    // Broadcast notification that a new message has
2165                                    // been allocated
2166                                    mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
2167                                            new Message(messageCursor));
2168                                }
2169                            } finally {
2170                                messageCursor.close();
2171                            }
2172                        }
2173                    }
2174                }
2175            } finally {
2176                // Close any opened file descriptors
2177                closeOpenedAttachmentFds(sendOrSaveMessage);
2178            }
2179        }
2180
2181        private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) {
2182            final Bundle openedFds = sendOrSaveMessage.attachmentFds();
2183            if (openedFds != null) {
2184                final Set<String> keys = openedFds.keySet();
2185                for (final String key : keys) {
2186                    final ParcelFileDescriptor fd = openedFds.getParcelable(key);
2187                    if (fd != null) {
2188                        try {
2189                            fd.close();
2190                        } catch (IOException e) {
2191                            // Do nothing
2192                        }
2193                    }
2194                }
2195            }
2196        }
2197
2198        /**
2199         * Use the {@link ContentResolver#call} method to send or save the message.
2200         *
2201         * If this was successful, this method will return an non-null Bundle instance
2202         */
2203        private static Bundle callAccountSendSaveMethod(final ContentResolver resolver,
2204                final Account account, final String method,
2205                final SendOrSaveMessage sendOrSaveMessage) {
2206            // Copy all of the values from the content values to the bundle
2207            final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size());
2208            final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet();
2209
2210            for (Entry<String, Object> entry : valueSet) {
2211                final Object entryValue = entry.getValue();
2212                final String key = entry.getKey();
2213                if (entryValue instanceof String) {
2214                    methodExtras.putString(key, (String)entryValue);
2215                } else if (entryValue instanceof Boolean) {
2216                    methodExtras.putBoolean(key, (Boolean)entryValue);
2217                } else if (entryValue instanceof Integer) {
2218                    methodExtras.putInt(key, (Integer)entryValue);
2219                } else if (entryValue instanceof Long) {
2220                    methodExtras.putLong(key, (Long)entryValue);
2221                } else {
2222                    LogUtils.wtf(LOG_TAG, "Unexpected object type: %s",
2223                            entryValue.getClass().getName());
2224                }
2225            }
2226
2227            // If the SendOrSaveMessage has some opened fds, add them to the bundle
2228            final Bundle fdMap = sendOrSaveMessage.attachmentFds();
2229            if (fdMap != null) {
2230                methodExtras.putParcelable(
2231                        UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap);
2232            }
2233
2234            return resolver.call(account.uri, method, account.uri.toString(), methodExtras);
2235        }
2236    }
2237
2238    @VisibleForTesting
2239    public static class SendOrSaveMessage {
2240        final ReplyFromAccount mAccount;
2241        final ContentValues mValues;
2242        final String mRefMessageId;
2243        @VisibleForTesting
2244        public final boolean mSave;
2245        final int mRequestId;
2246        private final Bundle mAttachmentFds;
2247
2248        public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values,
2249                String refMessageId, List<Attachment> attachments, boolean save) {
2250            mAccount = account;
2251            mValues = values;
2252            mRefMessageId = refMessageId;
2253            mSave = save;
2254            mRequestId = mValues.hashCode() ^ hashCode();
2255
2256            mAttachmentFds = initializeAttachmentFds(context, attachments);
2257        }
2258
2259        int requestId() {
2260            return mRequestId;
2261        }
2262
2263        Bundle attachmentFds() {
2264            return mAttachmentFds;
2265        }
2266
2267        /**
2268         * Opens {@link ParcelFileDescriptor} for each of the attachments.  This method must be
2269         * called before the ComposeActivity finishes.
2270         * Note: The caller is responsible for closing these file descriptors.
2271         */
2272        private static Bundle initializeAttachmentFds(final Context context,
2273                final List<Attachment> attachments) {
2274            if (attachments == null || attachments.size() == 0) {
2275                return null;
2276            }
2277
2278            final Bundle result = new Bundle(attachments.size());
2279            final ContentResolver resolver = context.getContentResolver();
2280
2281            for (Attachment attachment : attachments) {
2282                if (attachment == null || Utils.isEmpty(attachment.contentUri)) {
2283                    continue;
2284                }
2285
2286                ParcelFileDescriptor fileDescriptor;
2287                try {
2288                    fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r");
2289                } catch (FileNotFoundException e) {
2290                    LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment");
2291                    fileDescriptor = null;
2292                } catch (SecurityException e) {
2293                    // We have encountered a security exception when attempting to open the file
2294                    // specified by the content uri.  If the attachment has been cached, this
2295                    // isn't a problem, as even through the original permission may have been
2296                    // revoked, we have cached the file.  This will happen when saving/sending
2297                    // a previously saved draft.
2298                    // TODO(markwei): Expose whether the attachment has been cached through the
2299                    // attachment object.  This would allow us to limit when the log is made, as
2300                    // if the attachment has been cached, this really isn't an error
2301                    LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment");
2302                    // Just set the file descriptor to null, as the underlying provider needs
2303                    // to handle the file descriptor not being set.
2304                    fileDescriptor = null;
2305                }
2306
2307                if (fileDescriptor != null) {
2308                    result.putParcelable(attachment.contentUri.toString(), fileDescriptor);
2309                }
2310            }
2311
2312            return result;
2313        }
2314    }
2315
2316    /**
2317     * Get the to recipients.
2318     */
2319    public String[] getToAddresses() {
2320        return getAddressesFromList(mTo);
2321    }
2322
2323    /**
2324     * Get the cc recipients.
2325     */
2326    public String[] getCcAddresses() {
2327        return getAddressesFromList(mCc);
2328    }
2329
2330    /**
2331     * Get the bcc recipients.
2332     */
2333    public String[] getBccAddresses() {
2334        return getAddressesFromList(mBcc);
2335    }
2336
2337    public String[] getAddressesFromList(RecipientEditTextView list) {
2338        if (list == null) {
2339            return new String[0];
2340        }
2341        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
2342        int count = tokens.length;
2343        String[] result = new String[count];
2344        for (int i = 0; i < count; i++) {
2345            result[i] = tokens[i].toString();
2346        }
2347        return result;
2348    }
2349
2350    /**
2351     * Check for invalid email addresses.
2352     * @param to String array of email addresses to check.
2353     * @param wrongEmailsOut Emails addresses that were invalid.
2354     */
2355    public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) {
2356        if (mValidator == null) {
2357            return;
2358        }
2359        for (final String email : to) {
2360            if (!mValidator.isValid(email)) {
2361                wrongEmailsOut.add(email);
2362            }
2363        }
2364    }
2365
2366    public static class RecipientErrorDialogFragment extends DialogFragment {
2367        // Public no-args constructor needed for fragment re-instantiation
2368        public RecipientErrorDialogFragment() {}
2369
2370        public static RecipientErrorDialogFragment newInstance(final String message) {
2371            final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment();
2372            final Bundle args = new Bundle(1);
2373            args.putString("message", message);
2374            frag.setArguments(args);
2375            return frag;
2376        }
2377
2378        @Override
2379        public Dialog onCreateDialog(Bundle savedInstanceState) {
2380            final String message = getArguments().getString("message");
2381            return new AlertDialog.Builder(getActivity()).setMessage(message).setTitle(
2382                    R.string.recipient_error_dialog_title)
2383                    .setIconAttribute(android.R.attr.alertDialogIcon)
2384                    .setPositiveButton(
2385                            R.string.ok, new Dialog.OnClickListener() {
2386                        @Override
2387                        public void onClick(DialogInterface dialog, int which) {
2388                            ((ComposeActivity)getActivity()).finishRecipientErrorDialog();
2389                        }
2390                    }).create();
2391        }
2392    }
2393
2394    private void finishRecipientErrorDialog() {
2395        // after the user dismisses the recipient error
2396        // dialog we want to make sure to refocus the
2397        // recipient to field so they can fix the issue
2398        // easily
2399        if (mTo != null) {
2400            mTo.requestFocus();
2401        }
2402    }
2403
2404    /**
2405     * Show an error because the user has entered an invalid recipient.
2406     * @param message
2407     */
2408    private void showRecipientErrorDialog(final String message) {
2409        final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message);
2410        frag.show(getFragmentManager(), "recipient error");
2411    }
2412
2413    /**
2414     * Update the state of the UI based on whether or not the current draft
2415     * needs to be saved and the message is not empty.
2416     */
2417    public void updateSaveUi() {
2418        if (mSave != null) {
2419            mSave.setEnabled((shouldSave() && !isBlank()));
2420        }
2421    }
2422
2423    /**
2424     * Returns true if we need to save the current draft.
2425     */
2426    private boolean shouldSave() {
2427        synchronized (mDraftLock) {
2428            // The message should only be saved if:
2429            // It hasn't been sent AND
2430            // Some text has been added to the message OR
2431            // an attachment has been added or removed
2432            // AND there is actually something in the draft to save.
2433            return (mTextChanged || mAttachmentsChanged || mReplyFromChanged)
2434                    && !isBlank();
2435        }
2436    }
2437
2438    /**
2439     * Check if all fields are blank.
2440     * @return boolean
2441     */
2442    public boolean isBlank() {
2443        return mSubject.getText().length() == 0
2444                && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature,
2445                        mBodyView.getText().toString()) == 0)
2446                && mTo.length() == 0
2447                && mCc.length() == 0 && mBcc.length() == 0
2448                && mAttachmentsView.getAttachments().size() == 0;
2449    }
2450
2451    @VisibleForTesting
2452    protected int getSignatureStartPosition(String signature, String bodyText) {
2453        int startPos = -1;
2454
2455        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
2456            return startPos;
2457        }
2458
2459        int bodyLength = bodyText.length();
2460        int signatureLength = signature.length();
2461        String printableVersion = convertToPrintableSignature(signature);
2462        int printableLength = printableVersion.length();
2463
2464        if (bodyLength >= printableLength
2465                && bodyText.substring(bodyLength - printableLength)
2466                .equals(printableVersion)) {
2467            startPos = bodyLength - printableLength;
2468        } else if (bodyLength >= signatureLength
2469                && bodyText.substring(bodyLength - signatureLength)
2470                .equals(signature)) {
2471            startPos = bodyLength - signatureLength;
2472        }
2473        return startPos;
2474    }
2475
2476    /**
2477     * Allows any changes made by the user to be ignored. Called when the user
2478     * decides to discard a draft.
2479     */
2480    private void discardChanges() {
2481        mTextChanged = false;
2482        mAttachmentsChanged = false;
2483        mReplyFromChanged = false;
2484    }
2485
2486    /**
2487     * @param save
2488     * @param showToast
2489     * @return Whether the send or save succeeded.
2490     */
2491    protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
2492            final boolean orientationChanged, final boolean autoSend) {
2493        if (mAccounts == null || mAccount == null) {
2494            Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show();
2495            if (autoSend) {
2496                finish();
2497            }
2498            return false;
2499        }
2500
2501        final String[] to, cc, bcc;
2502        if (orientationChanged) {
2503            to = cc = bcc = new String[0];
2504        } else {
2505            to = getToAddresses();
2506            cc = getCcAddresses();
2507            bcc = getBccAddresses();
2508        }
2509
2510        // Don't let the user send to nobody (but it's okay to save a message
2511        // with no recipients)
2512        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
2513            showRecipientErrorDialog(getString(R.string.recipient_needed));
2514            return false;
2515        }
2516
2517        List<String> wrongEmails = new ArrayList<String>();
2518        if (!save) {
2519            checkInvalidEmails(to, wrongEmails);
2520            checkInvalidEmails(cc, wrongEmails);
2521            checkInvalidEmails(bcc, wrongEmails);
2522        }
2523
2524        // Don't let the user send an email with invalid recipients
2525        if (wrongEmails.size() > 0) {
2526            String errorText = String.format(getString(R.string.invalid_recipient),
2527                    wrongEmails.get(0));
2528            showRecipientErrorDialog(errorText);
2529            return false;
2530        }
2531
2532        // Show a warning before sending only if there are no attachments.
2533        if (!save) {
2534            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
2535                boolean warnAboutEmptySubject = isSubjectEmpty();
2536                boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0;
2537
2538                // A warning about an empty body may not be warranted when
2539                // forwarding mails, since a common use case is to forward
2540                // quoted text and not append any more text.
2541                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());
2542
2543                // When we bring up a dialog warning the user about a send,
2544                // assume that they accept sending the message. If they do not,
2545                // the dialog listener is required to enable sending again.
2546                if (warnAboutEmptySubject) {
2547                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, save,
2548                            showToast);
2549                    return true;
2550                }
2551
2552                if (warnAboutEmptyBody) {
2553                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body, save,
2554                            showToast);
2555                    return true;
2556                }
2557            }
2558            // Ask for confirmation to send (if always required)
2559            if (showSendConfirmation()) {
2560                showSendConfirmDialog(R.string.confirm_send_message, save, showToast);
2561                return true;
2562            }
2563        }
2564
2565        sendOrSave(save, showToast);
2566        return true;
2567    }
2568
2569    /**
2570     * Returns a boolean indicating whether warnings should be shown for empty
2571     * subject and body fields
2572     *
2573     * @return True if a warning should be shown for empty text fields
2574     */
2575    protected boolean showEmptyTextWarnings() {
2576        return mAttachmentsView.getAttachments().size() == 0;
2577    }
2578
2579    /**
2580     * Returns a boolean indicating whether the user should confirm each send
2581     *
2582     * @return True if a warning should be on each send
2583     */
2584    protected boolean showSendConfirmation() {
2585        return mCachedSettings != null ? mCachedSettings.confirmSend : false;
2586    }
2587
2588    public static class SendConfirmDialogFragment extends DialogFragment {
2589        // Public no-args constructor needed for fragment re-instantiation
2590        public SendConfirmDialogFragment() {}
2591
2592        public static SendConfirmDialogFragment newInstance(final int messageId,
2593                final boolean save, final boolean showToast) {
2594            final SendConfirmDialogFragment frag = new SendConfirmDialogFragment();
2595            final Bundle args = new Bundle(3);
2596            args.putInt("messageId", messageId);
2597            args.putBoolean("save", save);
2598            args.putBoolean("showToast", showToast);
2599            frag.setArguments(args);
2600            return frag;
2601        }
2602
2603        @Override
2604        public Dialog onCreateDialog(Bundle savedInstanceState) {
2605            final int messageId = getArguments().getInt("messageId");
2606            final boolean save = getArguments().getBoolean("save");
2607            final boolean showToast = getArguments().getBoolean("showToast");
2608
2609            return new AlertDialog.Builder(getActivity())
2610                    .setMessage(messageId)
2611                    .setTitle(R.string.confirm_send_title)
2612                    .setIconAttribute(android.R.attr.alertDialogIcon)
2613                    .setPositiveButton(R.string.send,
2614                            new DialogInterface.OnClickListener() {
2615                                @Override
2616                                public void onClick(DialogInterface dialog, int whichButton) {
2617                                    ((ComposeActivity)getActivity()).finishSendConfirmDialog(save,
2618                                            showToast);
2619                                }
2620                            })
2621                    .create();
2622        }
2623    }
2624
2625    private void finishSendConfirmDialog(final boolean save, final boolean showToast) {
2626        sendOrSave(save, showToast);
2627    }
2628
2629    private void showSendConfirmDialog(final int messageId, final boolean save,
2630            final boolean showToast) {
2631        final DialogFragment frag = SendConfirmDialogFragment.newInstance(messageId, save,
2632                showToast);
2633        frag.show(getFragmentManager(), "send confirm");
2634    }
2635
2636    /**
2637     * Returns whether the ComposeArea believes there is any text in the body of
2638     * the composition. TODO: When ComposeArea controls the Body as well, add
2639     * that here.
2640     */
2641    public boolean isBodyEmpty() {
2642        return !mQuotedTextView.isTextIncluded();
2643    }
2644
2645    /**
2646     * Test to see if the subject is empty.
2647     *
2648     * @return boolean.
2649     */
2650    // TODO: this will likely go away when composeArea.focus() is implemented
2651    // after all the widget control is moved over.
2652    public boolean isSubjectEmpty() {
2653        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
2654    }
2655
2656    /* package */
2657    static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount,
2658            Message message, final Message refMessage, Spanned body, final CharSequence quotedText,
2659            SendOrSaveCallback callback, Handler handler, boolean save, int composeMode,
2660            ReplyFromAccount draftAccount, final ContentValues extraValues) {
2661        final ContentValues values = new ContentValues();
2662
2663        final String refMessageId = refMessage != null ? refMessage.uri.toString() : "";
2664
2665        MessageModification.putToAddresses(values, message.getToAddresses());
2666        MessageModification.putCcAddresses(values, message.getCcAddresses());
2667        MessageModification.putBccAddresses(values, message.getBccAddresses());
2668
2669        MessageModification.putCustomFromAddress(values, message.getFrom());
2670
2671        MessageModification.putSubject(values, message.subject);
2672        // Make sure to remove only the composing spans from the Spannable before saving.
2673        final String htmlBody = Html.toHtml(removeComposingSpans(body));
2674
2675        boolean includeQuotedText = !TextUtils.isEmpty(quotedText);
2676        StringBuilder fullBody = new StringBuilder(htmlBody);
2677        if (includeQuotedText) {
2678            // HTML gets converted to text for now
2679            final String text = quotedText.toString();
2680            if (QuotedTextView.containsQuotedText(text)) {
2681                int pos = QuotedTextView.getQuotedTextOffset(text);
2682                final int quoteStartPos = fullBody.length() + pos;
2683                fullBody.append(text);
2684                MessageModification.putQuoteStartPos(values, quoteStartPos);
2685                MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
2686                MessageModification.putAppendRefMessageContent(values, includeQuotedText);
2687            } else {
2688                LogUtils.w(LOG_TAG, "Couldn't find quoted text");
2689                // This shouldn't happen, but just use what we have,
2690                // and don't do server-side expansion
2691                fullBody.append(text);
2692            }
2693        }
2694        int draftType = getDraftType(composeMode);
2695        MessageModification.putDraftType(values, draftType);
2696        if (refMessage != null) {
2697            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
2698                MessageModification.putBodyHtml(values, fullBody.toString());
2699            }
2700            if (!TextUtils.isEmpty(refMessage.bodyText)) {
2701                MessageModification.putBody(values,
2702                        Utils.convertHtmlToPlainText(fullBody.toString()).toString());
2703            }
2704        } else {
2705            MessageModification.putBodyHtml(values, fullBody.toString());
2706            MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString())
2707                    .toString());
2708        }
2709        MessageModification.putAttachments(values, message.getAttachments());
2710        if (!TextUtils.isEmpty(refMessageId)) {
2711            MessageModification.putRefMessageId(values, refMessageId);
2712        }
2713        if (extraValues != null) {
2714            values.putAll(extraValues);
2715        }
2716        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount,
2717                values, refMessageId, message.getAttachments(), save);
2718        SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback,
2719                draftAccount);
2720
2721        callback.initializeSendOrSave(sendOrSaveTask);
2722        // Do the send/save action on the specified handler to avoid possible
2723        // ANRs
2724        handler.post(sendOrSaveTask);
2725
2726        return sendOrSaveMessage.requestId();
2727    }
2728
2729    /**
2730     * Removes any composing spans from the specified string.  This will create a new
2731     * SpannableString instance, as to not modify the behavior of the EditText view.
2732     */
2733    private static SpannableString removeComposingSpans(Spanned body) {
2734        final SpannableString messageBody = new SpannableString(body);
2735        BaseInputConnection.removeComposingSpans(messageBody);
2736        return messageBody;
2737    }
2738
2739    private static int getDraftType(int mode) {
2740        int draftType = -1;
2741        switch (mode) {
2742            case ComposeActivity.COMPOSE:
2743                draftType = DraftType.COMPOSE;
2744                break;
2745            case ComposeActivity.REPLY:
2746                draftType = DraftType.REPLY;
2747                break;
2748            case ComposeActivity.REPLY_ALL:
2749                draftType = DraftType.REPLY_ALL;
2750                break;
2751            case ComposeActivity.FORWARD:
2752                draftType = DraftType.FORWARD;
2753                break;
2754        }
2755        return draftType;
2756    }
2757
2758    private void sendOrSave(final boolean save, final boolean showToast) {
2759        // Check if user is a monkey. Monkeys can compose and hit send
2760        // button but are not allowed to send anything off the device.
2761        if (ActivityManager.isUserAMonkey()) {
2762            return;
2763        }
2764
2765        final Spanned body = mBodyView.getEditableText();
2766
2767        SendOrSaveCallback callback = new SendOrSaveCallback() {
2768            // FIXME: unused
2769            private int mRestoredRequestId;
2770
2771            @Override
2772            public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
2773                synchronized (mActiveTasks) {
2774                    int numTasks = mActiveTasks.size();
2775                    if (numTasks == 0) {
2776                        // Start service so we won't be killed if this app is
2777                        // put in the background.
2778                        startService(new Intent(ComposeActivity.this, EmptyService.class));
2779                    }
2780
2781                    mActiveTasks.add(sendOrSaveTask);
2782                }
2783                if (sTestSendOrSaveCallback != null) {
2784                    sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
2785                }
2786            }
2787
2788            @Override
2789            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
2790                    Message message) {
2791                synchronized (mDraftLock) {
2792                    mDraftAccount = sendOrSaveMessage.mAccount;
2793                    mDraftId = message.id;
2794                    mDraft = message;
2795                    if (sRequestMessageIdMap != null) {
2796                        sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
2797                    }
2798                    // Cache request message map, in case the process is killed
2799                    saveRequestMap();
2800                }
2801                if (sTestSendOrSaveCallback != null) {
2802                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
2803                }
2804            }
2805
2806            @Override
2807            public Message getMessage() {
2808                synchronized (mDraftLock) {
2809                    return mDraft;
2810                }
2811            }
2812
2813            @Override
2814            public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
2815                // Update the last sent from account.
2816                if (mAccount != null) {
2817                    MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString());
2818                }
2819                if (success) {
2820                    // Successfully sent or saved so reset change markers
2821                    discardChanges();
2822                } else {
2823                    // A failure happened with saving/sending the draft
2824                    // TODO(pwestbro): add a better string that should be used
2825                    // when failing to send or save
2826                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT)
2827                            .show();
2828                }
2829
2830                int numTasks;
2831                synchronized (mActiveTasks) {
2832                    // Remove the task from the list of active tasks
2833                    mActiveTasks.remove(task);
2834                    numTasks = mActiveTasks.size();
2835                }
2836
2837                if (numTasks == 0) {
2838                    // Stop service so we can be killed.
2839                    stopService(new Intent(ComposeActivity.this, EmptyService.class));
2840                }
2841                if (sTestSendOrSaveCallback != null) {
2842                    sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
2843                }
2844            }
2845        };
2846
2847        setAccount(mReplyFromAccount.account);
2848
2849        if (mSendSaveTaskHandler == null) {
2850            HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
2851            handlerThread.start();
2852
2853            mSendSaveTaskHandler = new Handler(handlerThread.getLooper());
2854        }
2855
2856        Message msg = createMessage(mReplyFromAccount, getMode());
2857        mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body,
2858                mQuotedTextView.getQuotedTextIfIncluded(), callback,
2859                mSendSaveTaskHandler, save, mComposeMode, mDraftAccount, mExtraValues);
2860
2861        // Don't display the toast if the user is just changing the orientation,
2862        // but we still need to save the draft to the cursor because this is how we restore
2863        // the attachments when the configuration change completes.
2864        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
2865            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message,
2866                    Toast.LENGTH_LONG).show();
2867        }
2868
2869        // Need to update variables here because the send or save completes
2870        // asynchronously even though the toast shows right away.
2871        discardChanges();
2872        updateSaveUi();
2873
2874        // If we are sending, finish the activity
2875        if (!save) {
2876            finish();
2877        }
2878    }
2879
2880    /**
2881     * Save the state of the request messageid map. This allows for the Gmail
2882     * process to be killed, but and still allow for ComposeActivity instances
2883     * to be recreated correctly.
2884     */
2885    private void saveRequestMap() {
2886        // TODO: store the request map in user preferences.
2887    }
2888
2889    private void doAttach(String type) {
2890        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
2891        i.addCategory(Intent.CATEGORY_OPENABLE);
2892        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
2893        i.setType(type);
2894        mAddingAttachment = true;
2895        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
2896                RESULT_PICK_ATTACHMENT);
2897    }
2898
2899    private void showCcBccViews() {
2900        mCcBccView.show(true, true, true);
2901        if (mCcBccButton != null) {
2902            mCcBccButton.setVisibility(View.INVISIBLE);
2903        }
2904    }
2905
2906    private void logSendOrSave(boolean save) {
2907        final String category = (save) ? "message_save" : "message_send";
2908        final int attachmentCount = getAttachments().size();
2909        final String msgType;
2910        switch (mComposeMode) {
2911            case COMPOSE:
2912                msgType = "new_message";
2913                break;
2914            case REPLY:
2915                msgType = "reply";
2916                break;
2917            case REPLY_ALL:
2918                msgType = "reply_all";
2919                break;
2920            case FORWARD:
2921                msgType = "forward";
2922                break;
2923            default:
2924                msgType = "unknown";
2925                break;
2926        }
2927        final String label;
2928        final long value;
2929        if (mComposeMode == COMPOSE) {
2930            label = Integer.toString(attachmentCount);
2931            value = attachmentCount;
2932        } else {
2933            label = null;
2934            value = 0;
2935        }
2936        Analytics.getInstance().sendEvent(category, msgType, label, value);
2937    }
2938
2939    @Override
2940    public boolean onNavigationItemSelected(int position, long itemId) {
2941        int initialComposeMode = mComposeMode;
2942        if (position == ComposeActivity.REPLY) {
2943            mComposeMode = ComposeActivity.REPLY;
2944        } else if (position == ComposeActivity.REPLY_ALL) {
2945            mComposeMode = ComposeActivity.REPLY_ALL;
2946        } else if (position == ComposeActivity.FORWARD) {
2947            mComposeMode = ComposeActivity.FORWARD;
2948        }
2949        clearChangeListeners();
2950        if (initialComposeMode != mComposeMode) {
2951            resetMessageForModeChange();
2952            if (mRefMessage != null) {
2953                setFieldsFromRefMessage(mComposeMode);
2954            }
2955            boolean showCc = false;
2956            boolean showBcc = false;
2957            if (mDraft != null) {
2958                // Following desktop behavior, if the user has added a BCC
2959                // field to a draft, we show it regardless of compose mode.
2960                showBcc = !TextUtils.isEmpty(mDraft.getBcc());
2961                // Use the draft to determine what to populate.
2962                // If the Bcc field is showing, show the Cc field whether it is populated or not.
2963                showCc = showBcc
2964                        || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL);
2965            }
2966            if (mRefMessage != null) {
2967                showCc = !TextUtils.isEmpty(mCc.getText());
2968                showBcc = !TextUtils.isEmpty(mBcc.getText());
2969            }
2970            mCcBccView.show(false, showCc, showBcc);
2971        }
2972        updateHideOrShowCcBcc();
2973        initChangeListeners();
2974        return true;
2975    }
2976
2977    @VisibleForTesting
2978    protected void resetMessageForModeChange() {
2979        // When switching between reply, reply all, forward,
2980        // follow the behavior of webview.
2981        // The contents of the following fields are cleared
2982        // so that they can be populated directly from the
2983        // ref message:
2984        // 1) Any recipient fields
2985        // 2) The subject
2986        mTo.setText("");
2987        mCc.setText("");
2988        mBcc.setText("");
2989        // Any edits to the subject are replaced with the original subject.
2990        mSubject.setText("");
2991
2992        // Any changes to the contents of the following fields are kept:
2993        // 1) Body
2994        // 2) Attachments
2995        // If the user made changes to attachments, keep their changes.
2996        if (!mAttachmentsChanged) {
2997            mAttachmentsView.deleteAllAttachments();
2998        }
2999    }
3000
3001    private class ComposeModeAdapter extends ArrayAdapter<String> {
3002
3003        private LayoutInflater mInflater;
3004
3005        public ComposeModeAdapter(Context context) {
3006            super(context, R.layout.compose_mode_item, R.id.mode, getResources()
3007                    .getStringArray(R.array.compose_modes));
3008        }
3009
3010        private LayoutInflater getInflater() {
3011            if (mInflater == null) {
3012                mInflater = LayoutInflater.from(getContext());
3013            }
3014            return mInflater;
3015        }
3016
3017        @Override
3018        public View getView(int position, View convertView, ViewGroup parent) {
3019            if (convertView == null) {
3020                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
3021            }
3022            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
3023            return super.getView(position, convertView, parent);
3024        }
3025    }
3026
3027    @Override
3028    public void onRespondInline(String text) {
3029        appendToBody(text, false);
3030        mQuotedTextView.setUpperDividerVisible(false);
3031        mRespondedInline = true;
3032        if (!mBodyView.hasFocus()) {
3033            mBodyView.requestFocus();
3034        }
3035    }
3036
3037    /**
3038     * Append text to the body of the message. If there is no existing body
3039     * text, just sets the body to text.
3040     *
3041     * @param text
3042     * @param withSignature True to append a signature.
3043     */
3044    public void appendToBody(CharSequence text, boolean withSignature) {
3045        Editable bodyText = mBodyView.getEditableText();
3046        if (bodyText != null && bodyText.length() > 0) {
3047            bodyText.append(text);
3048        } else {
3049            setBody(text, withSignature);
3050        }
3051    }
3052
3053    /**
3054     * Set the body of the message.
3055     *
3056     * @param text
3057     * @param withSignature True to append a signature.
3058     */
3059    public void setBody(CharSequence text, boolean withSignature) {
3060        mBodyView.setText(text);
3061        if (withSignature) {
3062            appendSignature();
3063        }
3064    }
3065
3066    private void appendSignature() {
3067        String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
3068        boolean hasFocus = mBodyView.hasFocus();
3069        int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString());
3070        if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) {
3071            mSignature = newSignature;
3072            if (!TextUtils.isEmpty(mSignature)) {
3073                // Appending a signature does not count as changing text.
3074                mBodyView.removeTextChangedListener(this);
3075                mBodyView.append(convertToPrintableSignature(mSignature));
3076                mBodyView.addTextChangedListener(this);
3077            }
3078            if (hasFocus) {
3079                focusBody();
3080            }
3081        }
3082    }
3083
3084    private String convertToPrintableSignature(String signature) {
3085        String signatureResource = getResources().getString(R.string.signature);
3086        if (signature == null) {
3087            signature = "";
3088        }
3089        return String.format(signatureResource, signature);
3090    }
3091
3092    @Override
3093    public void onAccountChanged() {
3094        mReplyFromAccount = mFromSpinner.getCurrentAccount();
3095        if (!mAccount.equals(mReplyFromAccount.account)) {
3096            // Clear a signature, if there was one.
3097            mBodyView.removeTextChangedListener(this);
3098            String oldSignature = mSignature;
3099            String bodyText = getBody().getText().toString();
3100            if (!TextUtils.isEmpty(oldSignature)) {
3101                int pos = getSignatureStartPosition(oldSignature, bodyText);
3102                if (pos > -1) {
3103                    mBodyView.setText(bodyText.substring(0, pos));
3104                }
3105            }
3106            setAccount(mReplyFromAccount.account);
3107            mBodyView.addTextChangedListener(this);
3108            // TODO: handle discarding attachments when switching accounts.
3109            // Only enable save for this draft if there is any other content
3110            // in the message.
3111            if (!isBlank()) {
3112                enableSave(true);
3113            }
3114            mReplyFromChanged = true;
3115            initRecipients();
3116        }
3117    }
3118
3119    public void enableSave(boolean enabled) {
3120        if (mSave != null) {
3121            mSave.setEnabled(enabled);
3122        }
3123    }
3124
3125    public static class DiscardConfirmDialogFragment extends DialogFragment {
3126        // Public no-args constructor needed for fragment re-instantiation
3127        public DiscardConfirmDialogFragment() {}
3128
3129        @Override
3130        public Dialog onCreateDialog(Bundle savedInstanceState) {
3131            return new AlertDialog.Builder(getActivity())
3132                    .setMessage(R.string.confirm_discard_text)
3133                    .setPositiveButton(R.string.discard,
3134                            new DialogInterface.OnClickListener() {
3135                                @Override
3136                                public void onClick(DialogInterface dialog, int which) {
3137                                    ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation();
3138                                }
3139                            })
3140                    .setNegativeButton(R.string.cancel, null)
3141                    .create();
3142        }
3143    }
3144
3145    private void doDiscard() {
3146        final DialogFragment frag = new DiscardConfirmDialogFragment();
3147        frag.show(getFragmentManager(), "discard confirm");
3148    }
3149    /**
3150     * Effectively discard the current message.
3151     *
3152     * This method is either invoked from the menu or from the dialog
3153     * once the user has confirmed that they want to discard the message.
3154     */
3155    private void doDiscardWithoutConfirmation() {
3156        synchronized (mDraftLock) {
3157            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
3158                ContentValues values = new ContentValues();
3159                values.put(BaseColumns._ID, mDraftId);
3160                if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) {
3161                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
3162                } else {
3163                    getContentResolver().delete(mDraft.uri, null, null);
3164                }
3165                // This is not strictly necessary (since we should not try to
3166                // save the draft after calling this) but it ensures that if we
3167                // do save again for some reason we make a new draft rather than
3168                // trying to resave an expunged draft.
3169                mDraftId = UIProvider.INVALID_MESSAGE_ID;
3170            }
3171        }
3172
3173        // Display a toast to let the user know
3174        Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();
3175
3176        // This prevents the draft from being saved in onPause().
3177        discardChanges();
3178        mPerformedSendOrDiscard = true;
3179        finish();
3180    }
3181
3182    private void saveIfNeeded() {
3183        if (mAccount == null) {
3184            // We have not chosen an account yet so there's no way that we can save. This is ok,
3185            // though, since we are saving our state before AccountsActivity is activated. Thus, the
3186            // user has not interacted with us yet and there is no real state to save.
3187            return;
3188        }
3189
3190        if (shouldSave()) {
3191            doSave(!mAddingAttachment /* show toast */);
3192        }
3193    }
3194
3195    @Override
3196    public void onAttachmentDeleted() {
3197        mAttachmentsChanged = true;
3198        // If we are showing any attachments, make sure we have an upper
3199        // divider.
3200        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3201        updateSaveUi();
3202    }
3203
3204    @Override
3205    public void onAttachmentAdded() {
3206        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
3207        mAttachmentsView.focusLastAttachment();
3208    }
3209
3210    /**
3211     * This is called any time one of our text fields changes.
3212     */
3213    @Override
3214    public void afterTextChanged(Editable s) {
3215        mTextChanged = true;
3216        updateSaveUi();
3217    }
3218
3219    @Override
3220    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3221        // Do nothing.
3222    }
3223
3224    @Override
3225    public void onTextChanged(CharSequence s, int start, int before, int count) {
3226        // Do nothing.
3227    }
3228
3229
3230    // There is a big difference between the text associated with an address changing
3231    // to add the display name or to format properly and a recipient being added or deleted.
3232    // Make sure we only notify of changes when a recipient has been added or deleted.
3233    private class RecipientTextWatcher implements TextWatcher {
3234        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();
3235
3236        private RecipientEditTextView mView;
3237
3238        private TextWatcher mListener;
3239
3240        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
3241            mView = view;
3242            mListener = listener;
3243        }
3244
3245        @Override
3246        public void afterTextChanged(Editable s) {
3247            if (hasChanged()) {
3248                mListener.afterTextChanged(s);
3249            }
3250        }
3251
3252        private boolean hasChanged() {
3253            String[] currRecips = tokenizeRecips(getAddressesFromList(mView));
3254            int totalCount = currRecips.length;
3255            int totalPrevCount = 0;
3256            for (Entry<String, Integer> entry : mContent.entrySet()) {
3257                totalPrevCount += entry.getValue();
3258            }
3259            if (totalCount != totalPrevCount) {
3260                return true;
3261            }
3262
3263            for (String recip : currRecips) {
3264                if (!mContent.containsKey(recip)) {
3265                    return true;
3266                } else {
3267                    int count = mContent.get(recip) - 1;
3268                    if (count < 0) {
3269                        return true;
3270                    } else {
3271                        mContent.put(recip, count);
3272                    }
3273                }
3274            }
3275            return false;
3276        }
3277
3278        private String[] tokenizeRecips(String[] recips) {
3279            // Tokenize them all and put them in the list.
3280            String[] recipAddresses = new String[recips.length];
3281            for (int i = 0; i < recips.length; i++) {
3282                recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress();
3283            }
3284            return recipAddresses;
3285        }
3286
3287        @Override
3288        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
3289            String[] recips = tokenizeRecips(getAddressesFromList(mView));
3290            for (String recip : recips) {
3291                if (!mContent.containsKey(recip)) {
3292                    mContent.put(recip, 1);
3293                } else {
3294                    mContent.put(recip, (mContent.get(recip)) + 1);
3295                }
3296            }
3297        }
3298
3299        @Override
3300        public void onTextChanged(CharSequence s, int start, int before, int count) {
3301            // Do nothing.
3302        }
3303    }
3304
3305    public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) {
3306        if (sTestSendOrSaveCallback != null && testCallback != null) {
3307            throw new IllegalStateException("Attempting to register more than one test callback");
3308        }
3309        sTestSendOrSaveCallback = testCallback;
3310    }
3311
3312    @VisibleForTesting
3313    protected ArrayList<Attachment> getAttachments() {
3314        return mAttachmentsView.getAttachments();
3315    }
3316
3317    @Override
3318    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
3319        switch (id) {
3320            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3321                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3322                        null, null);
3323            case REFERENCE_MESSAGE_LOADER:
3324                return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null,
3325                        null, null);
3326            case LOADER_ACCOUNT_CURSOR:
3327                return new CursorLoader(this, MailAppProvider.getAccountsUri(),
3328                        UIProvider.ACCOUNTS_PROJECTION, null, null, null);
3329        }
3330        return null;
3331    }
3332
3333    @Override
3334    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
3335        int id = loader.getId();
3336        switch (id) {
3337            case INIT_DRAFT_USING_REFERENCE_MESSAGE:
3338                if (data != null && data.moveToFirst()) {
3339                    mRefMessage = new Message(data);
3340                    Intent intent = getIntent();
3341                    initFromRefMessage(mComposeMode);
3342                    finishSetup(mComposeMode, intent, null);
3343                    if (mComposeMode != FORWARD) {
3344                        String to = intent.getStringExtra(EXTRA_TO);
3345                        if (!TextUtils.isEmpty(to)) {
3346                            mRefMessage.setTo(null);
3347                            mRefMessage.setFrom(null);
3348                            clearChangeListeners();
3349                            mTo.append(to);
3350                            initChangeListeners();
3351                        }
3352                    }
3353                } else {
3354                    finish();
3355                }
3356                break;
3357            case REFERENCE_MESSAGE_LOADER:
3358                // Only populate mRefMessage and leave other fields untouched.
3359                if (data != null && data.moveToFirst()) {
3360                    mRefMessage = new Message(data);
3361                }
3362                finishSetup(mComposeMode, getIntent(), mInnerSavedState);
3363                break;
3364            case LOADER_ACCOUNT_CURSOR:
3365                if (data != null && data.moveToFirst()) {
3366                    // there are accounts now!
3367                    Account account;
3368                    final ArrayList<Account> accounts = new ArrayList<Account>();
3369                    final ArrayList<Account> initializedAccounts = new ArrayList<Account>();
3370                    do {
3371                        account = new Account(data);
3372                        if (account.isAccountReady()) {
3373                            initializedAccounts.add(account);
3374                        }
3375                        accounts.add(account);
3376                    } while (data.moveToNext());
3377                    if (initializedAccounts.size() > 0) {
3378                        findViewById(R.id.wait).setVisibility(View.GONE);
3379                        getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR);
3380                        findViewById(R.id.compose).setVisibility(View.VISIBLE);
3381                        mAccounts = initializedAccounts.toArray(
3382                                new Account[initializedAccounts.size()]);
3383
3384                        finishCreate();
3385                        invalidateOptionsMenu();
3386                    } else {
3387                        // Show "waiting"
3388                        account = accounts.size() > 0 ? accounts.get(0) : null;
3389                        showWaitFragment(account);
3390                    }
3391                }
3392                break;
3393        }
3394    }
3395
3396    private void showWaitFragment(Account account) {
3397        WaitFragment fragment = getWaitFragment();
3398        if (fragment != null) {
3399            fragment.updateAccount(account);
3400        } else {
3401            findViewById(R.id.wait).setVisibility(View.VISIBLE);
3402            replaceFragment(WaitFragment.newInstance(account, true),
3403                    FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT);
3404        }
3405    }
3406
3407    private WaitFragment getWaitFragment() {
3408        return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT);
3409    }
3410
3411    private int replaceFragment(Fragment fragment, int transition, String tag) {
3412        FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction();
3413        fragmentTransaction.setTransition(transition);
3414        fragmentTransaction.replace(R.id.wait, fragment, tag);
3415        final int transactionId = fragmentTransaction.commitAllowingStateLoss();
3416        return transactionId;
3417    }
3418
3419    @Override
3420    public void onLoaderReset(Loader<Cursor> arg0) {
3421        // Do nothing.
3422    }
3423
3424    @Override
3425    public Context getActivityContext() {
3426        return this;
3427    }
3428}
3429