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