com.android.mail.compose.ComposeActivity.java Source code

Java tutorial

Introduction

Here is the source code for com.android.mail.compose.ComposeActivity.java

Source

/**
 * Copyright (c) 2011, Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.mail.compose;

import android.annotation.SuppressLint;
import android.annotation.TargetApi;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.DialogFragment;
import android.app.Fragment;
import android.app.FragmentTransaction;
import android.app.LoaderManager;
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.CursorLoader;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.Loader;
import android.content.pm.ActivityInfo;
import android.content.res.Resources;
import android.database.Cursor;
import android.graphics.Rect;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.ParcelFileDescriptor;
import android.provider.BaseColumns;
import android.support.v4.app.RemoteInput;
import android.support.v7.app.ActionBar;
import android.support.v7.app.ActionBarActivity;
import android.support.v7.view.ActionMode;
import android.text.Editable;
import android.text.Html;
import android.text.SpanWatcher;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.util.Rfc822Token;
import android.text.util.Rfc822Tokenizer;
import android.view.Gravity;
import android.view.KeyEvent;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;

import com.android.common.Rfc822Validator;
import com.android.common.contacts.DataUsageStatUpdater;
import com.android.emailcommon.mail.Address;
import com.android.ex.chips.BaseRecipientAdapter;
import com.android.ex.chips.DropdownChipLayouter;
import com.android.ex.chips.RecipientEditTextView;
import com.android.mail.MailIntentService;
import com.android.mail.R;
import com.android.mail.analytics.Analytics;
import com.android.mail.browse.MessageHeaderView;
import com.android.mail.compose.AttachmentsView.AttachmentAddedOrDeletedListener;
import com.android.mail.compose.AttachmentsView.AttachmentFailureException;
import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener;
import com.android.mail.compose.QuotedTextView.RespondInlineListener;
import com.android.mail.providers.Account;
import com.android.mail.providers.Attachment;
import com.android.mail.providers.Folder;
import com.android.mail.providers.MailAppProvider;
import com.android.mail.providers.Message;
import com.android.mail.providers.MessageModification;
import com.android.mail.providers.ReplyFromAccount;
import com.android.mail.providers.Settings;
import com.android.mail.providers.UIProvider;
import com.android.mail.providers.UIProvider.AccountCapabilities;
import com.android.mail.providers.UIProvider.DraftType;
import com.android.mail.ui.AttachmentTile.AttachmentPreview;
import com.android.mail.ui.MailActivity;
import com.android.mail.ui.WaitFragment;
import com.android.mail.utils.AccountUtils;
import com.android.mail.utils.AttachmentUtils;
import com.android.mail.utils.ContentProviderTask;
import com.android.mail.utils.HtmlUtils;
import com.android.mail.utils.LogTag;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.NotificationActionUtils;
import com.android.mail.utils.Utils;
import com.android.mail.utils.ViewUtils;
import com.google.android.mail.common.html.parser.HtmlTree;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map.Entry;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

public class ComposeActivity extends ActionBarActivity implements OnClickListener, ActionBar.OnNavigationListener,
        RespondInlineListener, TextWatcher, AttachmentAddedOrDeletedListener, OnAccountChangedListener,
        LoaderManager.LoaderCallbacks<Cursor>, TextView.OnEditorActionListener,
        RecipientEditTextView.RecipientEntryItemClickedListener, View.OnFocusChangeListener {
    /**
     * An {@link Intent} action that launches {@link ComposeActivity}, but is handled as if the
     * {@link Activity} were launched with no special action.
     */
    private static final String ACTION_LAUNCH_COMPOSE = "com.android.mail.intent.action.LAUNCH_COMPOSE";

    // Identifiers for which type of composition this is
    public static final int COMPOSE = -1;
    public static final int REPLY = 0;
    public static final int REPLY_ALL = 1;
    public static final int FORWARD = 2;
    public static final int EDIT_DRAFT = 3;

    // Integer extra holding one of the above compose action
    protected static final String EXTRA_ACTION = "action";

    private static final String EXTRA_SHOW_CC = "showCc";
    private static final String EXTRA_SHOW_BCC = "showBcc";
    private static final String EXTRA_RESPONDED_INLINE = "respondedInline";
    private static final String EXTRA_SAVE_ENABLED = "saveEnabled";

    private static final String UTF8_ENCODING_NAME = "UTF-8";

    private static final String MAIL_TO = "mailto";

    private static final String EXTRA_SUBJECT = "subject";

    private static final String EXTRA_BODY = "body";
    private static final String EXTRA_TEXT_CHANGED = "extraTextChanged";

    private static final String EXTRA_SKIP_PARSING_BODY = "extraSkipParsingBody";

    /**
     * Expected to be html formatted text.
     */
    private static final String EXTRA_QUOTED_TEXT = "quotedText";

    protected static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString";

    private static final String EXTRA_ATTACHMENT_PREVIEWS = "attachmentPreviews";

    // Extra that we can get passed from other activities
    @VisibleForTesting
    protected static final String EXTRA_TO = "to";
    private static final String EXTRA_CC = "cc";
    private static final String EXTRA_BCC = "bcc";

    public static final String ANALYTICS_CATEGORY_ERRORS = "compose_errors";

    /**
     * An optional extra containing a {@link ContentValues} of values to be added to
     * {@link SendOrSaveMessage#mValues}.
     */
    public static final String EXTRA_VALUES = "extra-values";

    // List of all the fields
    static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC,
            EXTRA_QUOTED_TEXT };

    private static final String LEGACY_WEAR_EXTRA = "com.google.android.wearable.extras";

    /**
     * Constant value for the threshold to use for auto-complete suggestions
     * for the to/cc/bcc fields.
     */
    private static final int COMPLETION_THRESHOLD = 1;

    private static SendOrSaveCallback sTestSendOrSaveCallback = null;
    // Map containing information about requests to create new messages, and the id of the
    // messages that were the result of those requests.
    //
    // This map is used when the activity that initiated the save a of a new message, is killed
    // before the save has completed (and when we know the id of the newly created message).  When
    // a save is completed, the service that is running in the background, will update the map
    //
    // When a new ComposeActivity instance is created, it will attempt to use the information in
    // the previously instantiated map.  If ComposeActivity.onCreate() is called, with a bundle
    // (restoring data from a previous instance), and the map hasn't been created, we will attempt
    // to populate the map with data stored in shared preferences.
    private static final ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = new ConcurrentHashMap<Integer, Long>(
            10);
    private static final Random sRandom = new Random(System.currentTimeMillis());

    /**
     * Notifies the {@code Activity} that the caller is an Email
     * {@code Activity}, so that the back behavior may be modified accordingly.
     *
     * @see #onAppUpPressed
     */
    public static final String EXTRA_FROM_EMAIL_TASK = "fromemail";

    public static final String EXTRA_ATTACHMENTS = "attachments";

    /** If set, we will clear notifications for this folder. */
    public static final String EXTRA_NOTIFICATION_FOLDER = "extra-notification-folder";
    public static final String EXTRA_NOTIFICATION_CONVERSATION = "extra-notification-conversation";

    //  If this is a reply/forward then this extra will hold the original message
    private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message";
    // If this is a reply/forward then this extra will hold a uri we must query
    // to get the original message.
    protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri";
    // If this is an action to edit an existing draft message, this extra will hold the
    // draft message
    private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message";
    private static final String END_TOKEN = ", ";
    private static final String LOG_TAG = LogTag.getLogTag();
    // Request numbers for activities we start
    private static final int RESULT_PICK_ATTACHMENT = 1;
    private static final int RESULT_CREATE_ACCOUNT = 2;
    // TODO(mindyp) set mime-type for auto send?
    public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND";

    private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount";
    private static final String EXTRA_REQUEST_ID = "requestId";
    private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart";
    private static final String EXTRA_FOCUS_SELECTION_END = "focusSelectionEnd";
    private static final String EXTRA_MESSAGE = "extraMessage";
    private static final int REFERENCE_MESSAGE_LOADER = 0;
    private static final int LOADER_ACCOUNT_CURSOR = 1;
    private static final int INIT_DRAFT_USING_REFERENCE_MESSAGE = 2;
    private static final String EXTRA_SELECTED_ACCOUNT = "selectedAccount";
    private static final String TAG_WAIT = "wait-fragment";
    private static final String MIME_TYPE_ALL = "*/*";
    private static final String MIME_TYPE_PHOTO = "image/*";

    private static final String KEY_INNER_SAVED_STATE = "compose_state";

    // A single thread for running tasks in the background.
    private static final Handler SEND_SAVE_TASK_HANDLER;
    @VisibleForTesting
    public static final AtomicInteger PENDING_SEND_OR_SAVE_TASKS_NUM = new AtomicInteger(0);

    /* Path of the data directory (used for attachment uri checking). */
    private static final String DATA_DIRECTORY_ROOT;

    // Static initializations
    static {
        HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
        handlerThread.start();
        SEND_SAVE_TASK_HANDLER = new Handler(handlerThread.getLooper());

        DATA_DIRECTORY_ROOT = Environment.getDataDirectory().toString();
    }

    private final Rect mRect = new Rect();

    private ScrollView mScrollView;
    private RecipientEditTextView mTo;
    private RecipientEditTextView mCc;
    private RecipientEditTextView mBcc;
    private View mCcBccButton;
    private CcBccView mCcBccView;
    private AttachmentsView mAttachmentsView;
    protected Account mAccount;
    protected ReplyFromAccount mReplyFromAccount;
    private Settings mCachedSettings;
    private Rfc822Validator mValidator;
    private TextView mSubject;

    private ComposeModeAdapter mComposeModeAdapter;
    protected int mComposeMode = -1;
    private boolean mForward;
    private QuotedTextView mQuotedTextView;
    protected EditText mBodyView;
    private View mFromStatic;
    private TextView mFromStaticText;
    private View mFromSpinnerWrapper;
    @VisibleForTesting
    protected FromAddressSpinner mFromSpinner;
    protected boolean mAddingAttachment;
    private boolean mAttachmentsChanged;
    private boolean mTextChanged;
    private boolean mReplyFromChanged;
    private MenuItem mSave;
    @VisibleForTesting
    protected Message mRefMessage;
    private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
    private Message mDraft;
    private ReplyFromAccount mDraftAccount;
    private final Object mDraftLock = new Object();

    /**
     * Boolean indicating whether ComposeActivity was launched from a Gmail controlled view.
     */
    private boolean mLaunchedFromEmail = false;
    private RecipientTextWatcher mToListener;
    private RecipientTextWatcher mCcListener;
    private RecipientTextWatcher mBccListener;
    private Uri mRefMessageUri;
    private boolean mShowQuotedText = false;
    protected Bundle mInnerSavedState;
    private ContentValues mExtraValues = null;

    // This is used to track pending requests, refer to sRequestMessageIdMap
    private int mRequestId;
    private String mSignature;
    private Account[] mAccounts;
    private boolean mRespondedInline;
    private boolean mPerformedSendOrDiscard = false;

    // OnKeyListener solely used for intercepting CTRL+ENTER event for SEND.
    private final View.OnKeyListener mKeyListenerForSendShortcut = new View.OnKeyListener() {
        @Override
        public boolean onKey(View v, int keyCode, KeyEvent event) {
            if (event.hasModifiers(KeyEvent.META_CTRL_ON) && keyCode == KeyEvent.KEYCODE_ENTER
                    && event.getAction() == KeyEvent.ACTION_UP) {
                doSend();
                return true;
            }
            return false;
        }
    };

    private final HtmlTree.ConverterFactory mSpanConverterFactory = new HtmlTree.ConverterFactory() {
        @Override
        public HtmlTree.Converter<Spanned> createInstance() {
            return getSpanConverter();
        }
    };

    /**
     * Can be called from a non-UI thread.
     */
    public static void editDraft(Context launcher, Account account, Message message) {
        launch(launcher, account, message, EDIT_DRAFT, null, null, null, null, null /* extraValues */);
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static void compose(Context launcher, Account account) {
        launch(launcher, account, null, COMPOSE, null, null, null, null, null /* extraValues */);
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static void composeToAddress(Context launcher, Account account, String toAddress) {
        launch(launcher, account, null, COMPOSE, toAddress, null, null, null, null /* extraValues */);
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static void composeWithExtraValues(Context launcher, Account account, String subject,
            final ContentValues extraValues) {
        launch(launcher, account, null, COMPOSE, null, null, null, subject, extraValues);
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static Intent createReplyIntent(final Context launcher, final Account account, final Uri messageUri,
            final boolean isReplyAll) {
        return createActionIntent(launcher, account, messageUri, isReplyAll ? REPLY_ALL : REPLY);
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static Intent createForwardIntent(final Context launcher, final Account account, final Uri messageUri) {
        return createActionIntent(launcher, account, messageUri, FORWARD);
    }

    private static Intent createActionIntent(final Context context, final Account account, final Uri messageUri,
            final int action) {
        final Intent intent = new Intent(ACTION_LAUNCH_COMPOSE);
        intent.setPackage(context.getPackageName());

        updateActionIntent(account, messageUri, action, intent);

        return intent;
    }

    @VisibleForTesting
    static Intent updateActionIntent(Account account, Uri messageUri, int action, Intent intent) {
        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
        intent.putExtra(EXTRA_ACTION, action);
        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
        intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, messageUri);

        return intent;
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static void reply(Context launcher, Account account, Message message) {
        launch(launcher, account, message, REPLY, null, null, null, null, null /* extraValues */);
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static void replyAll(Context launcher, Account account, Message message) {
        launch(launcher, account, message, REPLY_ALL, null, null, null, null, null /* extraValues */);
    }

    /**
     * Can be called from a non-UI thread.
     */
    public static void forward(Context launcher, Account account, Message message) {
        launch(launcher, account, message, FORWARD, null, null, null, null, null /* extraValues */);
    }

    public static void reportRenderingFeedback(Context launcher, Account account, Message message, String body) {
        launch(launcher, account, message, FORWARD, "android-gmail-readability@google.com", body, null, null,
                null /* extraValues */);
    }

    private static void launch(Context context, Account account, Message message, int action, String toAddress,
            String body, String quotedText, String subject, final ContentValues extraValues) {
        Intent intent = new Intent(ACTION_LAUNCH_COMPOSE);
        intent.setPackage(context.getPackageName());
        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
        intent.putExtra(EXTRA_ACTION, action);
        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
        if (action == EDIT_DRAFT) {
            intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message);
        } else {
            intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message);
        }
        if (toAddress != null) {
            intent.putExtra(EXTRA_TO, toAddress);
        }
        if (body != null) {
            intent.putExtra(EXTRA_BODY, body);
        }
        if (quotedText != null) {
            intent.putExtra(EXTRA_QUOTED_TEXT, quotedText);
        }
        if (subject != null) {
            intent.putExtra(EXTRA_SUBJECT, subject);
        }
        if (extraValues != null) {
            LogUtils.d(LOG_TAG, "Launching with extraValues: %s", extraValues.toString());
            intent.putExtra(EXTRA_VALUES, extraValues);
        }
        if (action == COMPOSE) {
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
        } else if (message != null) {
            intent.setData(Utils.normalizeUri(message.uri));
        }
        context.startActivity(intent);
    }

    public static void composeMailto(Context context, Account account, Uri mailto) {
        final Intent intent = new Intent(Intent.ACTION_VIEW, mailto);
        intent.setPackage(context.getPackageName());
        intent.putExtra(EXTRA_FROM_EMAIL_TASK, true);
        intent.putExtra(Utils.EXTRA_ACCOUNT, account);
        if (mailto != null) {
            intent.setData(Utils.normalizeUri(mailto));
        }
        context.startActivity(intent);
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // Change the title for accessibility so we announce "Compose" instead
        // of the app_name while still showing the app_name in recents.
        setTitle(R.string.compose_title);
        setContentView(R.layout.compose);
        final ActionBar actionBar = getSupportActionBar();
        if (actionBar != null) {
            // Hide the app icon.
            actionBar.setIcon(null);
            actionBar.setDisplayUseLogoEnabled(false);
        }

        mInnerSavedState = (savedInstanceState != null) ? savedInstanceState.getBundle(KEY_INNER_SAVED_STATE)
                : null;
        checkValidAccounts();
    }

    private void finishCreate() {
        final Bundle savedState = mInnerSavedState;
        findViews();
        final Intent intent = getIntent();
        final Message message;
        final ArrayList<AttachmentPreview> previews;
        mShowQuotedText = false;
        final CharSequence quotedText;
        int action;
        // Check for any of the possibly supplied accounts.;
        final Account account;
        if (hadSavedInstanceStateMessage(savedState)) {
            action = savedState.getInt(EXTRA_ACTION, COMPOSE);
            account = savedState.getParcelable(Utils.EXTRA_ACCOUNT);
            message = savedState.getParcelable(EXTRA_MESSAGE);

            previews = savedState.getParcelableArrayList(EXTRA_ATTACHMENT_PREVIEWS);
            mRefMessage = savedState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE);
            quotedText = savedState.getCharSequence(EXTRA_QUOTED_TEXT);

            mExtraValues = savedState.getParcelable(EXTRA_VALUES);

            // Get the draft id from the request id if there is one.
            if (savedState.containsKey(EXTRA_REQUEST_ID)) {
                final int requestId = savedState.getInt(EXTRA_REQUEST_ID);
                if (sRequestMessageIdMap.containsKey(requestId)) {
                    synchronized (mDraftLock) {
                        mDraftId = sRequestMessageIdMap.get(requestId);
                    }
                }
            }
        } else {
            account = obtainAccount(intent);
            action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
            // Initialize the message from the message in the intent
            message = intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE);
            previews = intent.getParcelableArrayListExtra(EXTRA_ATTACHMENT_PREVIEWS);
            mRefMessage = intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
            mRefMessageUri = intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI);
            quotedText = null;

            if (Analytics.isLoggable()) {
                if (intent.getBooleanExtra(Utils.EXTRA_FROM_NOTIFICATION, false)) {
                    Analytics.getInstance().sendEvent("notification_action", "compose", getActionString(action), 0);
                }
            }
        }
        mAttachmentsView.setAttachmentPreviews(previews);

        setAccount(account);
        if (mAccount == null) {
            return;
        }

        initRecipients();

        // Clear the notification and mark the conversation as seen, if necessary
        final Folder notificationFolder = intent.getParcelableExtra(EXTRA_NOTIFICATION_FOLDER);

        if (notificationFolder != null) {
            final Uri conversationUri = intent.getParcelableExtra(EXTRA_NOTIFICATION_CONVERSATION);
            Intent actionIntent;
            if (conversationUri != null) {
                actionIntent = new Intent(MailIntentService.ACTION_RESEND_NOTIFICATIONS_WEAR);
                actionIntent.putExtra(Utils.EXTRA_CONVERSATION, conversationUri);
            } else {
                actionIntent = new Intent(MailIntentService.ACTION_CLEAR_NEW_MAIL_NOTIFICATIONS);
                actionIntent.setData(Utils.appendVersionQueryParameter(this, notificationFolder.folderUri.fullUri));
            }
            actionIntent.setPackage(getPackageName());
            actionIntent.putExtra(Utils.EXTRA_ACCOUNT, account);
            actionIntent.putExtra(Utils.EXTRA_FOLDER, notificationFolder);

            startService(actionIntent);
        }

        if (intent.getBooleanExtra(EXTRA_FROM_EMAIL_TASK, false)) {
            mLaunchedFromEmail = true;
        } else if (Intent.ACTION_SEND.equals(intent.getAction())) {
            final Uri dataUri = intent.getData();
            if (dataUri != null) {
                final String dataScheme = intent.getData().getScheme();
                final String accountScheme = mAccount.composeIntentUri.getScheme();
                mLaunchedFromEmail = TextUtils.equals(dataScheme, accountScheme);
            }
        }

        if (mRefMessageUri != null) {
            mShowQuotedText = true;
            mComposeMode = action;

            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
                String wearReply = null;
                if (remoteInput != null) {
                    LogUtils.d(LOG_TAG, "Got remote input from new api");
                    CharSequence input = remoteInput.getCharSequence(NotificationActionUtils.WEAR_REPLY_INPUT);
                    if (input != null) {
                        wearReply = input.toString();
                    }
                } else {
                    // TODO: remove after legacy code has been removed.
                    LogUtils.d(LOG_TAG, "No remote input from new api, falling back to compatibility mode");
                    ClipData clipData = intent.getClipData();
                    if (clipData != null && LEGACY_WEAR_EXTRA.equals(clipData.getDescription().getLabel())) {
                        Bundle extras = clipData.getItemAt(0).getIntent().getExtras();
                        if (extras != null) {
                            wearReply = extras.getString(NotificationActionUtils.WEAR_REPLY_INPUT);
                        }
                    }
                }

                if (!TextUtils.isEmpty(wearReply)) {
                    createWearReplyTask(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, mComposeMode,
                            wearReply).execute();
                    finish();
                    return;
                } else {
                    LogUtils.w(LOG_TAG, "remote input string is null");
                }
            }

            getLoaderManager().initLoader(INIT_DRAFT_USING_REFERENCE_MESSAGE, null, this);
            return;
        } else if (message != null && action != EDIT_DRAFT) {
            initFromDraftMessage(message);
            initQuotedTextFromRefMessage(mRefMessage, action);
            mShowQuotedText = message.appendRefMessageContent;
            // if we should be showing quoted text but mRefMessage is null
            // and we have some quotedText, display that
            if (mShowQuotedText && mRefMessage == null) {
                if (quotedText != null) {
                    initQuotedText(quotedText, false /* shouldQuoteText */);
                } else if (mExtraValues != null) {
                    initExtraValues(mExtraValues);
                    return;
                }
            }
        } else if (action == EDIT_DRAFT) {
            if (message == null) {
                throw new IllegalStateException("Message must not be null to edit draft");
            }
            initFromDraftMessage(message);
            // Update the action to the draft type of the previous draft
            switch (message.draftType) {
            case UIProvider.DraftType.REPLY:
                action = REPLY;
                break;
            case UIProvider.DraftType.REPLY_ALL:
                action = REPLY_ALL;
                break;
            case UIProvider.DraftType.FORWARD:
                action = FORWARD;
                break;
            case UIProvider.DraftType.COMPOSE:
            default:
                action = COMPOSE;
                break;
            }
            LogUtils.d(LOG_TAG, "Previous draft had action type: %d", action);

            mShowQuotedText = message.appendRefMessageContent;
            if (message.refMessageUri != null) {
                // If we're editing an existing draft that was in reference to an existing message,
                // still need to load that original message since we might need to refer to the
                // original sender and recipients if user switches "reply <-> reply-all".
                mRefMessageUri = message.refMessageUri;
                mComposeMode = action;
                getLoaderManager().initLoader(REFERENCE_MESSAGE_LOADER, null, this);
                return;
            }
        } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
            if (mRefMessage != null) {
                initFromRefMessage(action);
                mShowQuotedText = true;
            }
        } else {
            if (initFromExtras(intent)) {
                return;
            }
        }

        mComposeMode = action;
        finishSetup(action, intent, savedState);
    }

    @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
    private static AsyncTask<Void, Void, Message> createWearReplyTask(final ComposeActivity composeActivity,
            final Uri refMessageUri, final String[] projection, final int action, final String wearReply) {
        return new AsyncTask<Void, Void, Message>() {
            private Intent mEmptyServiceIntent = new Intent(composeActivity, EmptyService.class);

            @Override
            protected void onPreExecute() {
                // Start service so we won't be killed if this app is put in the background.
                composeActivity.startService(mEmptyServiceIntent);
            }

            @Override
            protected Message doInBackground(Void... params) {
                Cursor cursor = composeActivity.getContentResolver().query(refMessageUri, projection, null, null,
                        null, null);
                if (cursor != null) {
                    try {
                        cursor.moveToFirst();
                        return new Message(cursor);
                    } finally {
                        cursor.close();
                    }
                }
                return null;
            }

            @Override
            protected void onPostExecute(Message message) {
                composeActivity.stopService(mEmptyServiceIntent);

                composeActivity.mRefMessage = message;
                composeActivity.initFromRefMessage(action);
                composeActivity.setBody(wearReply, false);
                composeActivity.finishSetup(action, composeActivity.getIntent(), null);
                composeActivity.sendOrSaveWithSanityChecks(false /* save */, true /* show  toast */,
                        false /* orientationChanged */, true /* autoSend */);
            }
        };
    }

    private void checkValidAccounts() {
        final Account[] allAccounts = AccountUtils.getAccounts(this);
        if (allAccounts == null || allAccounts.length == 0) {
            final Intent noAccountIntent = MailAppProvider.getNoAccountIntent(this);
            if (noAccountIntent != null) {
                mAccounts = null;
                startActivityForResult(noAccountIntent, RESULT_CREATE_ACCOUNT);
            }
        } else {
            // If none of the accounts are syncing, setup a watcher.
            boolean anySyncing = false;
            for (Account a : allAccounts) {
                if (a.isAccountReady()) {
                    anySyncing = true;
                    break;
                }
            }
            if (!anySyncing) {
                // There are accounts, but none are sync'd, which is just like having no accounts.
                mAccounts = null;
                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
                return;
            }
            mAccounts = AccountUtils.getSyncingAccounts(this);
            finishCreate();
        }
    }

    private Account obtainAccount(Intent intent) {
        Account account = null;
        Object accountExtra = null;
        if (intent != null && intent.getExtras() != null) {
            accountExtra = intent.getExtras().get(Utils.EXTRA_ACCOUNT);
            if (accountExtra instanceof Account) {
                return (Account) accountExtra;
            } else if (accountExtra instanceof String) {
                // This is the Account attached to the widget compose intent.
                account = Account.newInstance((String) accountExtra);
                if (account != null) {
                    return account;
                }
            }
            accountExtra = intent.hasExtra(Utils.EXTRA_ACCOUNT) ? intent.getStringExtra(Utils.EXTRA_ACCOUNT)
                    : intent.getStringExtra(EXTRA_SELECTED_ACCOUNT);
        }

        MailAppProvider provider = MailAppProvider.getInstance();
        String lastAccountUri = provider.getLastSentFromAccount();
        if (TextUtils.isEmpty(lastAccountUri)) {
            lastAccountUri = provider.getLastViewedAccount();
        }
        if (!TextUtils.isEmpty(lastAccountUri)) {
            accountExtra = Uri.parse(lastAccountUri);
        }

        if (mAccounts != null && mAccounts.length > 0) {
            if (accountExtra instanceof String && !TextUtils.isEmpty((String) accountExtra)) {
                // For backwards compatibility, we need to check account
                // names.
                for (Account a : mAccounts) {
                    if (a.getEmailAddress().equals(accountExtra)) {
                        account = a;
                    }
                }
            } else if (accountExtra instanceof Uri) {
                // The uri of the last viewed account is what is stored in
                // the current code base.
                for (Account a : mAccounts) {
                    if (a.uri.equals(accountExtra)) {
                        account = a;
                    }
                }
            }
            if (account == null) {
                account = mAccounts[0];
            }
        }
        return account;
    }

    protected void finishSetup(int action, Intent intent, Bundle savedInstanceState) {
        setFocus(action);
        // Don't bother with the intent if we have procured a message from the
        // intent already.
        if (!hadSavedInstanceStateMessage(savedInstanceState)) {
            initAttachmentsFromIntent(intent);
        }
        initActionBar();
        initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(), action);

        // If this is a draft message, the draft account is whatever account was
        // used to open the draft message in Compose.
        if (mDraft != null) {
            mDraftAccount = mReplyFromAccount;
        }

        initChangeListeners();

        // These two should be identical since we check CC and BCC the same way
        boolean showCc = !TextUtils.isEmpty(mCc.getText())
                || (savedInstanceState != null && savedInstanceState.getBoolean(EXTRA_SHOW_CC));
        boolean showBcc = !TextUtils.isEmpty(mBcc.getText())
                || (savedInstanceState != null && savedInstanceState.getBoolean(EXTRA_SHOW_BCC));
        mCcBccView.show(false /* animate */, showCc, showBcc);
        updateHideOrShowCcBcc();
        updateHideOrShowQuotedText(mShowQuotedText);

        mRespondedInline = mInnerSavedState != null && mInnerSavedState.getBoolean(EXTRA_RESPONDED_INLINE);
        if (mRespondedInline) {
            mQuotedTextView.setVisibility(View.GONE);
        }

        mTextChanged = (savedInstanceState != null) ? savedInstanceState.getBoolean(EXTRA_TEXT_CHANGED) : false;
    }

    private static boolean hadSavedInstanceStateMessage(final Bundle savedInstanceState) {
        return savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE);
    }

    private void updateHideOrShowQuotedText(boolean showQuotedText) {
        mQuotedTextView.updateCheckedState(showQuotedText);
        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
    }

    private void setFocus(int action) {
        if (action == EDIT_DRAFT) {
            int type = mDraft.draftType;
            switch (type) {
            case UIProvider.DraftType.COMPOSE:
            case UIProvider.DraftType.FORWARD:
                action = COMPOSE;
                break;
            case UIProvider.DraftType.REPLY:
            case UIProvider.DraftType.REPLY_ALL:
            default:
                action = REPLY;
                break;
            }
        }
        switch (action) {
        case FORWARD:
        case COMPOSE:
            if (TextUtils.isEmpty(mTo.getText())) {
                mTo.requestFocus();
                break;
            }
            //$FALL-THROUGH$
        case REPLY:
        case REPLY_ALL:
        default:
            focusBody();
            break;
        }
    }

    /**
     * Focus the body of the message.
     */
    private void focusBody() {
        mBodyView.requestFocus();
        resetBodySelection();
    }

    private void resetBodySelection() {
        int length = mBodyView.getText().length();
        int signatureStartPos = getSignatureStartPosition(mSignature, mBodyView.getText().toString());
        if (signatureStartPos > -1) {
            // In case the user deleted the newlines...
            mBodyView.setSelection(signatureStartPos);
        } else if (length >= 0) {
            // Move cursor to the end.
            mBodyView.setSelection(length);
        }
    }

    @Override
    protected void onStart() {
        super.onStart();

        Analytics.getInstance().activityStart(this);
    }

    @Override
    protected void onStop() {
        super.onStop();

        Analytics.getInstance().activityStop(this);
    }

    @Override
    protected void onResume() {
        super.onResume();
        // Update the from spinner as other accounts
        // may now be available.
        if (mFromSpinner != null && mAccount != null) {
            mFromSpinner.initialize(mComposeMode, mAccount, mAccounts, mRefMessage);
        }
    }

    @Override
    protected void onPause() {
        super.onPause();

        // When the user exits the compose view, see if this draft needs saving.
        // Don't save unnecessary drafts if we are only changing the orientation.
        if (!isChangingConfigurations()) {
            saveIfNeeded();

            if (isFinishing() && !mPerformedSendOrDiscard && !isBlank()) {
                // log saving upon backing out of activity. (we avoid logging every sendOrSave()
                // because that method can be invoked many times in a single compose session.)
                logSendOrSave(true /* save */);
            }
        }
    }

    @Override
    protected void onActivityResult(int request, int result, Intent data) {
        if (request == RESULT_PICK_ATTACHMENT) {
            mAddingAttachment = false;
            if (result == RESULT_OK) {
                addAttachmentAndUpdateView(data);
            }
        } else if (request == RESULT_CREATE_ACCOUNT) {
            // We were waiting for the user to create an account
            if (result != RESULT_OK) {
                finish();
            } else {
                // Watch for accounts to show up!
                // restart the loader to get the updated list of accounts
                getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
                showWaitFragment(null);
            }
        }
    }

    @Override
    protected final void onRestoreInstanceState(Bundle savedInstanceState) {
        final boolean hasAccounts = mAccounts != null && mAccounts.length > 0;
        if (hasAccounts) {
            clearChangeListeners();
        }
        super.onRestoreInstanceState(savedInstanceState);
        if (mInnerSavedState != null) {
            if (mInnerSavedState.containsKey(EXTRA_FOCUS_SELECTION_START)) {
                int selectionStart = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_START);
                int selectionEnd = mInnerSavedState.getInt(EXTRA_FOCUS_SELECTION_END);
                // There should be a focus and it should be an EditText since we
                // only save these extras if these conditions are true.
                EditText focusEditText = (EditText) getCurrentFocus();
                final int length = focusEditText.getText().length();
                if (selectionStart < length && selectionEnd < length) {
                    focusEditText.setSelection(selectionStart, selectionEnd);
                }
            }
        }
        if (hasAccounts) {
            initChangeListeners();
        }
    }

    @Override
    protected void onSaveInstanceState(Bundle state) {
        super.onSaveInstanceState(state);
        final Bundle inner = new Bundle();
        saveState(inner);
        state.putBundle(KEY_INNER_SAVED_STATE, inner);
    }

    private void saveState(Bundle state) {
        // We have no accounts so there is nothing to compose, and therefore, nothing to save.
        if (mAccounts == null || mAccounts.length == 0) {
            return;
        }
        // The framework is happy to save and restore the selection but only if it also saves and
        // restores the contents of the edit text. That's a lot of text to put in a bundle so we do
        // this manually.
        View focus = getCurrentFocus();
        if (focus != null && focus instanceof EditText) {
            EditText focusEditText = (EditText) focus;
            state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart());
            state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd());
        }

        final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
        final int selectedPos = mFromSpinner.getSelectedItemPosition();
        final ReplyFromAccount selectedReplyFromAccount = (replyFromAccounts != null && replyFromAccounts.size() > 0
                && replyFromAccounts.size() > selectedPos) ? replyFromAccounts.get(selectedPos) : null;
        if (selectedReplyFromAccount != null) {
            state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize().toString());
            state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account);
        } else {
            state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount);
        }

        if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId != 0) {
            // We don't have a draft id, and we have a request id,
            // save the request id.
            state.putInt(EXTRA_REQUEST_ID, mRequestId);
        }

        // We want to restore the current mode after a pause
        // or rotation.
        int mode = getMode();
        state.putInt(EXTRA_ACTION, mode);

        final Message message = createMessage(selectedReplyFromAccount, mRefMessage, mode,
                removeComposingSpans(mBodyView.getText()));
        if (mDraft != null) {
            message.id = mDraft.id;
            message.serverId = mDraft.serverId;
            message.uri = mDraft.uri;
        }
        state.putParcelable(EXTRA_MESSAGE, message);

        if (mRefMessage != null) {
            state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage);
        } else if (message.appendRefMessageContent) {
            // If we have no ref message but should be appending
            // ref message content, we have orphaned quoted text. Save it.
            state.putCharSequence(EXTRA_QUOTED_TEXT, mQuotedTextView.getQuotedTextIfIncluded());
        }
        state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible());
        state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible());
        state.putBoolean(EXTRA_RESPONDED_INLINE, mRespondedInline);
        state.putBoolean(EXTRA_SAVE_ENABLED, mSave != null && mSave.isEnabled());
        state.putParcelableArrayList(EXTRA_ATTACHMENT_PREVIEWS, mAttachmentsView.getAttachmentPreviews());

        state.putParcelable(EXTRA_VALUES, mExtraValues);

        state.putBoolean(EXTRA_TEXT_CHANGED, mTextChanged);
        // On configuration changes, we don't actually need to parse the body html ourselves because
        // the framework can correctly restore the body EditText to its exact original state.
        state.putBoolean(EXTRA_SKIP_PARSING_BODY, isChangingConfigurations());
    }

    private int getMode() {
        int mode = ComposeActivity.COMPOSE;
        final ActionBar actionBar = getSupportActionBar();
        if (actionBar != null && actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) {
            mode = actionBar.getSelectedNavigationIndex();
        }
        return mode;
    }

    /**
     * This function might be called from a background thread, so be sure to move everything that
     * can potentially modify the UI to the main thread (e.g. removeComposingSpans for body).
     */
    private Message createMessage(ReplyFromAccount selectedReplyFromAccount, Message refMessage, int mode,
            Spanned body) {
        Message message = new Message();
        message.id = UIProvider.INVALID_MESSAGE_ID;
        message.serverId = null;
        message.uri = null;
        message.conversationUri = null;
        message.subject = mSubject.getText().toString();
        message.snippet = null;
        message.setTo(formatSenders(mTo.getText().toString()));
        message.setCc(formatSenders(mCc.getText().toString()));
        message.setBcc(formatSenders(mBcc.getText().toString()));
        message.setReplyTo(null);
        message.dateReceivedMs = 0;
        message.bodyHtml = spannedBodyToHtml(body, true);
        message.bodyText = body.toString();
        // Fallback to use the text version if html conversion fails for whatever the reason.
        final String htmlInPlainText = Utils.convertHtmlToPlainText(message.bodyHtml);
        if (message.bodyText != null && message.bodyText.trim().length() > 0
                && TextUtils.isEmpty(htmlInPlainText)) {
            LogUtils.w(LOG_TAG, "FAILED HTML CONVERSION: from %d to %d", message.bodyText.length(),
                    htmlInPlainText.length());
            Analytics.getInstance().sendEvent(ANALYTICS_CATEGORY_ERRORS, "failed_html_conversion", null, 0);
            message.bodyHtml = "<p>" + message.bodyText + "</p>";
        }
        message.embedsExternalResources = false;
        message.refMessageUri = mRefMessage != null ? mRefMessage.uri : null;
        message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null;
        ArrayList<Attachment> attachments = mAttachmentsView.getAttachments();
        message.hasAttachments = attachments != null && attachments.size() > 0;
        message.attachmentListUri = null;
        message.messageFlags = 0;
        message.alwaysShowImages = false;
        message.attachmentsJson = Attachment.toJSONArray(attachments);
        CharSequence quotedText = mQuotedTextView.getQuotedText();
        message.quotedTextOffset = -1; // Just a default value.
        if (refMessage != null && !TextUtils.isEmpty(quotedText)) {
            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
                // We want the index to point to just the quoted text and not the
                // "On December 25, 2014..." part of it.
                message.quotedTextOffset = QuotedTextView.getQuotedTextOffset(quotedText.toString());
            } else if (!TextUtils.isEmpty(refMessage.bodyText)) {
                // We want to point to the entire quoted text.
                message.quotedTextOffset = QuotedTextView.findQuotedTextIndex(quotedText);
            }
        }
        message.accountUri = null;
        message.setFrom(computeFromForAccount(selectedReplyFromAccount));
        message.draftType = getDraftType(mode);
        return message;
    }

    protected String computeFromForAccount(ReplyFromAccount selectedReplyFromAccount) {
        final String email = selectedReplyFromAccount != null ? selectedReplyFromAccount.address
                : mAccount != null ? mAccount.getEmailAddress() : null;
        final String senderName = selectedReplyFromAccount != null ? selectedReplyFromAccount.name
                : mAccount != null ? mAccount.getSenderName() : null;
        final Address address = new Address(email, senderName);
        return address.toHeader();
    }

    private static String formatSenders(final String string) {
        if (!TextUtils.isEmpty(string) && string.charAt(string.length() - 1) == ',') {
            return string.substring(0, string.length() - 1);
        }
        return string;
    }

    @VisibleForTesting
    protected void setAccount(Account account) {
        if (account == null) {
            return;
        }
        if (!account.equals(mAccount)) {
            mAccount = account;
            mCachedSettings = mAccount.settings;
            appendSignature();
        }
        if (mAccount != null) {
            MailActivity.setNfcMessage(mAccount.getEmailAddress());
        }
    }

    private void initFromSpinner(Bundle bundle, int action) {
        if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) {
            action = COMPOSE;
        }
        mFromSpinner.initialize(action, mAccount, mAccounts, mRefMessage);

        if (bundle != null) {
            if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) {
                mReplyFromAccount = ReplyFromAccount.deserialize(mAccount,
                        bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT));
            } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) {
                final String accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING);
                mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString);
            }
        }
        if (mReplyFromAccount == null) {
            if (mDraft != null) {
                mReplyFromAccount = getReplyFromAccountFromDraft(mDraft);
            } else if (mRefMessage != null) {
                mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage);
            }
        }
        if (mReplyFromAccount == null) {
            mReplyFromAccount = getDefaultReplyFromAccount(mAccount);
        }

        mFromSpinner.setCurrentAccount(mReplyFromAccount);

        if (mFromSpinner.getCount() > 1) {
            // If there is only 1 account, just show that account.
            // Otherwise, give the user the ability to choose which account to
            // send mail from / save drafts to.
            mFromStatic.setVisibility(View.GONE);
            mFromStaticText.setText(mReplyFromAccount.address);
            mFromSpinnerWrapper.setVisibility(View.VISIBLE);
        } else {
            mFromStatic.setVisibility(View.VISIBLE);
            mFromStaticText.setText(mReplyFromAccount.address);
            mFromSpinnerWrapper.setVisibility(View.GONE);
        }
    }

    private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) {
        if (refMessage.accountUri != null) {
            // This must be from combined inbox.
            List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts();
            for (ReplyFromAccount from : replyFromAccounts) {
                if (from.account.uri.equals(refMessage.accountUri)) {
                    return from;
                }
            }
            return null;
        } else {
            return getReplyFromAccount(account, refMessage);
        }
    }

    /**
     * Given an account and the message we're replying to,
     * return who the message should be sent from.
     * @param account Account in which the message arrived.
     * @param refMessage Message to analyze for account selection
     * @return the address from which to reply.
     */
    public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) {
        // First see if we are supposed to use the default address or
        // the address it was sentTo.
        if (mCachedSettings.forceReplyFromDefault) {
            return getDefaultReplyFromAccount(account);
        } else {
            // If we aren't explicitly told which account to look for, look at
            // all the message recipients and find one that matches
            // a custom from or account.
            List<String> allRecipients = new ArrayList<String>();
            allRecipients.addAll(Arrays.asList(refMessage.getToAddressesUnescaped()));
            allRecipients.addAll(Arrays.asList(refMessage.getCcAddressesUnescaped()));
            return getMatchingRecipient(account, allRecipients);
        }
    }

    /**
     * Compare all the recipients of an email to the current account and all
     * custom addresses associated with that account. Return the match if there
     * is one, or the default account if there isn't.
     */
    protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) {
        // Tokenize the list and place in a hashmap.
        ReplyFromAccount matchingReplyFrom = null;
        Rfc822Token[] tokens;
        HashSet<String> recipientsMap = new HashSet<String>();
        for (String address : sentTo) {
            tokens = Rfc822Tokenizer.tokenize(address);
            for (final Rfc822Token token : tokens) {
                recipientsMap.add(token.getAddress());
            }
        }

        int matchingAddressCount = 0;
        List<ReplyFromAccount> customFroms;
        customFroms = account.getReplyFroms();
        if (customFroms != null) {
            for (ReplyFromAccount entry : customFroms) {
                if (recipientsMap.contains(entry.address)) {
                    matchingReplyFrom = entry;
                    matchingAddressCount++;
                }
            }
        }
        if (matchingAddressCount > 1) {
            matchingReplyFrom = getDefaultReplyFromAccount(account);
        }
        return matchingReplyFrom;
    }

    private static ReplyFromAccount getDefaultReplyFromAccount(final Account account) {
        for (final ReplyFromAccount from : account.getReplyFroms()) {
            if (from.isDefault) {
                return from;
            }
        }
        return new ReplyFromAccount(account, account.uri, account.getEmailAddress(), account.getSenderName(),
                account.getEmailAddress(), true, false);
    }

    private ReplyFromAccount getReplyFromAccountFromDraft(final Message msg) {
        final Address[] draftFroms = Address.parse(msg.getFrom());
        final String sender = draftFroms.length > 0 ? draftFroms[0].getAddress() : "";
        ReplyFromAccount replyFromAccount = null;
        // Do not try to check against the "default" account because the default might be an alias.
        for (ReplyFromAccount fromAccount : mFromSpinner.getReplyFromAccounts()) {
            if (TextUtils.equals(fromAccount.address, sender)) {
                replyFromAccount = fromAccount;
                break;
            }
        }
        return replyFromAccount;
    }

    private void findViews() {
        mScrollView = (ScrollView) findViewById(R.id.compose);
        mScrollView.setVisibility(View.VISIBLE);
        mCcBccButton = findViewById(R.id.add_cc_bcc);
        if (mCcBccButton != null) {
            mCcBccButton.setOnClickListener(this);
        }
        mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper);
        mAttachmentsView = (AttachmentsView) findViewById(R.id.attachments);
        mTo = (RecipientEditTextView) findViewById(R.id.to);
        mTo.setOnKeyListener(mKeyListenerForSendShortcut);
        initializeRecipientEditTextView(mTo);
        mTo.setAlternatePopupAnchor(findViewById(R.id.compose_to_dropdown_anchor));
        mCc = (RecipientEditTextView) findViewById(R.id.cc);
        mCc.setOnKeyListener(mKeyListenerForSendShortcut);
        initializeRecipientEditTextView(mCc);
        mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
        mBcc.setOnKeyListener(mKeyListenerForSendShortcut);
        initializeRecipientEditTextView(mBcc);
        // TODO: add special chips text change watchers before adding
        // this as a text changed watcher to the to, cc, bcc fields.
        mSubject = (TextView) findViewById(R.id.subject);
        mSubject.setOnKeyListener(mKeyListenerForSendShortcut);
        mSubject.setOnEditorActionListener(this);
        mSubject.setOnFocusChangeListener(this);
        mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
        mQuotedTextView.setRespondInlineListener(this);
        mBodyView = (EditText) findViewById(R.id.body);
        mBodyView.setOnKeyListener(mKeyListenerForSendShortcut);
        mBodyView.setOnFocusChangeListener(this);
        mFromStatic = findViewById(R.id.static_from_content);
        mFromStaticText = (TextView) findViewById(R.id.from_account_name);
        mFromSpinnerWrapper = findViewById(R.id.spinner_from_content);
        mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker);

        // Bottom placeholder to forward click events to the body
        findViewById(R.id.composearea_tap_trap_bottom).setOnClickListener(new OnClickListener() {
            @Override
            public void onClick(View v) {
                mBodyView.requestFocus();
                mBodyView.setSelection(mBodyView.getText().length());
            }
        });
    }

    private void initializeRecipientEditTextView(RecipientEditTextView view) {
        view.setTokenizer(new Rfc822Tokenizer());
        view.setThreshold(COMPLETION_THRESHOLD);
    }

    @Override
    public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
        if (action == EditorInfo.IME_ACTION_DONE) {
            focusBody();
            return true;
        }
        return false;
    }

    /**
     * Convert the body text (in {@link Spanned} form) to ready-to-send HTML format as a plain
     * String.
     *
     * @param body the body text including fancy style spans
     * @param removedComposing whether the function already removed composingSpans. Necessary
     *   because we cannot call removeComposingSpans from a background thread.
     * @return HTML formatted body that's suitable for sending or saving
     */
    private String spannedBodyToHtml(Spanned body, boolean removedComposing) {
        if (!removedComposing) {
            body = removeComposingSpans(body);
        }
        final HtmlifyBeginResult r = onHtmlifyBegin(body);
        return onHtmlifyEnd(Html.toHtml(r.result), r.extras);
    }

    /**
     * A hook for subclasses to convert custom spans in the body text prior to system HTML
     * conversion. That HTML conversion is lossy, so anything above and beyond its capability
     * has to be handled here.
     *
     * @param body
     * @return a copy of the body text with custom spans replaced with HTML
     */
    protected HtmlifyBeginResult onHtmlifyBegin(Spanned body) {
        return new HtmlifyBeginResult(body, null /* extras */);
    }

    protected String onHtmlifyEnd(String html, Object extras) {
        return html;
    }

    protected TextView getBody() {
        return mBodyView;
    }

    @VisibleForTesting
    public String getBodyHtml() {
        return spannedBodyToHtml(mBodyView.getText(), false);
    }

    @VisibleForTesting
    public Account getFromAccount() {
        return mReplyFromAccount != null && mReplyFromAccount.account != null ? mReplyFromAccount.account
                : mAccount;
    }

    private void clearChangeListeners() {
        mSubject.removeTextChangedListener(this);
        mBodyView.removeTextChangedListener(this);
        mTo.removeTextChangedListener(mToListener);
        mCc.removeTextChangedListener(mCcListener);
        mBcc.removeTextChangedListener(mBccListener);
        mFromSpinner.setOnAccountChangedListener(null);
        mAttachmentsView.setAttachmentChangesListener(null);
    }

    // Now that the message has been initialized from any existing draft or
    // ref message data, set up listeners for any changes that occur to the
    // message.
    private void initChangeListeners() {
        // Make sure we only add text changed listeners once!
        clearChangeListeners();
        mSubject.addTextChangedListener(this);
        mBodyView.addTextChangedListener(this);
        if (mToListener == null) {
            mToListener = new RecipientTextWatcher(mTo, this);
        }
        mTo.addTextChangedListener(mToListener);
        if (mCcListener == null) {
            mCcListener = new RecipientTextWatcher(mCc, this);
        }
        mCc.addTextChangedListener(mCcListener);
        if (mBccListener == null) {
            mBccListener = new RecipientTextWatcher(mBcc, this);
        }
        mBcc.addTextChangedListener(mBccListener);
        mFromSpinner.setOnAccountChangedListener(this);
        mAttachmentsView.setAttachmentChangesListener(this);
    }

    private void initActionBar() {
        LogUtils.d(LOG_TAG, "initializing action bar in ComposeActivity");
        final ActionBar actionBar = getSupportActionBar();
        if (actionBar == null) {
            return;
        }
        if (mComposeMode == ComposeActivity.COMPOSE) {
            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
            actionBar.setTitle(R.string.compose_title);
        } else {
            actionBar.setTitle(null);
            if (mComposeModeAdapter == null) {
                mComposeModeAdapter = new ComposeModeAdapter(actionBar.getThemedContext());
            }
            actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
            actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
            switch (mComposeMode) {
            case ComposeActivity.REPLY:
                actionBar.setSelectedNavigationItem(0);
                break;
            case ComposeActivity.REPLY_ALL:
                actionBar.setSelectedNavigationItem(1);
                break;
            case ComposeActivity.FORWARD:
                actionBar.setSelectedNavigationItem(2);
                break;
            }
        }
        actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP);
        actionBar.setHomeButtonEnabled(true);
    }

    private void initFromRefMessage(int action) {
        setFieldsFromRefMessage(action);

        // Check if To: address and email body needs to be prefilled based on extras.
        // This is used for reporting rendering feedback.
        if (MessageHeaderView.ENABLE_REPORT_RENDERING_PROBLEM) {
            Intent intent = getIntent();
            if (intent.getExtras() != null) {
                String toAddresses = intent.getStringExtra(EXTRA_TO);
                if (toAddresses != null) {
                    addToAddresses(Arrays.asList(TextUtils.split(toAddresses, ",")));
                }
                String body = intent.getStringExtra(EXTRA_BODY);
                if (body != null) {
                    setBody(body, false /* withSignature */);
                }
            }
        }
    }

    private void setFieldsFromRefMessage(int action) {
        setSubject(mRefMessage, action);
        // Setup recipients
        if (action == FORWARD) {
            mForward = true;
        }
        initRecipientsFromRefMessage(mRefMessage, action);
        initQuotedTextFromRefMessage(mRefMessage, action);
        if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
            initAttachments(mRefMessage);
        }
    }

    protected HtmlTree.Converter<Spanned> getSpanConverter() {
        return new HtmlUtils.SpannedConverter();
    }

    private void initFromDraftMessage(Message message) {
        LogUtils.d(LOG_TAG, "Initializing draft from previous draft message: %s", message);

        synchronized (mDraftLock) {
            // Draft id might already be set by the request to id map, if so we don't need to set it
            if (mDraftId == UIProvider.INVALID_MESSAGE_ID) {
                mDraftId = message.id;
            } else {
                message.id = mDraftId;
            }
            mDraft = message;
        }
        mSubject.setText(message.subject);
        mForward = message.draftType == UIProvider.DraftType.FORWARD;

        final List<String> toAddresses = Arrays.asList(message.getToAddressesUnescaped());
        addToAddresses(toAddresses);
        addCcAddresses(Arrays.asList(message.getCcAddressesUnescaped()), toAddresses);
        addBccAddresses(Arrays.asList(message.getBccAddressesUnescaped()));
        if (message.hasAttachments) {
            List<Attachment> attachments = message.getAttachments();
            for (Attachment a : attachments) {
                addAttachmentAndUpdateView(a);
            }
        }

        // If we don't need to re-populate the body, and the quoted text will be restored from
        // ref message. So we can skip rest of this code.
        if (mInnerSavedState != null && mInnerSavedState.getBoolean(EXTRA_SKIP_PARSING_BODY)) {
            LogUtils.i(LOG_TAG, "Skipping manually populating body and quoted text from draft.");
            return;
        }

        int quotedTextIndex = message.appendRefMessageContent ? message.quotedTextOffset : -1;
        // Set the body
        CharSequence quotedText = null;
        if (!TextUtils.isEmpty(message.bodyHtml)) {
            String body = message.bodyHtml;
            if (quotedTextIndex > -1) {
                // Find the offset in the html text of the actual quoted text and strip it out.
                // Note that the actual quotedTextOffset in the message has not changed as
                // this different offset is used only for display purposes. They point to different
                // parts of the original message.  Please see the comments in QuoteTextView
                // to see the differences.
                quotedTextIndex = QuotedTextView.findQuotedTextIndex(message.bodyHtml);
                if (quotedTextIndex > -1) {
                    body = message.bodyHtml.substring(0, quotedTextIndex);
                    quotedText = message.bodyHtml.subSequence(quotedTextIndex, message.bodyHtml.length());
                }
            }
            new HtmlToSpannedTask().execute(body);
        } else {
            final String body = message.bodyText;
            final CharSequence bodyText;
            if (TextUtils.isEmpty(body)) {
                bodyText = "";
                quotedText = null;
            } else {
                if (quotedTextIndex > body.length()) {
                    // Sanity check to guarantee that we will not over index the String.
                    // If this happens there is a bigger problem. This should never happen hence
                    // the wtf logging.
                    quotedTextIndex = -1;
                    LogUtils.wtf(LOG_TAG, "quotedTextIndex (%d) > body.length() (%d)", quotedTextIndex,
                            body.length());
                }
                bodyText = quotedTextIndex > -1 ? body.substring(0, quotedTextIndex) : body;
                if (quotedTextIndex > -1) {
                    quotedText = body.substring(quotedTextIndex);
                }
            }
            setBody(bodyText, false);
        }
        if (quotedTextIndex > -1 && quotedText != null) {
            mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward);
        }
    }

    /**
     * Fill all the widgets with the content found in the Intent Extra, if any.
     * Also apply the same style to all widgets. Note: if initFromExtras is
     * called as a result of switching between reply, reply all, and forward per
     * the latest revision of Gmail, and the user has already made changes to
     * attachments on a previous incarnation of the message (as a reply, reply
     * all, or forward), the original attachments from the message will not be
     * re-instantiated. The user's changes will be respected. This follows the
     * web gmail interaction.
     * @return {@code true} if the activity should not call {@link #finishSetup}.
     */
    public boolean initFromExtras(Intent intent) {
        // If we were invoked with a SENDTO intent, the value
        // should take precedence
        final Uri dataUri = intent.getData();
        if (dataUri != null) {
            if (MAIL_TO.equals(dataUri.getScheme())) {
                initFromMailTo(dataUri.toString());
            } else {
                if (!mAccount.composeIntentUri.equals(dataUri)) {
                    String toText = dataUri.getSchemeSpecificPart();
                    if (toText != null) {
                        mTo.setText("");
                        addToAddresses(Arrays.asList(TextUtils.split(toText, ",")));
                    }
                }
            }
        }

        String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL);
        if (extraStrings != null) {
            addToAddresses(Arrays.asList(extraStrings));
        }
        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC);
        if (extraStrings != null) {
            addCcAddresses(Arrays.asList(extraStrings), null);
        }
        extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC);
        if (extraStrings != null) {
            addBccAddresses(Arrays.asList(extraStrings));
        }

        String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT);
        if (extraString != null) {
            mSubject.setText(extraString);
        }

        for (String extra : ALL_EXTRAS) {
            if (intent.hasExtra(extra)) {
                String value = intent.getStringExtra(extra);
                if (EXTRA_TO.equals(extra)) {
                    addToAddresses(Arrays.asList(TextUtils.split(value, ",")));
                } else if (EXTRA_CC.equals(extra)) {
                    addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null);
                } else if (EXTRA_BCC.equals(extra)) {
                    addBccAddresses(Arrays.asList(TextUtils.split(value, ",")));
                } else if (EXTRA_SUBJECT.equals(extra)) {
                    mSubject.setText(value);
                } else if (EXTRA_BODY.equals(extra)) {
                    setBody(value, true /* with signature */);
                } else if (EXTRA_QUOTED_TEXT.equals(extra)) {
                    initQuotedText(value, true /* shouldQuoteText */);
                }
            }
        }

        Bundle extras = intent.getExtras();
        if (extras != null) {
            CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT);
            setBody((text != null) ? text : "", true /* with signature */);

            // TODO - support EXTRA_HTML_TEXT
        }

        mExtraValues = intent.getParcelableExtra(EXTRA_VALUES);
        if (mExtraValues != null) {
            LogUtils.d(LOG_TAG, "Launched with extra values: %s", mExtraValues.toString());
            initExtraValues(mExtraValues);
            return true;
        }

        return false;
    }

    protected void initExtraValues(ContentValues extraValues) {
        // DO NOTHING - Gmail will override
    }

    @VisibleForTesting
    protected String decodeEmailInUri(String s) throws UnsupportedEncodingException {
        // TODO: handle the case where there are spaces in the display name as
        // well as the email such as "Guy with spaces <guy+with+spaces@gmail.com>"
        // as they could be encoded ambiguously.
        // Since URLDecode.decode changes + into ' ', and + is a valid
        // email character, we need to find/ replace these ourselves before
        // decoding.
        try {
            return URLDecoder.decode(replacePlus(s), UTF8_ENCODING_NAME);
        } catch (IllegalArgumentException e) {
            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s);
            } else {
                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
            }
            return null;
        }
    }

    /**
     * Replaces all occurrences of '+' with "%2B", to prevent URLDecode.decode from
     * changing '+' into ' '
     *
     * @param toReplace Input string
     * @return The string with all "+" characters replaced with "%2B"
     */
    private static String replacePlus(String toReplace) {
        return toReplace.replace("+", "%2B");
    }

    /**
     * Replaces all occurrences of '%' with "%25", to prevent URLDecode.decode from
     * crashing on decoded '%' symbols
     *
     * @param toReplace Input string
     * @return The string with all "%" characters replaced with "%25"
     */
    private static String replacePercent(String toReplace) {
        return toReplace.replace("%", "%25");
    }

    /**
     * Helper function to encapsulate encoding/decoding string from Uri.getQueryParameters
     * @param content Input string
     * @return The string that's properly escaped to be shown in mail subject/content
     */
    private static String decodeContentFromQueryParam(String content) {
        try {
            return URLDecoder.decode(replacePlus(replacePercent(content)), UTF8_ENCODING_NAME);
        } catch (UnsupportedEncodingException e) {
            LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), content);
            return ""; // Default to empty string so setText/setBody has same behavior as before.
        }
    }

    /**
     * Initialize the compose view from a String representing a mailTo uri.
     * @param mailToString The uri as a string.
     */
    public void initFromMailTo(String mailToString) {
        // We need to disguise this string as a URI in order to parse it
        // TODO:  Remove this hack when http://b/issue?id=1445295 gets fixed
        Uri uri = Uri.parse("foo://" + mailToString);
        int index = mailToString.indexOf("?");
        int length = "mailto".length() + 1;
        String to;
        try {
            // Extract the recipient after mailto:
            if (index == -1) {
                to = decodeEmailInUri(mailToString.substring(length));
            } else {
                to = decodeEmailInUri(mailToString.substring(length, index));
            }
            if (!TextUtils.isEmpty(to)) {
                addToAddresses(Arrays.asList(TextUtils.split(to, ",")));
            }
        } catch (UnsupportedEncodingException e) {
            if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) {
                LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString);
            } else {
                LogUtils.e(LOG_TAG, e, "Exception  while decoding mailto address");
            }
        }

        List<String> cc = uri.getQueryParameters("cc");
        addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null);

        List<String> otherTo = uri.getQueryParameters("to");
        addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()])));

        List<String> bcc = uri.getQueryParameters("bcc");
        addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()])));

        // NOTE: Uri.getQueryParameters already decodes % encoded characters
        List<String> subject = uri.getQueryParameters("subject");
        if (subject.size() > 0) {
            mSubject.setText(decodeContentFromQueryParam(subject.get(0)));
        }

        List<String> body = uri.getQueryParameters("body");
        if (body.size() > 0) {
            setBody(decodeContentFromQueryParam(body.get(0)), true /* with signature */);
        }
    }

    @VisibleForTesting
    protected void initAttachments(Message refMessage) {
        addAttachments(refMessage.getAttachments());
    }

    public long addAttachments(List<Attachment> attachments) {
        long size = 0;
        AttachmentFailureException error = null;
        for (Attachment a : attachments) {
            try {
                size += mAttachmentsView.addAttachment(mAccount, a);
            } catch (AttachmentFailureException e) {
                error = e;
            }
        }
        if (error != null) {
            LogUtils.e(LOG_TAG, error, "Error adding attachment");
            if (attachments.size() > 1) {
                showAttachmentTooBigToast(R.string.too_large_to_attach_multiple);
            } else {
                showAttachmentTooBigToast(error.getErrorRes());
            }
        }
        return size;
    }

    /**
     * When an attachment is too large to be added to a message, show a toast.
     * This method also updates the position of the toast so that it is shown
     * clearly above they keyboard if it happens to be open.
     */
    private void showAttachmentTooBigToast(int errorRes) {
        String maxSize = AttachmentUtils.convertToHumanReadableSize(getApplicationContext(),
                mAccount.settings.getMaxAttachmentSize());
        showErrorToast(getString(errorRes, maxSize));
    }

    private void showErrorToast(String message) {
        Toast t = Toast.makeText(this, message, Toast.LENGTH_LONG);
        t.setText(message);
        t.setGravity(Gravity.CENTER_HORIZONTAL, 0,
                getResources().getDimensionPixelSize(R.dimen.attachment_toast_yoffset));
        t.show();
    }

    private void initAttachmentsFromIntent(Intent intent) {
        Bundle extras = intent.getExtras();
        if (extras == null) {
            extras = Bundle.EMPTY;
        }
        final String action = intent.getAction();
        if (!mAttachmentsChanged) {
            long totalSize = 0;
            if (extras.containsKey(EXTRA_ATTACHMENTS)) {
                final String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS);
                final ArrayList<Uri> parsedUris = Lists.newArrayListWithCapacity(uris.length);
                for (String uri : uris) {
                    parsedUris.add(Uri.parse(uri));
                }
                totalSize += handleAttachmentUrisFromIntent(parsedUris);
            }
            if (extras.containsKey(Intent.EXTRA_STREAM)) {
                if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
                    final ArrayList<Uri> uris = extras.getParcelableArrayList(Intent.EXTRA_STREAM);
                    totalSize += handleAttachmentUrisFromIntent(uris);
                } else {
                    final Uri uri = extras.getParcelable(Intent.EXTRA_STREAM);
                    final ArrayList<Uri> uris = Lists.newArrayList(uri);
                    totalSize += handleAttachmentUrisFromIntent(uris);
                }
            }

            if (totalSize > 0) {
                mAttachmentsChanged = true;
                updateSaveUi();

                Analytics.getInstance().sendEvent("send_intent_with_attachments",
                        Integer.toString(getAttachments().size()), null, totalSize);
            }
        }
    }

    /**
     * @return the authority of EmailProvider for this app. should be overridden in concrete
     * app implementations. can't be known here because this project doesn't know about that sort
     * of thing.
     */
    protected String getEmailProviderAuthority() {
        throw new UnsupportedOperationException("unimplemented, EmailProvider unknown");
    }

    /**
     * Helper function to handle a list of uris to attach.
     * @return the total size of all successfully attached files.
     */
    private long handleAttachmentUrisFromIntent(List<Uri> uris) {
        ArrayList<Attachment> attachments = Lists.newArrayList();
        for (Uri uri : uris) {
            try {
                if (uri != null) {
                    if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
                        // We must not allow files from /data, even from our process.
                        final File f = new File(uri.getPath());
                        final String filePath = f.getCanonicalPath();
                        if (filePath.startsWith(DATA_DIRECTORY_ROOT)) {
                            showErrorToast(getString(R.string.attachment_permission_denied));
                            Analytics.getInstance().sendEvent(ANALYTICS_CATEGORY_ERRORS, "send_intent_attachment",
                                    "data_dir", 0);
                            continue;
                        }
                    } else if (ContentResolver.SCHEME_CONTENT.equals(uri.getScheme())) {
                        // disallow attachments from our own EmailProvider (b/27308057)
                        if (getEmailProviderAuthority().equals(uri.getAuthority())) {
                            showErrorToast(getString(R.string.attachment_permission_denied));
                            Analytics.getInstance().sendEvent(ANALYTICS_CATEGORY_ERRORS, "send_intent_attachment",
                                    "email_provider", 0);
                            continue;
                        }
                    }

                    if (!handleSpecialAttachmentUri(uri)) {
                        final Attachment a = mAttachmentsView.generateLocalAttachment(uri);
                        attachments.add(a);

                        Analytics.getInstance().sendEvent("send_intent_attachment",
                                Utils.normalizeMimeType(a.getContentType()), null, a.size);
                    }
                }
            } catch (AttachmentFailureException e) {
                LogUtils.e(LOG_TAG, e, "Error adding attachment");
                showAttachmentTooBigToast(e.getErrorRes());
            } catch (IOException | SecurityException e) {
                LogUtils.e(LOG_TAG, e, "Error adding attachment");
                showErrorToast(getString(R.string.attachment_permission_denied));
            }
        }
        return addAttachments(attachments);
    }

    protected void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) {
        mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText);
        mShowQuotedText = true;
    }

    private void initQuotedTextFromRefMessage(Message refMessage, int action) {
        if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) {
            mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD);
        }
    }

    private void updateHideOrShowCcBcc() {
        // Its possible there is a menu item OR a button.
        boolean ccVisible = mCcBccView.isCcVisible();
        boolean bccVisible = mCcBccView.isBccVisible();
        if (mCcBccButton != null) {
            if (!ccVisible || !bccVisible) {
                mCcBccButton.setVisibility(View.VISIBLE);
            } else {
                mCcBccButton.setVisibility(View.GONE);
            }
        }
    }

    /**
     * Add attachment and update the compose area appropriately.
     */
    private void addAttachmentAndUpdateView(Intent data) {
        if (data == null) {
            return;
        }

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            final ClipData clipData = data.getClipData();
            if (clipData != null) {
                for (int i = 0, size = clipData.getItemCount(); i < size; i++) {
                    addAttachmentAndUpdateView(clipData.getItemAt(i).getUri());
                }
                return;
            }
        }

        addAttachmentAndUpdateView(data.getData());
    }

    private void addAttachmentAndUpdateView(Uri contentUri) {
        if (contentUri == null) {
            return;
        }
        try {

            if (handleSpecialAttachmentUri(contentUri)) {
                return;
            }

            addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri));
        } catch (AttachmentFailureException e) {
            LogUtils.e(LOG_TAG, e, "Error adding attachment");
            showErrorToast(getResources().getString(e.getErrorRes(), AttachmentUtils.convertToHumanReadableSize(
                    getApplicationContext(), mAccount.settings.getMaxAttachmentSize())));
        }
    }

    /**
     * Allow subclasses to implement custom handling of attachments.
     *
     * @param contentUri a passed-in URI from a pick intent
     * @return true iff handled
     */
    protected boolean handleSpecialAttachmentUri(final Uri contentUri) {
        return false;
    }

    private void addAttachmentAndUpdateView(Attachment attachment) {
        try {
            long size = mAttachmentsView.addAttachment(mAccount, attachment);
            if (size > 0) {
                mAttachmentsChanged = true;
                updateSaveUi();
            }
        } catch (AttachmentFailureException e) {
            LogUtils.e(LOG_TAG, e, "Error adding attachment");
            showAttachmentTooBigToast(e.getErrorRes());
        }
    }

    void initRecipientsFromRefMessage(Message refMessage, int action) {
        // Don't populate the address if this is a forward.
        if (action == ComposeActivity.FORWARD) {
            return;
        }
        initReplyRecipients(refMessage, action);
    }

    // TODO: This should be private.  This method shouldn't be used by ComposeActivityTests, as
    // it doesn't setup the state of the activity correctly
    @VisibleForTesting
    void initReplyRecipients(final Message refMessage, final int action) {
        String[] sentToAddresses = refMessage.getToAddressesUnescaped();
        final Collection<String> toAddresses;
        final String[] fromAddresses = refMessage.getFromAddressesUnescaped();
        final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null;
        final String[] replyToAddresses = getReplyToAddresses(refMessage.getReplyToAddressesUnescaped(),
                fromAddress);

        // If this is a reply, the Cc list is empty. If this is a reply-all, the
        // Cc list is the union of the To and Cc recipients of the original
        // message, excluding the current user's email address and any addresses
        // already on the To list.
        if (action == ComposeActivity.REPLY) {
            toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses);
            addToAddresses(toAddresses);
        } else if (action == ComposeActivity.REPLY_ALL) {
            final Set<String> ccAddresses = Sets.newHashSet();
            toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses);
            addToAddresses(toAddresses);
            addRecipients(ccAddresses, sentToAddresses);
            addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped());
            addCcAddresses(ccAddresses, toAddresses);
        }
    }

    // If there is no reply to address, the reply to address is the sender.
    private static String[] getReplyToAddresses(String[] replyTo, String from) {
        boolean hasReplyTo = false;
        for (final String replyToAddress : replyTo) {
            if (!TextUtils.isEmpty(replyToAddress)) {
                hasReplyTo = true;
            }
        }
        return hasReplyTo ? replyTo : new String[] { from };
    }

    private void addToAddresses(Collection<String> addresses) {
        addAddressesToList(addresses, mTo);
    }

    private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
        addCcAddressesToList(tokenizeAddressList(addresses),
                toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc);
    }

    private void addBccAddresses(Collection<String> addresses) {
        addAddressesToList(addresses, mBcc);
    }

    @VisibleForTesting
    protected void addCcAddressesToList(List<Rfc822Token[]> addresses, List<Rfc822Token[]> compareToList,
            RecipientEditTextView list) {
        String address;

        if (compareToList == null) {
            for (final Rfc822Token[] tokens : addresses) {
                for (final Rfc822Token token : tokens) {
                    address = token.toString();
                    list.append(address + END_TOKEN);
                }
            }
        } else {
            HashSet<String> compareTo = convertToHashSet(compareToList);
            for (final Rfc822Token[] tokens : addresses) {
                for (final Rfc822Token token : tokens) {
                    address = token.toString();
                    // Check if this is a duplicate:
                    if (!compareTo.contains(token.getAddress())) {
                        // Get the address here
                        list.append(address + END_TOKEN);
                    }
                }
            }
        }
    }

    private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) {
        final HashSet<String> hash = new HashSet<String>();
        for (final Rfc822Token[] tokens : list) {
            for (final Rfc822Token token : tokens) {
                hash.add(token.getAddress());
            }
        }
        return hash;
    }

    protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) {
        @VisibleForTesting
        List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>();

        for (String address : addresses) {
            tokenized.add(Rfc822Tokenizer.tokenize(address));
        }
        return tokenized;
    }

    @VisibleForTesting
    void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) {
        for (String address : addresses) {
            addAddressToList(address, list);
        }
    }

    private static void addAddressToList(final String address, final RecipientEditTextView list) {
        if (address == null || list == null)
            return;

        final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);

        for (final Rfc822Token token : tokens) {
            list.append(token + END_TOKEN);
        }
    }

    @VisibleForTesting
    protected Collection<String> initToRecipients(final String fullSenderAddress, final String[] replyToAddresses,
            final String[] inToAddresses) {
        // The To recipient is the reply-to address specified in the original
        // message, unless it is:
        // the current user OR a custom from of the current user, in which case
        // it's the To recipient list of the original message.
        // OR missing, in which case use the sender of the original message
        Set<String> toAddresses = Sets.newHashSet();
        for (final String replyToAddress : replyToAddresses) {
            if (!TextUtils.isEmpty(replyToAddress) && !recipientMatchesThisAccount(replyToAddress)) {
                toAddresses.add(replyToAddress);
            }
        }
        if (toAddresses.size() == 0) {
            // In this case, the user is replying to a message in which their
            // current account or some of their custom from addresses are the only
            // recipients and they sent the original message.
            if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress)
                    && recipientMatchesThisAccount(inToAddresses[0])) {
                toAddresses.add(inToAddresses[0]);
                return toAddresses;
            }
            // This happens if the user replies to a message they originally
            // wrote. In this case, "reply" really means "re-send," so we
            // target the original recipients. This works as expected even
            // if the user sent the original message to themselves.
            for (String address : inToAddresses) {
                if (!recipientMatchesThisAccount(address)) {
                    toAddresses.add(address);
                }
            }
        }
        return toAddresses;
    }

    private void addRecipients(final Set<String> recipients, final String[] addresses) {
        for (final String email : addresses) {
            // Do not add this account, or any of its custom from addresses, to
            // the list of recipients.
            final String recipientAddress = Address.getEmailAddress(email).getAddress();
            if (!recipientMatchesThisAccount(recipientAddress)) {
                recipients.add(email.replace("\"\"", ""));
            }
        }
    }

    /**
     * A recipient matches this account if it has the same address as the
     * currently selected account OR one of the custom from addresses associated
     * with the currently selected account.
     * @param recipientAddress address we are comparing with the currently selected account
     */
    protected boolean recipientMatchesThisAccount(String recipientAddress) {
        return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress, mAccount.getReplyFroms());
    }

    /**
     * Returns a formatted subject string with the appropriate prefix for the action type.
     * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}.
     */
    public static String buildFormattedSubject(Resources res, String subject, int action) {
        final String prefix;
        final String correctedSubject;
        if (action == ComposeActivity.COMPOSE) {
            prefix = "";
        } else if (action == ComposeActivity.FORWARD) {
            prefix = res.getString(R.string.forward_subject_label);
        } else {
            prefix = res.getString(R.string.reply_subject_label);
        }

        if (TextUtils.isEmpty(subject)) {
            correctedSubject = prefix;
        } else {
            // Don't duplicate the prefix
            if (subject.toLowerCase().startsWith(prefix.toLowerCase())) {
                correctedSubject = subject;
            } else {
                correctedSubject = String.format(res.getString(R.string.formatted_subject), prefix, subject);
            }
        }

        return correctedSubject;
    }

    private void setSubject(Message refMessage, int action) {
        mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action));
    }

    private void initRecipients() {
        setupRecipients(mTo);
        setupRecipients(mCc);
        setupRecipients(mBcc);
    }

    private void setupRecipients(RecipientEditTextView view) {
        final DropdownChipLayouter layouter = getDropdownChipLayouter();
        if (layouter != null) {
            view.setDropdownChipLayouter(layouter);
        }
        view.setAdapter(getRecipientAdapter());
        view.setRecipientEntryItemClickedListener(this);
        if (mValidator == null) {
            final String accountName = mAccount.getEmailAddress();
            int offset = accountName.indexOf("@") + 1;
            String account = accountName;
            if (offset > 0) {
                account = account.substring(offset);
            }
            mValidator = new Rfc822Validator(account);
        }
        view.setValidator(mValidator);
    }

    /**
     * Derived classes should override if they wish to provide their own autocomplete behavior.
     */
    public BaseRecipientAdapter getRecipientAdapter() {
        return new RecipientAdapter(this, mAccount);
    }

    /**
     * Derived classes should override this to provide their own dropdown behavior.
     * If the result is null, the default {@link com.android.ex.chips.DropdownChipLayouter}
     * is used.
     */
    public DropdownChipLayouter getDropdownChipLayouter() {
        return null;
    }

    @Override
    public void onClick(View v) {
        final int id = v.getId();
        if (id == R.id.add_cc_bcc) {
            // Verify that cc/ bcc aren't showing.
            // Animate in cc/bcc.
            showCcBccViews();
        }
    }

    @Override
    public void onFocusChange(View v, boolean hasFocus) {
        final int id = v.getId();
        if (hasFocus && (id == R.id.subject || id == R.id.body)) {
            // Collapse cc/bcc iff both are empty
            final boolean showCcBccFields = !TextUtils.isEmpty(mCc.getText()) || !TextUtils.isEmpty(mBcc.getText());
            mCcBccView.show(false /* animate */, showCcBccFields, showCcBccFields);
            mCcBccButton.setVisibility(showCcBccFields ? View.GONE : View.VISIBLE);

            // On phones autoscroll down so that Cc aligns to the top if we are showing cc/bcc.
            if (getResources().getBoolean(R.bool.auto_scroll_cc) && showCcBccFields) {
                final int[] coords = new int[2];
                mCc.getLocationOnScreen(coords);

                // Subtract status bar and action bar height from y-coord.
                getWindow().getDecorView().getWindowVisibleDisplayFrame(mRect);
                final int deltaY = coords[1] - getSupportActionBar().getHeight() - mRect.top;

                // Only scroll down
                if (deltaY > 0) {
                    mScrollView.smoothScrollBy(0, deltaY);
                }
            }
        }
    }

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        final boolean superCreated = super.onCreateOptionsMenu(menu);
        // Don't render any menu items when there are no accounts.
        if (mAccounts == null || mAccounts.length == 0) {
            return superCreated;
        }
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.compose_menu, menu);

        /*
         * Start save in the correct enabled state.
         * 1) If a user launches compose from within gmail, save is disabled
         * until they add something, at which point, save is enabled, auto save
         * on exit; if the user empties everything, save is disabled, exiting does not
         * auto-save
         * 2) if a user replies/ reply all/ forwards from within gmail, save is
         * disabled until they change something, at which point, save is
         * enabled, auto save on exit; if the user empties everything, save is
         * disabled, exiting does not auto-save.
         * 3) If a user launches compose from another application and something
         * gets populated (attachments, recipients, body, subject, etc), save is
         * enabled, auto save on exit; if the user empties everything, save is
         * disabled, exiting does not auto-save
         */
        mSave = menu.findItem(R.id.save);
        String action = getIntent() != null ? getIntent().getAction() : null;
        enableSave(mInnerSavedState != null ? mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED)
                : (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)
                        || Intent.ACTION_SENDTO.equals(action) || isDraftDirty()));

        final MenuItem helpItem = menu.findItem(R.id.help_info_menu_item);
        final MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item);
        final MenuItem attachFromServiceItem = menu.findItem(R.id.attach_from_service_stub1);
        if (helpItem != null) {
            helpItem.setVisible(mAccount != null && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT));
        }
        if (sendFeedbackItem != null) {
            sendFeedbackItem
                    .setVisible(mAccount != null && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK));
        }
        if (attachFromServiceItem != null) {
            attachFromServiceItem.setVisible(shouldEnableAttachFromServiceMenu(mAccount));
        }

        // Show attach picture on pre-K devices.
        menu.findItem(R.id.add_photo_attachment).setVisible(!Utils.isRunningKitkatOrLater());

        return true;
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        final int id = item.getItemId();

        Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id, "compose", 0);

        boolean handled = true;
        if (id == R.id.add_file_attachment) {
            doAttach(MIME_TYPE_ALL);
        } else if (id == R.id.add_photo_attachment) {
            doAttach(MIME_TYPE_PHOTO);
        } else if (id == R.id.save) {
            doSave(true);
        } else if (id == R.id.send) {
            doSend();
        } else if (id == R.id.discard) {
            doDiscard();
        } else if (id == R.id.settings) {
            Utils.showSettings(this, mAccount);
        } else if (id == android.R.id.home) {
            onAppUpPressed();
        } else if (id == R.id.help_info_menu_item) {
            Utils.showHelp(this, mAccount, getString(R.string.compose_help_context));
        } else {
            handled = false;
        }
        return handled || super.onOptionsItemSelected(item);
    }

    @Override
    public void onBackPressed() {
        // If we are showing the wait fragment, just exit.
        if (getWaitFragment() != null) {
            finish();
        } else {
            super.onBackPressed();
        }
    }

    /**
     * Carries out the "up" action in the action bar.
     */
    private void onAppUpPressed() {
        if (mLaunchedFromEmail) {
            // If this was started from Gmail, simply treat app up as the system back button, so
            // that the last view is restored.
            onBackPressed();
            return;
        }

        // Fire the main activity to ensure it launches the "top" screen of mail.
        // Since the main Activity is singleTask, it should revive that task if it was already
        // started.
        final Intent mailIntent = Utils.createViewInboxIntent(mAccount);
        mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK
                | Intent.FLAG_ACTIVITY_TASK_ON_HOME);
        startActivity(mailIntent);
        finish();
    }

    private void doSend() {
        sendOrSaveWithSanityChecks(false, true, false, false);
        logSendOrSave(false /* save */);
        mPerformedSendOrDiscard = true;
    }

    private void doSave(boolean showToast) {
        sendOrSaveWithSanityChecks(true, showToast, false, false);
    }

    @Override
    public void onRecipientEntryItemClicked(int charactersTyped, int position) {
        // Send analytics of characters typed and position in dropdown selected.
        Analytics.getInstance().sendEvent("suggest_click", Integer.toString(charactersTyped),
                Integer.toString(position), 0);
    }

    @VisibleForTesting
    public interface SendOrSaveCallback {
        void initializeSendOrSave();

        void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);

        long getMessageId();

        void sendOrSaveFinished(SendOrSaveMessage message, boolean success);
    }

    private void runSendOrSaveProviderCalls(SendOrSaveMessage sendOrSaveMessage, SendOrSaveCallback callback,
            ReplyFromAccount currReplyFromAccount, ReplyFromAccount originalReplyFromAccount) {
        long messageId = callback.getMessageId();
        // If a previous draft has been saved, in an account that is different
        // than what the user wants to send from, remove the old draft, and treat this
        // as a new message
        if (originalReplyFromAccount != null
                && !currReplyFromAccount.account.uri.equals(originalReplyFromAccount.account.uri)) {
            if (messageId != UIProvider.INVALID_MESSAGE_ID) {
                ContentResolver resolver = getContentResolver();
                ContentValues values = new ContentValues();
                values.put(BaseColumns._ID, messageId);
                if (originalReplyFromAccount.account.expungeMessageUri != null) {
                    new ContentProviderTask.UpdateTask().run(resolver,
                            originalReplyFromAccount.account.expungeMessageUri, values, null, null);
                } else {
                    // TODO(mindyp) delete the conversation.
                }
                // reset messageId to 0, so a new message will be created
                messageId = UIProvider.INVALID_MESSAGE_ID;
            }
        }

        final long messageIdToSave = messageId;
        sendOrSaveMessage(callback, messageIdToSave, sendOrSaveMessage, currReplyFromAccount);

        if (!sendOrSaveMessage.mSave) {
            incrementRecipientsTimesContacted((String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO),
                    (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC),
                    (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
        }
        callback.sendOrSaveFinished(sendOrSaveMessage, true);
    }

    private void incrementRecipientsTimesContacted(final String toAddresses, final String ccAddresses,
            final String bccAddresses) {
        final List<String> recipients = Lists.newArrayList();
        addAddressesToRecipientList(recipients, toAddresses);
        addAddressesToRecipientList(recipients, ccAddresses);
        addAddressesToRecipientList(recipients, bccAddresses);
        incrementRecipientsTimesContacted(recipients);
    }

    private void addAddressesToRecipientList(final List<String> recipients, final String addressString) {
        if (recipients == null) {
            throw new IllegalArgumentException("recipientList cannot be null");
        }
        if (TextUtils.isEmpty(addressString)) {
            return;
        }
        final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString);
        for (final Rfc822Token token : tokens) {
            recipients.add(token.getAddress());
        }
    }

    /**
     * Send or Save a message.
     */
    private void sendOrSaveMessage(SendOrSaveCallback callback, final long messageIdToSave,
            final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) {
        final ContentResolver resolver = getContentResolver();
        final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID;

        final String accountMethod = sendOrSaveMessage.mSave ? UIProvider.AccountCallMethods.SAVE_MESSAGE
                : UIProvider.AccountCallMethods.SEND_MESSAGE;

        try {
            if (updateExistingMessage) {
                sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);

                callAccountSendSaveMethod(resolver, selectedAccount.account, accountMethod, sendOrSaveMessage);
            } else {
                Uri messageUri = null;
                final Bundle result = callAccountSendSaveMethod(resolver, selectedAccount.account, accountMethod,
                        sendOrSaveMessage);
                if (result != null) {
                    // If a non-null value was returned, then the provider handled the call
                    // method
                    messageUri = result.getParcelable(UIProvider.MessageColumns.URI);
                }
                if (sendOrSaveMessage.mSave && messageUri != null) {
                    final Cursor messageCursor = resolver.query(messageUri, UIProvider.MESSAGE_PROJECTION, null,
                            null, null);
                    if (messageCursor != null) {
                        try {
                            if (messageCursor.moveToFirst()) {
                                // Broadcast notification that a new message has
                                // been allocated
                                callback.notifyMessageIdAllocated(sendOrSaveMessage, new Message(messageCursor));
                            }
                        } finally {
                            messageCursor.close();
                        }
                    }
                }
            }
        } finally {
            // Close any opened file descriptors
            closeOpenedAttachmentFds(sendOrSaveMessage);
        }
    }

    private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) {
        final Bundle openedFds = sendOrSaveMessage.attachmentFds();
        if (openedFds != null) {
            final Set<String> keys = openedFds.keySet();
            for (final String key : keys) {
                final ParcelFileDescriptor fd = openedFds.getParcelable(key);
                if (fd != null) {
                    try {
                        fd.close();
                    } catch (IOException e) {
                        // Do nothing
                    }
                }
            }
        }
    }

    /**
     * Use the {@link ContentResolver#call} method to send or save the message.
     *
     * If this was successful, this method will return an non-null Bundle instance
     */
    private static Bundle callAccountSendSaveMethod(final ContentResolver resolver, final Account account,
            final String method, final SendOrSaveMessage sendOrSaveMessage) {
        // Copy all of the values from the content values to the bundle
        final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size());
        final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet();

        for (Entry<String, Object> entry : valueSet) {
            final Object entryValue = entry.getValue();
            final String key = entry.getKey();
            if (entryValue instanceof String) {
                methodExtras.putString(key, (String) entryValue);
            } else if (entryValue instanceof Boolean) {
                methodExtras.putBoolean(key, (Boolean) entryValue);
            } else if (entryValue instanceof Integer) {
                methodExtras.putInt(key, (Integer) entryValue);
            } else if (entryValue instanceof Long) {
                methodExtras.putLong(key, (Long) entryValue);
            } else {
                LogUtils.wtf(LOG_TAG, "Unexpected object type: %s", entryValue.getClass().getName());
            }
        }

        // If the SendOrSaveMessage has some opened fds, add them to the bundle
        final Bundle fdMap = sendOrSaveMessage.attachmentFds();
        if (fdMap != null) {
            methodExtras.putParcelable(UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap);
        }

        return resolver.call(account.uri, method, account.uri.toString(), methodExtras);
    }

    /**
     * Reports recipients that have been contacted in order to improve auto-complete
     * suggestions. Default behavior updates usage statistics in ContactsProvider.
     * @param recipients addresses
     */
    protected void incrementRecipientsTimesContacted(List<String> recipients) {
        final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(this);
        statsUpdater.updateWithAddress(recipients);
    }

    @VisibleForTesting
    public static class SendOrSaveMessage {
        final int mRequestId;
        final ContentValues mValues;
        final String mRefMessageId;
        @VisibleForTesting
        public final boolean mSave;
        private final Bundle mAttachmentFds;

        public SendOrSaveMessage(Context context, int requestId, ContentValues values, String refMessageId,
                List<Attachment> attachments, Bundle optionalAttachmentFds, boolean save) {
            mRequestId = requestId;
            mValues = values;
            mRefMessageId = refMessageId;
            mSave = save;

            // If the attachments are already open for us (pre-JB), then don't open them again
            if (optionalAttachmentFds != null) {
                mAttachmentFds = optionalAttachmentFds;
            } else {
                mAttachmentFds = initializeAttachmentFds(context, attachments);
            }
        }

        Bundle attachmentFds() {
            return mAttachmentFds;
        }
    }

    /**
     * Opens {@link ParcelFileDescriptor} for each of the attachments.  This method must be
     * called before the ComposeActivity finishes.
     * Note: The caller is responsible for closing these file descriptors.
     */
    private static Bundle initializeAttachmentFds(final Context context, final List<Attachment> attachments) {
        if (attachments == null || attachments.size() == 0) {
            return null;
        }

        final Bundle result = new Bundle(attachments.size());
        final ContentResolver resolver = context.getContentResolver();

        for (Attachment attachment : attachments) {
            if (attachment == null || Utils.isEmpty(attachment.contentUri)) {
                continue;
            }

            ParcelFileDescriptor fileDescriptor;
            try {
                fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r");
            } catch (FileNotFoundException e) {
                LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment");
                fileDescriptor = null;
            } catch (SecurityException e) {
                // We have encountered a security exception when attempting to open the file
                // specified by the content uri.  If the attachment has been cached, this
                // isn't a problem, as even through the original permission may have been
                // revoked, we have cached the file.  This will happen when saving/sending
                // a previously saved draft.
                // TODO(markwei): Expose whether the attachment has been cached through the
                // attachment object.  This would allow us to limit when the log is made, as
                // if the attachment has been cached, this really isn't an error
                LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment");
                // Just set the file descriptor to null, as the underlying provider needs
                // to handle the file descriptor not being set.
                fileDescriptor = null;
            }

            if (fileDescriptor != null) {
                result.putParcelable(attachment.contentUri.toString(), fileDescriptor);
            }
        }

        return result;
    }

    /**
     * Get the to recipients.
     */
    public String[] getToAddresses() {
        return getAddressesFromList(mTo);
    }

    /**
     * Get the cc recipients.
     */
    public String[] getCcAddresses() {
        return getAddressesFromList(mCc);
    }

    /**
     * Get the bcc recipients.
     */
    public String[] getBccAddresses() {
        return getAddressesFromList(mBcc);
    }

    public String[] getAddressesFromList(RecipientEditTextView list) {
        if (list == null) {
            return new String[0];
        }
        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText());
        int count = tokens.length;
        String[] result = new String[count];
        for (int i = 0; i < count; i++) {
            result[i] = tokens[i].toString();
        }
        return result;
    }

    /**
     * Check for invalid email addresses.
     * @param to String array of email addresses to check.
     * @param wrongEmailsOut Emails addresses that were invalid.
     */
    public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) {
        if (mValidator == null) {
            return;
        }
        for (final String email : to) {
            if (!mValidator.isValid(email)) {
                wrongEmailsOut.add(email);
            }
        }
    }

    public static class RecipientErrorDialogFragment extends DialogFragment {
        // Public no-args constructor needed for fragment re-instantiation
        public RecipientErrorDialogFragment() {
        }

        public static RecipientErrorDialogFragment newInstance(final String message) {
            final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment();
            final Bundle args = new Bundle(1);
            args.putString("message", message);
            frag.setArguments(args);
            return frag;
        }

        @Override
        public Dialog onCreateDialog(Bundle savedInstanceState) {
            final String message = getArguments().getString("message");
            return new AlertDialog.Builder(getActivity()).setMessage(message)
                    .setPositiveButton(R.string.ok, new Dialog.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            ((ComposeActivity) getActivity()).finishRecipientErrorDialog();
                        }
                    }).create();
        }
    }

    private void finishRecipientErrorDialog() {
        // after the user dismisses the recipient error
        // dialog we want to make sure to refocus the
        // recipient to field so they can fix the issue
        // easily
        if (mTo != null) {
            mTo.requestFocus();
        }
    }

    /**
     * Show an error because the user has entered an invalid recipient.
     */
    private void showRecipientErrorDialog(final String message) {
        final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message);
        frag.show(getFragmentManager(), "recipient error");
    }

    /**
     * Update the state of the UI based on whether or not the current draft
     * needs to be saved and the message is not empty.
     */
    public void updateSaveUi() {
        if (mSave != null) {
            mSave.setEnabled((isDraftDirty() && !isBlank()));
        }
    }

    /**
     * Returns true if the current draft is modified from the version we previously saved.
     */
    private boolean isDraftDirty() {
        synchronized (mDraftLock) {
            // The message should only be saved if:
            // It hasn't been sent AND
            // Some text has been added to the message OR
            // an attachment has been added or removed
            // AND there is actually something in the draft to save.
            return (mTextChanged || mAttachmentsChanged || mReplyFromChanged) && !isBlank();
        }
    }

    /**
     * Returns whether the "Attach from Drive" menu item should be visible.
     */
    protected boolean shouldEnableAttachFromServiceMenu(Account mAccount) {
        return false;
    }

    /**
     * Check if all fields are blank.
     * @return boolean
     */
    public boolean isBlank() {
        // Need to check for null since isBlank() can be called from onPause()
        // before findViews() is called
        if (mSubject == null || mBodyView == null || mTo == null || mCc == null || mAttachmentsView == null) {
            LogUtils.w(LOG_TAG, "null views in isBlank check");
            return true;
        }
        return mSubject.getText().length() == 0
                && (mBodyView.getText().length() == 0
                        || getSignatureStartPosition(mSignature, mBodyView.getText().toString()) == 0)
                && mTo.length() == 0 && mCc.length() == 0 && mBcc.length() == 0
                && mAttachmentsView.getAttachments().size() == 0;
    }

    @VisibleForTesting
    protected int getSignatureStartPosition(String signature, String bodyText) {
        int startPos = -1;

        if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) {
            return startPos;
        }

        int bodyLength = bodyText.length();
        int signatureLength = signature.length();
        String printableVersion = convertToPrintableSignature(signature);
        int printableLength = printableVersion.length();

        if (bodyLength >= printableLength
                && bodyText.substring(bodyLength - printableLength).equals(printableVersion)) {
            startPos = bodyLength - printableLength;
        } else if (bodyLength >= signatureLength
                && bodyText.substring(bodyLength - signatureLength).equals(signature)) {
            startPos = bodyLength - signatureLength;
        }
        return startPos;
    }

    /**
     * Allows any changes made by the user to be ignored. Called when the user
     * decides to discard a draft.
     */
    private void discardChanges() {
        mTextChanged = false;
        mAttachmentsChanged = false;
        mReplyFromChanged = false;
    }

    /**
     * @param save True to save, false to send
     * @param showToast True to show a toast once the message is sent/saved
     */
    protected void sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
            final boolean orientationChanged, final boolean autoSend) {
        if (mAccounts == null || mAccount == null) {
            Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show();
            if (autoSend) {
                finish();
            }
            return;
        }

        final String[] to, cc, bcc;
        if (orientationChanged) {
            to = cc = bcc = new String[0];
        } else {
            to = getToAddresses();
            cc = getCcAddresses();
            bcc = getBccAddresses();
        }

        final ArrayList<String> recipients = buildEmailAddressList(to);
        recipients.addAll(buildEmailAddressList(cc));
        recipients.addAll(buildEmailAddressList(bcc));

        // Don't let the user send to nobody (but it's okay to save a message
        // with no recipients)
        if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) {
            showRecipientErrorDialog(getString(R.string.recipient_needed));
            return;
        }

        List<String> wrongEmails = new ArrayList<String>();
        if (!save) {
            checkInvalidEmails(to, wrongEmails);
            checkInvalidEmails(cc, wrongEmails);
            checkInvalidEmails(bcc, wrongEmails);
        }

        // Don't let the user send an email with invalid recipients
        if (wrongEmails.size() > 0) {
            String errorText = String.format(getString(R.string.invalid_recipient), wrongEmails.get(0));
            showRecipientErrorDialog(errorText);
            return;
        }

        if (!save) {
            if (autoSend) {
                // Skip all further checks during autosend. This flow is used by Android Wear
                // and Google Now.
                sendOrSave(save, showToast);
                return;
            }

            // Show a warning before sending only if there are no attachments, body, or subject.
            if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
                boolean warnAboutEmptySubject = isSubjectEmpty();
                boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0;

                // A warning about an empty body may not be warranted when
                // forwarding mails, since a common use case is to forward
                // quoted text and not append any more text.
                boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty());

                // When we bring up a dialog warning the user about a send,
                // assume that they accept sending the message. If they do not,
                // the dialog listener is required to enable sending again.
                if (warnAboutEmptySubject) {
                    showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, showToast, recipients);
                    return;
                }

                if (warnAboutEmptyBody) {
                    showSendConfirmDialog(R.string.confirm_send_message_with_no_body, showToast, recipients);
                    return;
                }
            }
            // Ask for confirmation to send.
            if (showSendConfirmation()) {
                showSendConfirmDialog(R.string.confirm_send_message, showToast, recipients);
                return;
            }
        }

        performAdditionalSendOrSaveSanityChecks(save, showToast, recipients);
    }

    /**
     * Returns a boolean indicating whether warnings should be shown for empty
     * subject and body fields
     *
     * @return True if a warning should be shown for empty text fields
     */
    protected boolean showEmptyTextWarnings() {
        return mAttachmentsView.getAttachments().size() == 0;
    }

    /**
     * Returns a boolean indicating whether the user should confirm each send
     *
     * @return True if a warning should be on each send
     */
    protected boolean showSendConfirmation() {
        return mCachedSettings != null && mCachedSettings.confirmSend;
    }

    public static class SendConfirmDialogFragment extends DialogFragment
            implements DialogInterface.OnClickListener {

        private static final String MESSAGE_ID = "messageId";
        private static final String SHOW_TOAST = "showToast";
        private static final String RECIPIENTS = "recipients";

        private boolean mShowToast;

        private ArrayList<String> mRecipients;

        // Public no-args constructor needed for fragment re-instantiation
        public SendConfirmDialogFragment() {
        }

        public static SendConfirmDialogFragment newInstance(final int messageId, final boolean showToast,
                final ArrayList<String> recipients) {
            final SendConfirmDialogFragment frag = new SendConfirmDialogFragment();
            final Bundle args = new Bundle(3);
            args.putInt(MESSAGE_ID, messageId);
            args.putBoolean(SHOW_TOAST, showToast);
            args.putStringArrayList(RECIPIENTS, recipients);
            frag.setArguments(args);
            return frag;
        }

        @Override
        public Dialog onCreateDialog(Bundle savedInstanceState) {
            final int messageId = getArguments().getInt(MESSAGE_ID);
            mShowToast = getArguments().getBoolean(SHOW_TOAST);
            mRecipients = getArguments().getStringArrayList(RECIPIENTS);

            final int confirmTextId = (messageId == R.string.confirm_send_message) ? R.string.ok : R.string.send;

            return new AlertDialog.Builder(getActivity()).setMessage(messageId)
                    .setPositiveButton(confirmTextId, this).setNegativeButton(R.string.cancel, null).create();
        }

        @Override
        public void onClick(DialogInterface dialog, int which) {
            if (which == DialogInterface.BUTTON_POSITIVE) {
                ((ComposeActivity) getActivity()).finishSendConfirmDialog(mShowToast, mRecipients);
            }
        }
    }

    private void finishSendConfirmDialog(final boolean showToast, final ArrayList<String> recipients) {
        performAdditionalSendOrSaveSanityChecks(false /* save */, showToast, recipients);
    }

    // The list of recipients are used by the additional sendOrSave checks.
    // However, the send confirm dialog may be shown before performing
    // the additional checks. As a result, we need to plumb the recipient
    // list through the send confirm dialog so that
    // performAdditionalSendOrSaveChecks can be performed properly.
    private void showSendConfirmDialog(final int messageId, final boolean showToast,
            final ArrayList<String> recipients) {
        final DialogFragment frag = SendConfirmDialogFragment.newInstance(messageId, showToast, recipients);
        frag.show(getFragmentManager(), "send confirm");
    }

    /**
     * Returns whether the ComposeArea believes there is any text in the body of
     * the composition. TODO: When ComposeArea controls the Body as well, add
     * that here.
     */
    public boolean isBodyEmpty() {
        return !mQuotedTextView.isTextIncluded();
    }

    /**
     * Test to see if the subject is empty.
     *
     * @return boolean.
     */
    // TODO: this will likely go away when composeArea.focus() is implemented
    // after all the widget control is moved over.
    public boolean isSubjectEmpty() {
        return TextUtils.getTrimmedLength(mSubject.getText()) == 0;
    }

    @VisibleForTesting
    public String getSubject() {
        return mSubject.getText().toString();
    }

    private void sendOrSaveInternal(Context context, int requestId, ReplyFromAccount currReplyFromAccount,
            ReplyFromAccount originalReplyFromAccount, Message message, Message refMessage, CharSequence quotedText,
            SendOrSaveCallback callback, boolean save, int composeMode, ContentValues extraValues,
            Bundle optionalAttachmentFds) {
        final ContentValues values = new ContentValues();

        final String refMessageId = refMessage != null ? refMessage.uri.toString() : "";

        MessageModification.putToAddresses(values, message.getToAddresses());
        MessageModification.putCcAddresses(values, message.getCcAddresses());
        MessageModification.putBccAddresses(values, message.getBccAddresses());
        MessageModification.putCustomFromAddress(values, message.getFrom());

        MessageModification.putSubject(values, message.subject);

        // bodyHtml already have the composing spans removed.
        final String htmlBody = message.bodyHtml;
        final String textBody = message.bodyText;
        // fullbodyhtml/fullbodytext will contain the actual body plus the quoted text.
        String fullBodyHtml = htmlBody;
        String fullBodyText = textBody;
        String quotedString = null;
        final boolean hasQuotedText = !TextUtils.isEmpty(quotedText);
        if (hasQuotedText) {
            // The quoted text is HTML at this point.
            quotedString = quotedText.toString();
            fullBodyHtml = htmlBody + quotedString;
            fullBodyText = textBody + Utils.convertHtmlToPlainText(quotedString);
            MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD);
            MessageModification.putAppendRefMessageContent(values, true /* include quoted */);
        }

        // Only take refMessage into account if either one of its html/text is not empty.
        int quotedTextPos = -1;
        if (refMessage != null
                && !(TextUtils.isEmpty(refMessage.bodyHtml) && TextUtils.isEmpty(refMessage.bodyText))) {
            // The code below might need to be revisited. The quoted text position is different
            // between text/html and text/plain parts and they should be stored seperately and
            // the right version should be used in the UI. text/html should have preference
            // if both exist.  Issues like this made me file b/14256940 to make sure that we
            // properly handle the existing of both text/html and text/plain parts and to verify
            // that we are not making some assumptions that break if there is no text/html part.
            if (!TextUtils.isEmpty(refMessage.bodyHtml)) {
                MessageModification.putBodyHtml(values, fullBodyHtml);
                if (hasQuotedText) {
                    quotedTextPos = htmlBody.length() + QuotedTextView.getQuotedTextOffset(quotedString);
                }
            }
            if (!TextUtils.isEmpty(refMessage.bodyText)) {
                MessageModification.putBody(values, fullBodyText);
                if (hasQuotedText && (quotedTextPos == -1)) {
                    quotedTextPos = textBody.length();
                }
            }
            if (quotedTextPos != -1) {
                // The quoted text pos is the text/html version first and the text/plan version
                // if there is no text/html part. The reason for this is because preference
                // is given to text/html in the compose window if it exists. In the future, we
                // should calculate the index for both since the user could choose to compose
                // explicitly in text/plain.
                MessageModification.putQuoteStartPos(values, quotedTextPos);
            }
        } else {
            MessageModification.putBodyHtml(values, fullBodyHtml);
            MessageModification.putBody(values, fullBodyText);
        }
        int draftType = getDraftType(composeMode);
        MessageModification.putDraftType(values, draftType);
        MessageModification.putAttachments(values, message.getAttachments());
        if (!TextUtils.isEmpty(refMessageId)) {
            MessageModification.putRefMessageId(values, refMessageId);
        }
        if (extraValues != null) {
            values.putAll(extraValues);
        }

        SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, requestId, values, refMessageId,
                message.getAttachments(), optionalAttachmentFds, save);
        runSendOrSaveProviderCalls(sendOrSaveMessage, callback, currReplyFromAccount, originalReplyFromAccount);

        LogUtils.i(LOG_TAG,
                "[compose] SendOrSaveMessage [%s] posted (isSave: %s) - "
                        + "bodyHtml length: %d, bodyText length: %d, quoted text pos: %d, attach count: %d",
                requestId, save, message.bodyHtml.length(), message.bodyText.length(), quotedTextPos,
                message.getAttachmentCount(true));
    }

    /**
     * Removes any composing spans from the specified string.  This will create a new
     * SpannableString instance, as to not modify the behavior of the EditText view.
     */
    private static SpannableString removeComposingSpans(Spanned body) {
        final SpannableString messageBody = new SpannableString(body);
        BaseInputConnection.removeComposingSpans(messageBody);

        // Remove watcher spans while we're at it, so any off-UI thread manipulation of these
        // spans doesn't trigger unexpected side-effects. This copy is essentially 100% detached
        // from the EditText.
        //
        // (must remove SpanWatchers first to avoid triggering them as we remove other spans)
        removeSpansOfType(messageBody, SpanWatcher.class);
        removeSpansOfType(messageBody, TextWatcher.class);

        return messageBody;
    }

    private static void removeSpansOfType(SpannableString str, Class<?> cls) {
        for (Object span : str.getSpans(0, str.length(), cls)) {
            str.removeSpan(span);
        }
    }

    private static int getDraftType(int mode) {
        int draftType = -1;
        switch (mode) {
        case ComposeActivity.COMPOSE:
            draftType = DraftType.COMPOSE;
            break;
        case ComposeActivity.REPLY:
            draftType = DraftType.REPLY;
            break;
        case ComposeActivity.REPLY_ALL:
            draftType = DraftType.REPLY_ALL;
            break;
        case ComposeActivity.FORWARD:
            draftType = DraftType.FORWARD;
            break;
        }
        return draftType;
    }

    /**
     * Derived classes should override this step to perform additional checks before
     * send or save. The default implementation simply calls {@link #sendOrSave(boolean, boolean)}.
     */
    protected void performAdditionalSendOrSaveSanityChecks(final boolean save, final boolean showToast,
            ArrayList<String> recipients) {
        sendOrSave(save, showToast);
    }

    protected void sendOrSave(final boolean save, final boolean showToast) {
        // Check if user is a monkey. Monkeys can compose and hit send
        // button but are not allowed to send anything off the device.
        if (ActivityManager.isUserAMonkey()) {
            return;
        }

        final SendOrSaveCallback callback = new SendOrSaveCallback() {
            @Override
            public void initializeSendOrSave() {
                final Intent i = new Intent(ComposeActivity.this, EmptyService.class);

                // API 16+ allows for setClipData. For pre-16 we are going to open the fds
                // on the main thread.
                if (Utils.isRunningJellybeanOrLater()) {
                    // Grant the READ permission for the attachments to the service so that
                    // as long as the service stays alive we won't hit PermissionExceptions.
                    final ClipDescription desc = new ClipDescription("attachment_uris",
                            new String[] { ClipDescription.MIMETYPE_TEXT_URILIST });
                    ClipData clipData = null;
                    for (Attachment a : mAttachmentsView.getAttachments()) {
                        if (a != null && !Utils.isEmpty(a.contentUri)) {
                            final ClipData.Item uriItem = new ClipData.Item(a.contentUri);
                            if (clipData == null) {
                                clipData = new ClipData(desc, uriItem);
                            } else {
                                clipData.addItem(uriItem);
                            }
                        }
                    }
                    i.setClipData(clipData);
                    i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
                }

                synchronized (PENDING_SEND_OR_SAVE_TASKS_NUM) {
                    if (PENDING_SEND_OR_SAVE_TASKS_NUM.getAndAdd(1) == 0) {
                        // Start service so we won't be killed if this app is
                        // put in the background.
                        startService(i);
                    }
                }
                if (sTestSendOrSaveCallback != null) {
                    sTestSendOrSaveCallback.initializeSendOrSave();
                }
            }

            @Override
            public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message) {
                synchronized (mDraftLock) {
                    mDraftId = message.id;
                    mDraft = message;
                    if (sRequestMessageIdMap != null) {
                        sRequestMessageIdMap.put(sendOrSaveMessage.mRequestId, mDraftId);
                    }
                    // Cache request message map, in case the process is killed
                    saveRequestMap();
                }
                if (sTestSendOrSaveCallback != null) {
                    sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
                }
            }

            @Override
            public long getMessageId() {
                synchronized (mDraftLock) {
                    return mDraftId;
                }
            }

            @Override
            public void sendOrSaveFinished(SendOrSaveMessage message, boolean success) {
                // Update the last sent from account.
                if (mAccount != null) {
                    MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString());
                }
                if (success) {
                    // Successfully sent or saved so reset change markers
                    discardChanges();
                } else {
                    // A failure happened with saving/sending the draft
                    // TODO(pwestbro): add a better string that should be used
                    // when failing to send or save
                    Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT).show();
                }

                synchronized (PENDING_SEND_OR_SAVE_TASKS_NUM) {
                    if (PENDING_SEND_OR_SAVE_TASKS_NUM.addAndGet(-1) == 0) {
                        // Stop service so we can be killed.
                        stopService(new Intent(ComposeActivity.this, EmptyService.class));
                    }
                }
                if (sTestSendOrSaveCallback != null) {
                    sTestSendOrSaveCallback.sendOrSaveFinished(message, success);
                }
            }
        };
        setAccount(mReplyFromAccount.account);

        final Spanned body = removeComposingSpans(mBodyView.getText());
        callback.initializeSendOrSave();

        // For pre-JB we need to open the fds on the main thread
        final Bundle attachmentFds;
        if (!Utils.isRunningJellybeanOrLater()) {
            attachmentFds = initializeAttachmentFds(this, mAttachmentsView.getAttachments());
        } else {
            attachmentFds = null;
        }

        // Generate a unique message id for this request
        mRequestId = sRandom.nextInt();
        SEND_SAVE_TASK_HANDLER.post(new Runnable() {
            @Override
            public void run() {
                final Message msg = createMessage(mReplyFromAccount, mRefMessage, getMode(), body);
                sendOrSaveInternal(ComposeActivity.this, mRequestId, mReplyFromAccount, mDraftAccount, msg,
                        mRefMessage, mQuotedTextView.getQuotedTextIfIncluded(), callback, save, mComposeMode,
                        mExtraValues, attachmentFds);
            }
        });

        // Don't display the toast if the user is just changing the orientation,
        // but we still need to save the draft to the cursor because this is how we restore
        // the attachments when the configuration change completes.
        if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) {
            Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, Toast.LENGTH_LONG)
                    .show();
        }

        // Need to update variables here because the send or save completes
        // asynchronously even though the toast shows right away.
        discardChanges();
        updateSaveUi();

        // If we are sending, finish the activity
        if (!save) {
            finish();
        }
    }

    /**
     * Save the state of the request messageid map. This allows for the Gmail
     * process to be killed, but and still allow for ComposeActivity instances
     * to be recreated correctly.
     */
    private void saveRequestMap() {
        // TODO: store the request map in user preferences.
    }

    @SuppressLint("NewApi")
    private void doAttach(String type) {
        Intent i = new Intent(Intent.ACTION_GET_CONTENT);
        i.addCategory(Intent.CATEGORY_OPENABLE);
        i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
        i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
        i.setType(type);
        mAddingAttachment = true;
        startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)),
                RESULT_PICK_ATTACHMENT);
    }

    private void showCcBccViews() {
        mCcBccView.show(true, true, true);
        if (mCcBccButton != null) {
            mCcBccButton.setVisibility(View.GONE);
        }
    }

    private static String getActionString(int action) {
        final String msgType;
        switch (action) {
        case COMPOSE:
            msgType = "new_message";
            break;
        case REPLY:
            msgType = "reply";
            break;
        case REPLY_ALL:
            msgType = "reply_all";
            break;
        case FORWARD:
            msgType = "forward";
            break;
        default:
            msgType = "unknown";
            break;
        }
        return msgType;
    }

    private void logSendOrSave(boolean save) {
        if (!Analytics.isLoggable() || mAttachmentsView == null) {
            return;
        }

        final String category = (save) ? "message_save" : "message_send";
        final int attachmentCount = getAttachments().size();
        final String msgType = getActionString(mComposeMode);
        final String label;
        final long value;
        if (mComposeMode == COMPOSE) {
            label = Integer.toString(attachmentCount);
            value = attachmentCount;
        } else {
            label = null;
            value = 0;
        }
        Analytics.getInstance().sendEvent(category, msgType, label, value);
    }

    @Override
    public boolean onNavigationItemSelected(int position, long itemId) {
        int initialComposeMode = mComposeMode;
        if (position == ComposeActivity.REPLY) {
            mComposeMode = ComposeActivity.REPLY;
        } else if (position == ComposeActivity.REPLY_ALL) {
            mComposeMode = ComposeActivity.REPLY_ALL;
        } else if (position == ComposeActivity.FORWARD) {
            mComposeMode = ComposeActivity.FORWARD;
        }
        clearChangeListeners();
        if (initialComposeMode != mComposeMode) {
            resetMessageForModeChange();
            if (mRefMessage != null) {
                setFieldsFromRefMessage(mComposeMode);
            }
            boolean showCc = false;
            boolean showBcc = false;
            if (mDraft != null) {
                // Following desktop behavior, if the user has added a BCC
                // field to a draft, we show it regardless of compose mode.
                showBcc = !TextUtils.isEmpty(mDraft.getBcc());
                // Use the draft to determine what to populate.
                // If the Bcc field is showing, show the Cc field whether it is populated or not.
                showCc = showBcc || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL);
            }
            if (mRefMessage != null) {
                showCc = !TextUtils.isEmpty(mCc.getText());
                showBcc = !TextUtils.isEmpty(mBcc.getText());
            }
            mCcBccView.show(false /* animate */, showCc, showBcc);
        }
        updateHideOrShowCcBcc();
        initChangeListeners();
        return true;
    }

    @VisibleForTesting
    protected void resetMessageForModeChange() {
        // When switching between reply, reply all, forward,
        // follow the behavior of webview.
        // The contents of the following fields are cleared
        // so that they can be populated directly from the
        // ref message:
        // 1) Any recipient fields
        // 2) The subject
        mTo.setText("");
        mCc.setText("");
        mBcc.setText("");
        // Any edits to the subject are replaced with the original subject.
        mSubject.setText("");

        // Any changes to the contents of the following fields are kept:
        // 1) Body
        // 2) Attachments
        // If the user made changes to attachments, keep their changes.
        if (!mAttachmentsChanged) {
            mAttachmentsView.deleteAllAttachments();
        }
    }

    private class ComposeModeAdapter extends ArrayAdapter<String> {

        private Context mContext;
        private LayoutInflater mInflater;

        public ComposeModeAdapter(Context context) {
            super(context, R.layout.compose_mode_item, R.id.mode,
                    getResources().getStringArray(R.array.compose_modes));
            mContext = context;
        }

        private LayoutInflater getInflater() {
            if (mInflater == null) {
                mInflater = LayoutInflater.from(mContext);
            }
            return mInflater;
        }

        @Override
        public View getView(int position, View convertView, ViewGroup parent) {
            if (convertView == null) {
                convertView = getInflater().inflate(R.layout.compose_mode_display_item, null);
            }
            ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position));
            return super.getView(position, convertView, parent);
        }
    }

    @Override
    public void onRespondInline(String text) {
        appendToBody(text, false);
        mQuotedTextView.setUpperDividerVisible(false);
        mRespondedInline = true;
        if (!mBodyView.hasFocus()) {
            mBodyView.requestFocus();
        }
    }

    /**
     * Append text to the body of the message. If there is no existing body
     * text, just sets the body to text.
     *
     * @param text Text to append
     * @param withSignature True to append a signature.
     */
    public void appendToBody(CharSequence text, boolean withSignature) {
        Editable bodyText = mBodyView.getEditableText();
        if (bodyText != null && bodyText.length() > 0) {
            bodyText.append(text);
        } else {
            setBody(text, withSignature);
        }
    }

    /**
     * Set the body of the message.
     * Please try to exclusively use this method instead of calling mBodyView.setText(..) directly.
     *
     * @param text text to set
     * @param withSignature True to append a signature.
     */
    public void setBody(CharSequence text, boolean withSignature) {
        LogUtils.i(LOG_TAG, "Body populated, len: %d, sig: %b", text.length(), withSignature);
        mBodyView.setText(text);
        if (withSignature) {
            appendSignature();
        }
    }

    private void appendSignature() {
        final String newSignature = mCachedSettings != null ? mCachedSettings.signature : null;
        final int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString());
        if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) {
            mSignature = newSignature;
            if (!TextUtils.isEmpty(mSignature)) {
                // Appending a signature does not count as changing text.
                mBodyView.removeTextChangedListener(this);
                mBodyView.append(convertToPrintableSignature(mSignature));
                mBodyView.addTextChangedListener(this);
            }
            resetBodySelection();
        }
    }

    private String convertToPrintableSignature(String signature) {
        String signatureResource = getResources().getString(R.string.signature);
        if (signature == null) {
            signature = "";
        }
        return String.format(signatureResource, signature);
    }

    @Override
    public void onAccountChanged() {
        mReplyFromAccount = mFromSpinner.getCurrentAccount();
        if (!mAccount.equals(mReplyFromAccount.account)) {
            // Clear a signature, if there was one.
            mBodyView.removeTextChangedListener(this);
            String oldSignature = mSignature;
            String bodyText = getBody().getText().toString();
            if (!TextUtils.isEmpty(oldSignature)) {
                int pos = getSignatureStartPosition(oldSignature, bodyText);
                if (pos > -1) {
                    setBody(bodyText.substring(0, pos), false);
                }
            }
            setAccount(mReplyFromAccount.account);
            mBodyView.addTextChangedListener(this);
            // TODO: handle discarding attachments when switching accounts.
            // Only enable save for this draft if there is any other content
            // in the message.
            if (!isBlank()) {
                enableSave(true);
            }
            mReplyFromChanged = true;
            initRecipients();

            invalidateOptionsMenu();
        }
    }

    public void enableSave(boolean enabled) {
        if (mSave != null) {
            mSave.setEnabled(enabled);
        }
    }

    public static class DiscardConfirmDialogFragment extends DialogFragment {
        // Public no-args constructor needed for fragment re-instantiation
        public DiscardConfirmDialogFragment() {
        }

        @Override
        public Dialog onCreateDialog(Bundle savedInstanceState) {
            return new AlertDialog.Builder(getActivity()).setMessage(R.string.confirm_discard_text)
                    .setPositiveButton(R.string.discard, new DialogInterface.OnClickListener() {
                        @Override
                        public void onClick(DialogInterface dialog, int which) {
                            ((ComposeActivity) getActivity()).doDiscardWithoutConfirmation();
                        }
                    }).setNegativeButton(R.string.cancel, null).create();
        }
    }

    private void doDiscard() {
        // Only need to ask for confirmation if the draft is in a dirty state.
        if (isDraftDirty()) {
            final DialogFragment frag = new DiscardConfirmDialogFragment();
            frag.show(getFragmentManager(), "discard confirm");
        } else {
            doDiscardWithoutConfirmation();
        }
    }

    /**
     * Effectively discard the current message.
     *
     * This method is either invoked from the menu or from the dialog
     * once the user has confirmed that they want to discard the message.
     */
    private void doDiscardWithoutConfirmation() {
        synchronized (mDraftLock) {
            if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
                ContentValues values = new ContentValues();
                values.put(BaseColumns._ID, mDraftId);
                if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) {
                    getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
                } else {
                    getContentResolver().delete(mDraft.uri, null, null);
                }
                // This is not strictly necessary (since we should not try to
                // save the draft after calling this) but it ensures that if we
                // do save again for some reason we make a new draft rather than
                // trying to resave an expunged draft.
                mDraftId = UIProvider.INVALID_MESSAGE_ID;
            }
        }

        // Display a toast to let the user know
        Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show();

        // This prevents the draft from being saved in onPause().
        discardChanges();
        mPerformedSendOrDiscard = true;
        finish();
    }

    private void saveIfNeeded() {
        if (mAccount == null) {
            // We have not chosen an account yet so there's no way that we can save. This is ok,
            // though, since we are saving our state before AccountsActivity is activated. Thus, the
            // user has not interacted with us yet and there is no real state to save.
            return;
        }

        if (isDraftDirty()) {
            doSave(!mAddingAttachment /* show toast */);
        }
    }

    @Override
    public void onAttachmentDeleted() {
        mAttachmentsChanged = true;
        // If we are showing any attachments, make sure we have an upper
        // divider.
        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
        updateSaveUi();
    }

    @Override
    public void onAttachmentAdded() {
        mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0);
        mAttachmentsView.focusLastAttachment();
    }

    /**
     * This is called any time one of our text fields changes.
     */
    @Override
    public void afterTextChanged(Editable s) {
        mTextChanged = true;
        updateSaveUi();
    }

    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
        // Do nothing.
    }

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
        // Do nothing.
    }

    // There is a big difference between the text associated with an address changing
    // to add the display name or to format properly and a recipient being added or deleted.
    // Make sure we only notify of changes when a recipient has been added or deleted.
    private class RecipientTextWatcher implements TextWatcher {
        private HashMap<String, Integer> mContent = new HashMap<String, Integer>();

        private RecipientEditTextView mView;

        private TextWatcher mListener;

        public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) {
            mView = view;
            mListener = listener;
        }

        @Override
        public void afterTextChanged(Editable s) {
            if (hasChanged()) {
                mListener.afterTextChanged(s);
            }
        }

        private boolean hasChanged() {
            final ArrayList<String> currRecips = buildEmailAddressList(getAddressesFromList(mView));
            int totalCount = currRecips.size();
            int totalPrevCount = 0;
            for (Entry<String, Integer> entry : mContent.entrySet()) {
                totalPrevCount += entry.getValue();
            }
            if (totalCount != totalPrevCount) {
                return true;
            }

            for (String recip : currRecips) {
                if (!mContent.containsKey(recip)) {
                    return true;
                } else {
                    int count = mContent.get(recip) - 1;
                    if (count < 0) {
                        return true;
                    } else {
                        mContent.put(recip, count);
                    }
                }
            }
            return false;
        }

        @Override
        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
            final ArrayList<String> recips = buildEmailAddressList(getAddressesFromList(mView));
            for (String recip : recips) {
                if (!mContent.containsKey(recip)) {
                    mContent.put(recip, 1);
                } else {
                    mContent.put(recip, (mContent.get(recip)) + 1);
                }
            }
        }

        @Override
        public void onTextChanged(CharSequence s, int start, int before, int count) {
            // Do nothing.
        }
    }

    /**
     * Returns a list of email addresses from the recipients. List only contains
     * email addresses strips additional info like the recipient's name.
     */
    private static ArrayList<String> buildEmailAddressList(String[] recips) {
        // Tokenize them all and put them in the list.
        final ArrayList<String> recipAddresses = Lists.newArrayListWithCapacity(recips.length);
        for (int i = 0; i < recips.length; i++) {
            recipAddresses.add(Rfc822Tokenizer.tokenize(recips[i])[0].getAddress());
        }
        return recipAddresses;
    }

    public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) {
        if (sTestSendOrSaveCallback != null && testCallback != null) {
            throw new IllegalStateException("Attempting to register more than one test callback");
        }
        sTestSendOrSaveCallback = testCallback;
    }

    @VisibleForTesting
    protected ArrayList<Attachment> getAttachments() {
        return mAttachmentsView.getAttachments();
    }

    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        switch (id) {
        case INIT_DRAFT_USING_REFERENCE_MESSAGE:
            return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, null, null);
        case REFERENCE_MESSAGE_LOADER:
            return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, null, null);
        case LOADER_ACCOUNT_CURSOR:
            return new CursorLoader(this, MailAppProvider.getAccountsUri(), UIProvider.ACCOUNTS_PROJECTION, null,
                    null, null);
        }
        return null;
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
        int id = loader.getId();
        switch (id) {
        case INIT_DRAFT_USING_REFERENCE_MESSAGE:
            if (data != null && data.moveToFirst()) {
                mRefMessage = new Message(data);
                Intent intent = getIntent();
                initFromRefMessage(mComposeMode);
                finishSetup(mComposeMode, intent, null);
                if (mComposeMode != FORWARD) {
                    String to = intent.getStringExtra(EXTRA_TO);
                    if (!TextUtils.isEmpty(to)) {
                        mRefMessage.setTo(null);
                        mRefMessage.setFrom(null);
                        clearChangeListeners();
                        mTo.append(to);
                        initChangeListeners();
                    }
                }
            } else {
                finish();
            }
            break;
        case REFERENCE_MESSAGE_LOADER:
            // Only populate mRefMessage and leave other fields untouched.
            if (data != null && data.moveToFirst()) {
                mRefMessage = new Message(data);
            }
            finishSetup(mComposeMode, getIntent(), mInnerSavedState);
            break;
        case LOADER_ACCOUNT_CURSOR:
            if (data != null && data.moveToFirst()) {
                // there are accounts now!
                Account account;
                final ArrayList<Account> accounts = new ArrayList<Account>();
                final ArrayList<Account> initializedAccounts = new ArrayList<Account>();
                do {
                    account = Account.builder().buildFrom(data);
                    if (account.isAccountReady()) {
                        initializedAccounts.add(account);
                    }
                    accounts.add(account);
                } while (data.moveToNext());
                if (initializedAccounts.size() > 0) {
                    findViewById(R.id.wait).setVisibility(View.GONE);
                    getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR);
                    findViewById(R.id.compose).setVisibility(View.VISIBLE);
                    mAccounts = initializedAccounts.toArray(new Account[initializedAccounts.size()]);

                    finishCreate();
                    invalidateOptionsMenu();
                } else {
                    // Show "waiting"
                    account = accounts.size() > 0 ? accounts.get(0) : null;
                    showWaitFragment(account);
                }
            }
            break;
        }
    }

    private void showWaitFragment(Account account) {
        WaitFragment fragment = getWaitFragment();
        if (fragment != null) {
            fragment.updateAccount(account);
        } else {
            findViewById(R.id.wait).setVisibility(View.VISIBLE);
            replaceFragment(WaitFragment.newInstance(account, false /* expectingMessages */),
                    FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT);
        }
    }

    private WaitFragment getWaitFragment() {
        return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT);
    }

    private int replaceFragment(Fragment fragment, int transition, String tag) {
        FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction();
        fragmentTransaction.setTransition(transition);
        fragmentTransaction.replace(R.id.wait, fragment, tag);
        final int transactionId = fragmentTransaction.commitAllowingStateLoss();
        return transactionId;
    }

    @Override
    public void onLoaderReset(Loader<Cursor> arg0) {
        // Do nothing.
    }

    /**
     * Background task to convert the message's html to Spanned.
     */
    private class HtmlToSpannedTask extends AsyncTask<String, Void, Spanned> {

        @Override
        protected Spanned doInBackground(String... input) {
            return HtmlUtils.htmlToSpan(input[0], mSpanConverterFactory);
        }

        @Override
        protected void onPostExecute(Spanned spanned) {
            mBodyView.removeTextChangedListener(ComposeActivity.this);
            setBody(spanned, false);
            mTextChanged = false;
            mBodyView.addTextChangedListener(ComposeActivity.this);
        }
    }

    @Override
    public void onSupportActionModeStarted(ActionMode mode) {
        super.onSupportActionModeStarted(mode);
        ViewUtils.setStatusBarColor(this, R.color.action_mode_statusbar_color);
    }

    @Override
    public void onSupportActionModeFinished(ActionMode mode) {
        super.onSupportActionModeFinished(mode);
        ViewUtils.setStatusBarColor(this, R.color.primary_dark_color);
    }
}