blob: 9866f4f7070b3a3b267891f7429f25d4a9a76ca4 [file] [log] [blame]
/**
* 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.app.ActionBar;
import android.app.ActivityManager;
import android.app.AlertDialog;
import android.app.Dialog;
import android.app.ActionBar.OnNavigationListener;
import android.app.Activity;
import android.app.LoaderManager.LoaderCallbacks;
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.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.provider.BaseColumns;
import android.text.Editable;
import android.text.Html;
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.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.View.OnClickListener;
import android.view.inputmethod.BaseInputConnection;
import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import android.widget.Toast;
import com.android.common.Rfc822Validator;
import com.android.mail.compose.AttachmentsView.AttachmentDeletedListener;
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.Address;
import com.android.mail.providers.Attachment;
import com.android.mail.providers.Message;
import com.android.mail.providers.MessageModification;
import com.android.mail.providers.Settings;
import com.android.mail.providers.UIProvider;
import com.android.mail.providers.UIProvider.MessageColumns;
import com.android.mail.R;
import com.android.mail.utils.LogUtils;
import com.android.mail.utils.Utils;
import com.android.ex.chips.RecipientEditTextView;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.Map.Entry;
import java.util.concurrent.ConcurrentHashMap;
public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener,
RespondInlineListener, DialogInterface.OnClickListener, TextWatcher,
AttachmentDeletedListener, OnAccountChangedListener, LoaderCallbacks<Cursor> {
// Identifiers for which type of composition this is
static final int COMPOSE = -1; // also used for editing a draft
static final int REPLY = 0;
static final int REPLY_ALL = 1;
static final int FORWARD = 2;
// Integer extra holding one of the above compose action
private static final String EXTRA_ACTION = "action";
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 ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null;
// Key used to store the above map
private static final String CACHED_MESSAGE_REQUEST_IDS_KEY = "cache-message-request-ids";
/**
* Notifies the {@code Activity} that the caller is an Email
* {@code Activity}, so that the back behavior may be modified accordingly.
*
* @see #onAppUpPressed
*/
private static final String EXTRA_FROM_EMAIL_TASK = "fromemail";
// 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";
private static final String END_TOKEN = ", ";
private static final String LOG_TAG = new LogUtils().getLogTag();
// Request numbers for activities we start
private static final int RESULT_PICK_ATTACHMENT = 1;
private static final int RESULT_CREATE_ACCOUNT = 2;
private static final int ACCOUNT_SETTINGS_LOADER = 0;
/**
* A single thread for running tasks in the background.
*/
private Handler mSendSaveTaskHandler = null;
private RecipientEditTextView mTo;
private RecipientEditTextView mCc;
private RecipientEditTextView mBcc;
private Button mCcBccButton;
private CcBccView mCcBccView;
private AttachmentsView mAttachmentsView;
private Account mAccount;
private Settings mCachedSettings;
private Rfc822Validator mValidator;
private TextView mSubject;
private ComposeModeAdapter mComposeModeAdapter;
private int mComposeMode = -1;
private boolean mForward;
private String mRecipient;
private QuotedTextView mQuotedTextView;
private TextView mBodyView;
private View mFromStatic;
private TextView mFromStaticText;
private View mFromSpinnerWrapper;
private FromAddressSpinner mFromSpinner;
private boolean mAddingAttachment;
private boolean mAttachmentsChanged;
private boolean mTextChanged;
private boolean mReplyFromChanged;
private MenuItem mSave;
private MenuItem mSend;
private String mRefMessageId;
private AlertDialog mRecipientErrorDialog;
private AlertDialog mSendConfirmDialog;
private Message mRefMessage;
private long mDraftId = UIProvider.INVALID_MESSAGE_ID;
private Message mDraft;
private Object mDraftLock = new Object();
private ImageView mAttachmentsButton;
/**
* Can be called from a non-UI thread.
*/
public static void editDraft(Context launcher, Account account, Message message) {
}
/**
* Can be called from a non-UI thread.
*/
public static void compose(Context launcher, Account account) {
launch(launcher, account, null, COMPOSE);
}
/**
* Can be called from a non-UI thread.
*/
public static void reply(Context launcher, Account account, Message message) {
launch(launcher, account, message, REPLY);
}
/**
* Can be called from a non-UI thread.
*/
public static void replyAll(Context launcher, Account account, Message message) {
launch(launcher, account, message, REPLY_ALL);
}
/**
* Can be called from a non-UI thread.
*/
public static void forward(Context launcher, Account account, Message message) {
launch(launcher, account, message, FORWARD);
}
private static void launch(Context launcher, Account account, Message message, int action) {
Intent intent = new Intent(launcher, ComposeActivity.class);
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, message);
launcher.startActivity(intent);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.compose);
findViews();
Intent intent = getIntent();
setAccount((Account)intent.getParcelableExtra(Utils.EXTRA_ACCOUNT));
if (mAccount == null) {
return;
}
int action = intent.getIntExtra(EXTRA_ACTION, COMPOSE);
mRefMessage = (Message) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE);
if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) {
initFromRefMessage(action, mAccount.name);
} else {
mQuotedTextView.setVisibility(View.GONE);
}
initRecipients();
initActionBar(action);
initFromSpinner();
initChangeListeners();
}
@Override
protected void onResume() {
super.onResume();
// Update the from spinner as other accounts
// may now be available.
if (mFromSpinner != null && mAccount != null) {
mFromSpinner.asyncInitFromSpinner();
}
}
@Override
protected void onPause() {
super.onPause();
if (mSendConfirmDialog != null) {
mSendConfirmDialog.dismiss();
}
if (mRecipientErrorDialog != null) {
mRecipientErrorDialog.dismiss();
}
saveIfNeeded();
}
@Override
protected final void onActivityResult(int request, int result, Intent data) {
mAddingAttachment = false;
if (result == RESULT_OK && request == RESULT_PICK_ATTACHMENT) {
addAttachmentAndUpdateView(data);
}
}
@Override
public final void onSaveInstanceState(Bundle state) {
super.onSaveInstanceState(state);
// onSaveInstanceState is only called if the user might come back to this activity so it is
// not an ideal location to save the draft. However, if we have never saved the draft before
// we have to save it here in order to have an id to save in the bundle.
saveIfNeededOnOrientationChanged();
}
@VisibleForTesting
void setAccount(Account account) {
mAccount = account;
getLoaderManager().restartLoader(ACCOUNT_SETTINGS_LOADER, null, this);
}
private void initFromSpinner() {
mFromSpinner.setCurrentAccount(mAccount);
mFromSpinner.asyncInitFromSpinner();
boolean showSpinner = 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(
showSpinner ? View.GONE : View.VISIBLE);
mFromStaticText.setText(mAccount.name);
mFromSpinnerWrapper.setVisibility(
showSpinner ? View.VISIBLE : View.GONE);
}
private void findViews() {
mCcBccButton = (Button) 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);
mAttachmentsButton = (ImageView) findViewById(R.id.add_attachment);
if (mAttachmentsButton != null) {
mAttachmentsButton.setOnClickListener(this);
}
mTo = (RecipientEditTextView) findViewById(R.id.to);
mCc = (RecipientEditTextView) findViewById(R.id.cc);
mBcc = (RecipientEditTextView) findViewById(R.id.bcc);
// 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);
mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view);
mQuotedTextView.setRespondInlineListener(this);
mBodyView = (TextView) findViewById(R.id.body);
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);
}
// 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() {
mSubject.addTextChangedListener(this);
mBodyView.addTextChangedListener(this);
mTo.addTextChangedListener(new RecipientTextWatcher(mTo, this));
mCc.addTextChangedListener(new RecipientTextWatcher(mCc, this));
mBcc.addTextChangedListener(new RecipientTextWatcher(mBcc, this));
mFromSpinner.setOnAccountChangedListener(this);
mAttachmentsView.setAttachmentChangesListener(this);
}
private void initActionBar(int action) {
mComposeMode = action;
ActionBar actionBar = getActionBar();
if (action == ComposeActivity.COMPOSE) {
actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD);
actionBar.setTitle(R.string.compose);
} else {
actionBar.setTitle(null);
if (mComposeModeAdapter == null) {
mComposeModeAdapter = new ComposeModeAdapter(this);
}
actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST);
actionBar.setListNavigationCallbacks(mComposeModeAdapter, this);
switch (action) {
case ComposeActivity.REPLY:
actionBar.setSelectedNavigationItem(0);
break;
case ComposeActivity.REPLY_ALL:
actionBar.setSelectedNavigationItem(1);
break;
case ComposeActivity.FORWARD:
actionBar.setSelectedNavigationItem(2);
break;
}
}
}
private void initFromRefMessage(int action, String recipientAddress) {
if (mRefMessage != null) {
mRefMessageId = mRefMessage.refMessageId;
setSubject(mRefMessage, action);
// Setup recipients
if (action == FORWARD) {
mForward = true;
}
initRecipientsFromRefMessage(recipientAddress, mRefMessage, action);
initBodyFromRefMessage(mRefMessage, action);
if (action == ComposeActivity.FORWARD || mAttachmentsChanged) {
initAttachments(mRefMessage);
}
updateHideOrShowCcBcc();
}
}
private void initAttachments(Message refMessage) {
mAttachmentsView.addAttachments(mAccount, refMessage);
}
private void initBodyFromRefMessage(Message refMessage, int action) {
if (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 = !TextUtils.isEmpty(mCc.getText());
boolean bccVisible = !TextUtils.isEmpty(mBcc.getText());
if (ccVisible || bccVisible) {
mCcBccView.show(false, ccVisible, bccVisible);
}
if (mCcBccButton != null) {
if (!mCc.isShown() || !mBcc.isShown()) {
mCcBccButton.setVisibility(View.VISIBLE);
mCcBccButton.setText(getString(!mCc.isShown() ? R.string.add_cc_label
: R.string.add_bcc_label));
} else {
mCcBccButton.setVisibility(View.GONE);
}
}
}
/**
* Add attachment and update the compose area appropriately.
* @param data
*/
public void addAttachmentAndUpdateView(Intent data) {
Uri uri = data != null ? data.getData() : null;
try {
long size = mAttachmentsView.addAttachment(mAccount, uri, false /* doSave */,
true /* local file */);
if (size > 0) {
mAttachmentsChanged = true;
updateSaveUi();
}
} catch (AttachmentFailureException e) {
// A toast has already been shown to the user, no need to do
// anything.
LogUtils.e(LOG_TAG, e, "Error adding attachment");
}
}
void initRecipientsFromRefMessage(String recipientAddress, Message refMessage,
int action) {
// Don't populate the address if this is a forward.
if (action == ComposeActivity.FORWARD) {
return;
}
initReplyRecipients(mAccount.name, refMessage, action);
}
@VisibleForTesting
void initReplyRecipients(String account, Message refMessage, int action) {
// This is the email address of the current user, i.e. the one composing
// the reply.
final String accountEmail = Address.getEmailAddress(account).getAddress();
String fromAddress = refMessage.from;
String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage.to);
String replytoAddress = refMessage.replyTo;
final Collection<String> toAddresses;
// 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(account, accountEmail, fromAddress, replytoAddress,
new String[0]);
addToAddresses(toAddresses);
} else if (action == ComposeActivity.REPLY_ALL) {
final Set<String> ccAddresses = Sets.newHashSet();
toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress,
new String[0]);
addToAddresses(toAddresses);
addRecipients(accountEmail, ccAddresses, sentToAddresses);
addRecipients(accountEmail, ccAddresses,
Utils.splitCommaSeparatedString(refMessage.cc));
addCcAddresses(ccAddresses, toAddresses);
}
}
private void addToAddresses(Collection<String> addresses) {
addAddressesToList(addresses, mTo);
}
private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) {
addCcAddressesToList(tokenizeAddressList(addresses), tokenizeAddressList(toAddresses),
mCc);
}
@VisibleForTesting
protected void addCcAddressesToList(List<Rfc822Token[]> addresses,
List<Rfc822Token[]> compareToList, RecipientEditTextView list) {
String address;
HashSet<String> compareTo = convertToHashSet(compareToList);
for (Rfc822Token[] tokens : addresses) {
for (int i = 0; i < tokens.length; i++) {
address = tokens[i].toString();
// Check if this is a duplicate:
if (!compareTo.contains(tokens[i].getAddress())) {
// Get the address here
list.append(address + END_TOKEN);
}
}
}
}
private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) {
HashSet<String> hash = new HashSet<String>();
for (Rfc822Token[] tokens : list) {
for (int i = 0; i < tokens.length; i++) {
hash.add(tokens[i].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 void addAddressToList(String address, RecipientEditTextView list) {
if (address == null || list == null)
return;
Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address);
for (int i = 0; i < tokens.length; i++) {
list.append(tokens[i] + END_TOKEN);
}
}
@VisibleForTesting
protected Collection<String> initToRecipients(String account, String accountEmail,
String senderAddress, String replyToAddress, 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();
if (!TextUtils.isEmpty(replyToAddress)) {
toAddresses.add(replyToAddress);
} else {
toAddresses.add(senderAddress);
}
return toAddresses;
}
private static void addRecipients(String account, Set<String> recipients, String[] addresses) {
for (String email : addresses) {
// Do not add this account, or any of the custom froms, to the list
// of recipients.
final String recipientAddress = Address.getEmailAddress(email).getAddress();
if (!account.equalsIgnoreCase(recipientAddress)) {
recipients.add(email.replace("\"\"", ""));
}
}
}
private void setSubject(Message refMessage, int action) {
String subject = refMessage.subject;
String prefix;
String correctedSubject = null;
if (action == ComposeActivity.COMPOSE) {
prefix = "";
} else if (action == ComposeActivity.FORWARD) {
prefix = getString(R.string.forward_subject_label);
} else {
prefix = getString(R.string.reply_subject_label);
}
// Don't duplicate the prefix
if (subject.toLowerCase().startsWith(prefix.toLowerCase())) {
correctedSubject = subject;
} else {
correctedSubject = String
.format(getString(R.string.formatted_subject), prefix, subject);
}
mSubject.setText(correctedSubject);
}
private void initRecipients() {
setupRecipients(mTo);
setupRecipients(mCc);
setupRecipients(mBcc);
}
private void setupRecipients(RecipientEditTextView view) {
view.setAdapter(new RecipientAdapter(this, mAccount));
view.setTokenizer(new Rfc822Tokenizer());
if (mValidator == null) {
final String accountName = mAccount.name;
int offset = accountName.indexOf("@") + 1;
String account = accountName;
if (offset > -1) {
account = account.substring(accountName.indexOf("@") + 1);
}
mValidator = new Rfc822Validator(account);
}
view.setValidator(mValidator);
}
@Override
public void onClick(View v) {
int id = v.getId();
switch (id) {
case R.id.add_cc_bcc:
// Verify that cc/ bcc aren't showing.
// Animate in cc/bcc.
showCcBccViews();
break;
case R.id.add_attachment:
doAttach();
break;
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
MenuInflater inflater = getMenuInflater();
inflater.inflate(R.menu.compose_menu, menu);
mSave = menu.findItem(R.id.save);
mSend = menu.findItem(R.id.send);
return true;
}
@Override
public boolean onPrepareOptionsMenu(Menu menu) {
MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc);
if (ccBcc != null && mCc != null) {
// Its possible there is a menu item OR a button.
boolean ccFieldVisible = mCc.isShown();
boolean bccFieldVisible = mBcc.isShown();
if (!ccFieldVisible || !bccFieldVisible) {
ccBcc.setVisible(true);
ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label
: R.string.add_bcc_label));
} else {
ccBcc.setVisible(false);
}
}
if (mSave != null) {
mSave.setEnabled(shouldSave());
}
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
int id = item.getItemId();
boolean handled = true;
switch (id) {
case R.id.add_attachment:
doAttach();
break;
case R.id.add_cc_bcc:
showCcBccViews();
break;
case R.id.save:
doSave(true, false);
break;
case R.id.send:
doSend();
break;
case R.id.discard:
doDiscard();
break;
case R.id.settings:
Utils.showSettings(this, mAccount);
break;
default:
handled = false;
break;
}
return !handled ? super.onOptionsItemSelected(item) : handled;
}
private void doSend() {
sendOrSaveWithSanityChecks(false, true, false);
}
private void doSave(boolean showToast, boolean resetIME) {
sendOrSaveWithSanityChecks(true, showToast, false);
if (resetIME) {
// Clear the IME composing suggestions from the body.
BaseInputConnection.removeComposingSpans(mBodyView.getEditableText());
}
}
/*package*/ interface SendOrSaveCallback {
public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask);
public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message);
public Message getMessage();
public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success);
}
/*package*/ static class SendOrSaveTask implements Runnable {
private final Context mContext;
private final SendOrSaveCallback mSendOrSaveCallback;
@VisibleForTesting
final SendOrSaveMessage mSendOrSaveMessage;
public SendOrSaveTask(Context context, SendOrSaveMessage message,
SendOrSaveCallback callback) {
mContext = context;
mSendOrSaveCallback = callback;
mSendOrSaveMessage = message;
}
@Override
public void run() {
final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage;
final Account selectedAccount = sendOrSaveMessage.mSelectedAccount;
Message message = mSendOrSaveCallback.getMessage();
long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID;
// 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 (!selectedAccount.equals(sendOrSaveMessage.mAccount)) {
if (messageId != UIProvider.INVALID_MESSAGE_ID) {
ContentResolver resolver = mContext.getContentResolver();
ContentValues values = new ContentValues();
values.put(BaseColumns._ID, messageId);
if (selectedAccount.expungeMessageUri != null) {
resolver.update(selectedAccount.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;
if (messageIdToSave != UIProvider.INVALID_MESSAGE_ID) {
sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave);
mContext.getContentResolver().update(
Uri.parse(sendOrSaveMessage.mSave ? message.saveUri : message.sendUri),
sendOrSaveMessage.mValues, null, null);
} else {
ContentResolver resolver = mContext.getContentResolver();
Uri messageUri = resolver.insert(
sendOrSaveMessage.mSave ? selectedAccount.saveDraftUri
: selectedAccount.sendMessageUri, sendOrSaveMessage.mValues);
if (sendOrSaveMessage.mSave && messageUri != null) {
Cursor messageCursor = resolver.query(messageUri,
UIProvider.MESSAGE_PROJECTION, null, null, null);
if (messageCursor != null && messageCursor.moveToFirst()) {
// Broadcast notification that a new message has
// been allocated
mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage,
new Message(messageCursor));
}
}
}
if (!sendOrSaveMessage.mSave) {
UIProvider.incrementRecipientsTimesContacted(mContext,
(String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO));
UIProvider.incrementRecipientsTimesContacted(mContext,
(String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC));
UIProvider.incrementRecipientsTimesContacted(mContext,
(String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC));
}
mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true);
}
}
// Array of the outstanding send or save tasks. Access is synchronized
// with the object itself
/* package for testing */
ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList();
private int mRequestId;
private String mSignature;
/*package*/ static class SendOrSaveMessage {
final Account mAccount;
final Account mSelectedAccount;
final ContentValues mValues;
final String mRefMessageId;
final boolean mSave;
final int mRequestId;
public SendOrSaveMessage(Account account, Account selectedAccount, ContentValues values,
String refMessageId, boolean save) {
mAccount = account;
mSelectedAccount = selectedAccount;
mValues = values;
mRefMessageId = refMessageId;
mSave = save;
mRequestId = mValues.hashCode() ^ hashCode();
}
int requestId() {
return mRequestId;
}
}
/**
* 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(String[] to, List<String> wrongEmailsOut) {
for (String email : to) {
if (!mValidator.isValid(email)) {
wrongEmailsOut.add(email);
}
}
}
/**
* Show an error because the user has entered an invalid recipient.
* @param message
*/
public void showRecipientErrorDialog(String message) {
// Only 1 invalid recipients error dialog should be allowed up at a
// time.
if (mRecipientErrorDialog != null) {
mRecipientErrorDialog.dismiss();
}
mRecipientErrorDialog = new AlertDialog.Builder(this).setMessage(message).setTitle(
R.string.recipient_error_dialog_title)
.setIconAttribute(android.R.attr.alertDialogIcon)
.setCancelable(false)
.setPositiveButton(
R.string.ok, new Dialog.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
// 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();
}
mRecipientErrorDialog = null;
}
}).show();
}
/**
* 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((shouldSave() && !isBlank()));
}
}
/**
* Returns true if we need to save the current draft.
*/
private boolean shouldSave() {
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
return (mTextChanged || mAttachmentsChanged ||
(mReplyFromChanged && !isBlank()));
}
}
/**
* Check if all fields are blank.
* @return boolean
*/
public boolean isBlank() {
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 body
* @param save
* @param showToast
* @return Whether the send or save succeeded.
*/
protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast,
final boolean orientationChanged) {
String[] to, cc, bcc;
Editable body = mBodyView.getEditableText();
if (orientationChanged) {
to = cc = bcc = new String[0];
} else {
to = getToAddresses();
cc = getCcAddresses();
bcc = getBccAddresses();
}
// 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 false;
}
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 false;
}
DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int which) {
sendOrSave(mBodyView.getEditableText(), save, showToast, orientationChanged);
}
};
// Show a warning before sending only if there are no attachments.
if (!save) {
if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) {
boolean warnAboutEmptySubject = isSubjectEmpty();
boolean emptyBody = TextUtils.getTrimmedLength(body) == 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, listener);
return true;
}
if (warnAboutEmptyBody) {
showSendConfirmDialog(R.string.confirm_send_message_with_no_body, listener);
return true;
}
}
// Ask for confirmation to send (if always required)
if (showSendConfirmation()) {
showSendConfirmDialog(R.string.confirm_send_message, listener);
return true;
}
}
sendOrSave(body, save, showToast, false);
return true;
}
/**
* 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 : false;
}
private void showSendConfirmDialog(int messageId, DialogInterface.OnClickListener listener) {
if (mSendConfirmDialog != null) {
mSendConfirmDialog.dismiss();
mSendConfirmDialog = null;
}
mSendConfirmDialog = new AlertDialog.Builder(this).setMessage(messageId)
.setTitle(R.string.confirm_send_title)
.setIconAttribute(android.R.attr.alertDialogIcon)
.setPositiveButton(R.string.send, listener)
.setNegativeButton(R.string.cancel, this).setCancelable(false).show();
}
/**
* 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;
}
/* package */
static int sendOrSaveInternal(Context context, final Account account,
final Account selectedAccount, String fromAddress, final Spanned body,
final String[] to, final String[] cc, final String[] bcc, final String subject,
final CharSequence quotedText, final List<Attachment> attachments,
final String refMessageId, SendOrSaveCallback callback, Handler handler, boolean save,
boolean forward) {
ContentValues values = new ContentValues();
MessageModification.putToAddresses(values, to);
MessageModification.putCcAddresses(values, cc);
MessageModification.putBccAddresses(values, bcc);
MessageModification.putSubject(values, subject);
String htmlBody = Html.toHtml(body);
boolean includeQuotedText = !TextUtils.isEmpty(quotedText);
StringBuilder fullBody = new StringBuilder(htmlBody);
if (includeQuotedText) {
if (forward) {
// forwarded messages get full text in HTML from client
fullBody.append(quotedText);
MessageModification.putForward(values, forward);
} else {
// replies get full quoted text from server - HTMl gets
// converted to text for now
final String text = quotedText.toString();
if (QuotedTextView.containsQuotedText(text)) {
int pos = QuotedTextView.getQuotedTextOffset(text);
fullBody.append(text.substring(0, pos));
MessageModification.putForward(values, forward);
MessageModification.putAppendRefMessageContent(values, includeQuotedText);
} else {
LogUtils.w(LOG_TAG, "Couldn't find quoted text");
// This shouldn't happen, but just use what we have,
// and don't do server-side expansion
fullBody.append(text);
}
}
}
MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString());
MessageModification.putBodyHtml(values, fullBody.toString());
MessageModification.putAttachments(values, attachments);
SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(account, selectedAccount,
values, refMessageId, save);
SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback);
callback.initializeSendOrSave(sendOrSaveTask);
// Do the send/save action on the specified handler to avoid possible
// ANRs
handler.post(sendOrSaveTask);
return sendOrSaveMessage.requestId();
}
private void sendOrSave(Spanned body, boolean save, boolean showToast,
boolean orientationChanged) {
// Check if user is a monkey. Monkeys can compose and hit send
// button but are not allowed to send anything off the device.
if (!save && ActivityManager.isUserAMonkey()) {
return;
}
String[] to, cc, bcc;
if (orientationChanged) {
to = cc = bcc = new String[0];
} else {
to = getToAddresses();
cc = getCcAddresses();
bcc = getBccAddresses();
}
SendOrSaveCallback callback = new SendOrSaveCallback() {
private int mRestoredRequestId;
public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) {
synchronized (mActiveTasks) {
int numTasks = mActiveTasks.size();
if (numTasks == 0) {
// Start service so we won't be killed if this app is
// put in the background.
startService(new Intent(ComposeActivity.this, EmptyService.class));
}
mActiveTasks.add(sendOrSaveTask);
}
if (sTestSendOrSaveCallback != null) {
sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask);
}
}
public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage,
Message message) {
synchronized (mDraftLock) {
mDraftId = message.id;
mDraft = message;
if (sRequestMessageIdMap != null) {
sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId);
}
// Cache request message map, in case the process is killed
saveRequestMap();
}
if (sTestSendOrSaveCallback != null) {
sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message);
}
}
public Message getMessage() {
synchronized (mDraftLock) {
return mDraft;
}
}
public void sendOrSaveFinished(SendOrSaveTask task, boolean success) {
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();
}
int numTasks;
synchronized (mActiveTasks) {
// Remove the task from the list of active tasks
mActiveTasks.remove(task);
numTasks = mActiveTasks.size();
}
if (numTasks == 0) {
// Stop service so we can be killed.
stopService(new Intent(ComposeActivity.this, EmptyService.class));
}
if (sTestSendOrSaveCallback != null) {
sTestSendOrSaveCallback.sendOrSaveFinished(task, success);
}
}
};
// Get the selected account if the from spinner has been setup.
Account selectedAccount = mAccount;
String fromAddress = selectedAccount.name;
if (selectedAccount == null || fromAddress == null) {
// We don't have either the selected account or from address,
// use mAccount.
selectedAccount = mAccount;
fromAddress = mAccount.name;
}
if (mSendSaveTaskHandler == null) {
HandlerThread handlerThread = new HandlerThread("Send Message Task Thread");
handlerThread.start();
mSendSaveTaskHandler = new Handler(handlerThread.getLooper());
}
mRequestId = sendOrSaveInternal(this, mAccount, selectedAccount, fromAddress, body, to, cc,
bcc, mSubject.getText().toString(), mQuotedTextView.getQuotedText(),
mAttachmentsView.getAttachments(), mRefMessageId, callback, mSendSaveTaskHandler,
save, mForward);
if (mRecipient != null && mRecipient.equals(mAccount.name)) {
mRecipient = selectedAccount.name;
}
mAccount = selectedAccount;
// 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.
}
public void doAttach() {
Intent i = new Intent(Intent.ACTION_GET_CONTENT);
i.addCategory(Intent.CATEGORY_OPENABLE);
if (android.provider.Settings.System.getInt(getContentResolver(),
UIProvider.getAttachmentTypeSetting(), 0) != 0) {
i.setType("*/*");
} else {
i.setType("image/*");
}
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);
}
}
@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;
}
if (initialComposeMode != mComposeMode) {
resetMessageForModeChange();
initFromRefMessage(mComposeMode, mAccount.name);
}
return true;
}
private 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 LayoutInflater mInflater;
public ComposeModeAdapter(Context context) {
super(context, R.layout.compose_mode_item, R.id.mode, getResources()
.getStringArray(R.array.compose_modes));
}
private LayoutInflater getInflater() {
if (mInflater == null) {
mInflater = LayoutInflater.from(getContext());
}
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);
}
/**
* Append text to the body of the message. If there is no existing body
* text, just sets the body to text.
*
* @param text
* @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.
*
* @param text
* @param withSignature True to append a signature.
*/
public void setBody(CharSequence text, boolean withSignature) {
mBodyView.setText(text);
if (withSignature) {
appendSignature();
}
}
private void appendSignature() {
mSignature = mCachedSettings != null ? mCachedSettings.signature : null;
if (!TextUtils.isEmpty(mSignature)) {
// Appending a signature does not count as changing text.
mBodyView.removeTextChangedListener(this);
mBodyView.append(convertToPrintableSignature(mSignature));
mBodyView.addTextChangedListener(this);
}
}
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() {
Account selectedAccountInfo = mFromSpinner.getCurrentAccount();
if (!mAccount.equals(selectedAccountInfo)) {
mAccount = selectedAccountInfo;
mCachedSettings = null;
getLoaderManager().restartLoader(ACCOUNT_SETTINGS_LOADER, null, 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();
}
}
public void enableSave(boolean enabled) {
if (mSave != null) {
mSave.setEnabled(enabled);
}
}
public void enableSend(boolean enabled) {
if (mSend != null) {
mSend.setEnabled(enabled);
}
}
/**
* Handles button clicks from any error dialogs dealing with sending
* a message.
*/
@Override
public void onClick(DialogInterface dialog, int which) {
switch (which) {
case DialogInterface.BUTTON_POSITIVE: {
doDiscardWithoutConfirmation(true /* show toast */ );
break;
}
case DialogInterface.BUTTON_NEGATIVE: {
// If the user cancels the send, re-enable the send button.
enableSend(true);
break;
}
}
}
private void doDiscard() {
new AlertDialog.Builder(this).setMessage(R.string.confirm_discard_text)
.setPositiveButton(R.string.ok, this)
.setNegativeButton(R.string.cancel, null)
.create().show();
}
/**
* 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.
* @param showToast show "Message discarded" toast if true
*/
private void doDiscardWithoutConfirmation(boolean showToast) {
synchronized (mDraftLock) {
if (mDraftId != UIProvider.INVALID_MESSAGE_ID) {
ContentValues values = new ContentValues();
values.put(MessageColumns.SERVER_ID, mDraftId);
if (mAccount.expungeMessageUri != null) {
getContentResolver().update(mAccount.expungeMessageUri, values, null, null);
} else {
// TODO(mindyp): call delete on this conversation instead.
}
// 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;
}
}
if (showToast) {
// 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();
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 (shouldSave()) {
doSave(!mAddingAttachment /* show toast */, true /* reset IME */);
}
}
private void saveIfNeededOnOrientationChanged() {
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 (shouldSave()) {
doSaveOrientationChanged(!mAddingAttachment /* show toast */, true /* reset IME */);
}
}
/**
* Save a draft if a draft already exists or the message is not empty.
*/
public void doSaveOrientationChanged(boolean showToast, boolean resetIME) {
saveOnOrientationChanged();
if (resetIME) {
// Clear the IME composing suggestions from the body.
BaseInputConnection.removeComposingSpans(mBodyView.getEditableText());
}
}
protected boolean saveOnOrientationChanged() {
return sendOrSaveWithSanityChecks(true, false, true);
}
@Override
public void onAttachmentDeleted() {
mAttachmentsChanged = true;
updateSaveUi();
}
/**
* This is called any time one of our text fields changes.
*/
public void afterTextChanged(Editable s) {
mTextChanged = true;
updateSaveUi();
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// Do nothing.
}
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() {
String[] currRecips = tokenizeRecips(getAddressesFromList(mView));
int totalCount = currRecips.length;
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;
}
private String[] tokenizeRecips(String[] recips) {
// Tokenize them all and put them in the list.
String[] recipAddresses = new String[recips.length];
for (int i = 0; i < recips.length; i++) {
recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress();
}
return recipAddresses;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
String[] recips = tokenizeRecips(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.
}
}
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
if (id == ACCOUNT_SETTINGS_LOADER) {
if (mAccount.settingsQueryUri != null) {
return new CursorLoader(this, mAccount.settingsQueryUri,
UIProvider.SETTINGS_PROJECTION, null, null, null);
}
}
return null;
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
if (loader.getId() == ACCOUNT_SETTINGS_LOADER) {
if (data != null) {
data.moveToFirst();
mCachedSettings = new Settings(data);
appendSignature();
}
}
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
// Do nothing.
}
}