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