ComposeActivity.java revision b67aa8f5975082434dc6b8ebeedf2f1333dbf86d
1/** 2 * Copyright (c) 2011, Google Inc. 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.mail.compose; 18 19import android.app.ActionBar; 20import android.app.ActionBar.OnNavigationListener; 21import android.app.Activity; 22import android.app.ActivityManager; 23import android.app.AlertDialog; 24import android.app.Dialog; 25import android.app.LoaderManager; 26import android.content.ContentResolver; 27import android.content.ContentValues; 28import android.content.Context; 29import android.content.CursorLoader; 30import android.content.DialogInterface; 31import android.content.Intent; 32import android.content.Loader; 33import android.content.pm.ActivityInfo; 34import android.database.Cursor; 35import android.net.Uri; 36import android.os.Bundle; 37import android.os.Handler; 38import android.os.HandlerThread; 39import android.os.Parcelable; 40import android.provider.BaseColumns; 41import android.text.Editable; 42import android.text.Html; 43import android.text.Spanned; 44import android.text.TextUtils; 45import android.text.TextWatcher; 46import android.text.util.Rfc822Token; 47import android.text.util.Rfc822Tokenizer; 48import android.view.LayoutInflater; 49import android.view.Menu; 50import android.view.MenuInflater; 51import android.view.MenuItem; 52import android.view.View; 53import android.view.View.OnClickListener; 54import android.view.View.OnFocusChangeListener; 55import android.view.ViewGroup; 56import android.view.inputmethod.BaseInputConnection; 57import android.widget.ArrayAdapter; 58import android.widget.Button; 59import android.widget.EditText; 60import android.widget.ImageView; 61import android.widget.TextView; 62import android.widget.Toast; 63 64import com.android.common.Rfc822Validator; 65import com.android.ex.chips.RecipientEditTextView; 66import com.android.mail.R; 67import com.android.mail.compose.AttachmentsView.AttachmentDeletedListener; 68import com.android.mail.compose.AttachmentsView.AttachmentFailureException; 69import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener; 70import com.android.mail.compose.QuotedTextView.RespondInlineListener; 71import com.android.mail.providers.Account; 72import com.android.mail.providers.Address; 73import com.android.mail.providers.Attachment; 74import com.android.mail.providers.Folder; 75import com.android.mail.providers.Message; 76import com.android.mail.providers.MessageModification; 77import com.android.mail.providers.ReplyFromAccount; 78import com.android.mail.providers.Settings; 79import com.android.mail.providers.UIProvider; 80import com.android.mail.providers.UIProvider.AccountCapabilities; 81import com.android.mail.providers.UIProvider.DraftType; 82import com.android.mail.utils.AccountUtils; 83import com.android.mail.utils.LogTag; 84import com.android.mail.utils.LogUtils; 85import com.android.mail.utils.Utils; 86import com.google.common.annotations.VisibleForTesting; 87import com.google.common.collect.Lists; 88import com.google.common.collect.Sets; 89 90import org.json.JSONException; 91 92import java.io.UnsupportedEncodingException; 93import java.net.URLDecoder; 94import java.util.ArrayList; 95import java.util.Arrays; 96import java.util.Collection; 97import java.util.HashMap; 98import java.util.HashSet; 99import java.util.List; 100import java.util.Map.Entry; 101import java.util.Set; 102import java.util.concurrent.ConcurrentHashMap; 103 104public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener, 105 RespondInlineListener, DialogInterface.OnClickListener, TextWatcher, 106 AttachmentDeletedListener, OnAccountChangedListener, LoaderManager.LoaderCallbacks<Cursor> { 107 // Identifiers for which type of composition this is 108 static final int COMPOSE = -1; 109 static final int REPLY = 0; 110 static final int REPLY_ALL = 1; 111 static final int FORWARD = 2; 112 static final int EDIT_DRAFT = 3; 113 114 // Integer extra holding one of the above compose action 115 protected static final String EXTRA_ACTION = "action"; 116 117 private static final String EXTRA_SHOW_CC = "showCc"; 118 private static final String EXTRA_SHOW_BCC = "showBcc"; 119 120 private static final String UTF8_ENCODING_NAME = "UTF-8"; 121 122 private static final String MAIL_TO = "mailto"; 123 124 private static final String EXTRA_SUBJECT = "subject"; 125 126 private static final String EXTRA_BODY = "body"; 127 128 private static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString"; 129 130 // Extra that we can get passed from other activities 131 private static final String EXTRA_TO = "to"; 132 private static final String EXTRA_CC = "cc"; 133 private static final String EXTRA_BCC = "bcc"; 134 135 // List of all the fields 136 static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC }; 137 138 private static SendOrSaveCallback sTestSendOrSaveCallback = null; 139 // Map containing information about requests to create new messages, and the id of the 140 // messages that were the result of those requests. 141 // 142 // This map is used when the activity that initiated the save a of a new message, is killed 143 // before the save has completed (and when we know the id of the newly created message). When 144 // a save is completed, the service that is running in the background, will update the map 145 // 146 // When a new ComposeActivity instance is created, it will attempt to use the information in 147 // the previously instantiated map. If ComposeActivity.onCreate() is called, with a bundle 148 // (restoring data from a previous instance), and the map hasn't been created, we will attempt 149 // to populate the map with data stored in shared preferences. 150 private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null; 151 // Key used to store the above map 152 private static final String CACHED_MESSAGE_REQUEST_IDS_KEY = "cache-message-request-ids"; 153 /** 154 * Notifies the {@code Activity} that the caller is an Email 155 * {@code Activity}, so that the back behavior may be modified accordingly. 156 * 157 * @see #onAppUpPressed 158 */ 159 public static final String EXTRA_FROM_EMAIL_TASK = "fromemail"; 160 161 public static final String EXTRA_ATTACHMENTS = "attachments"; 162 163 // If this is a reply/forward then this extra will hold the original message 164 private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message"; 165 // If this is a reply/forward then this extra will hold a uri we must query 166 // to get the original message. 167 protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri"; 168 // If this is an action to edit an existing draft messagge, this extra will hold the 169 // draft message 170 private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message"; 171 private static final String END_TOKEN = ", "; 172 private static final String LOG_TAG = LogTag.getLogTag(); 173 // Request numbers for activities we start 174 private static final int RESULT_PICK_ATTACHMENT = 1; 175 private static final int RESULT_CREATE_ACCOUNT = 2; 176 // TODO(mindyp) set mime-type for auto send? 177 public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND"; 178 179 // Max size for attachments (5 megs). Will be overridden by account settings if found. 180 // TODO(mindyp): read this from account settings? 181 private static final int DEFAULT_MAX_ATTACHMENT_SIZE = 25 * 1024 * 1024; 182 private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount"; 183 private static final String EXTRA_REQUEST_ID = "requestId"; 184 private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart"; 185 private static final String EXTRA_FOCUS_SELECTION_END = null; 186 private static final String EXTRA_MESSAGE = "extraMessage"; 187 private static final int REFERENCE_MESSAGE_LOADER = 0; 188 189 /** 190 * A single thread for running tasks in the background. 191 */ 192 private Handler mSendSaveTaskHandler = null; 193 private RecipientEditTextView mTo; 194 private RecipientEditTextView mCc; 195 private RecipientEditTextView mBcc; 196 private Button mCcBccButton; 197 private CcBccView mCcBccView; 198 private AttachmentsView mAttachmentsView; 199 private Account mAccount; 200 private ReplyFromAccount mReplyFromAccount; 201 private Settings mCachedSettings; 202 private Rfc822Validator mValidator; 203 private TextView mSubject; 204 205 private ComposeModeAdapter mComposeModeAdapter; 206 private int mComposeMode = -1; 207 private boolean mForward; 208 private String mRecipient; 209 private QuotedTextView mQuotedTextView; 210 private EditText mBodyView; 211 private View mFromStatic; 212 private TextView mFromStaticText; 213 private View mFromSpinnerWrapper; 214 @VisibleForTesting 215 protected FromAddressSpinner mFromSpinner; 216 private boolean mAddingAttachment; 217 private boolean mAttachmentsChanged; 218 private boolean mTextChanged; 219 private boolean mReplyFromChanged; 220 private MenuItem mSave; 221 private MenuItem mSend; 222 private AlertDialog mRecipientErrorDialog; 223 private AlertDialog mSendConfirmDialog; 224 @VisibleForTesting 225 protected Message mRefMessage; 226 private long mDraftId = UIProvider.INVALID_MESSAGE_ID; 227 private Message mDraft; 228 private Object mDraftLock = new Object(); 229 private ImageView mAttachmentsButton; 230 231 /** 232 * Boolean indicating whether ComposeActivity was launched from a Gmail controlled view. 233 */ 234 private boolean mLaunchedFromEmail = false; 235 private RecipientTextWatcher mToListener; 236 private RecipientTextWatcher mCcListener; 237 private RecipientTextWatcher mBccListener; 238 private Uri mRefMessageUri; 239 240 241 /** 242 * Can be called from a non-UI thread. 243 */ 244 public static void editDraft(Context launcher, Account account, Message message) { 245 launch(launcher, account, message, EDIT_DRAFT); 246 } 247 248 /** 249 * Can be called from a non-UI thread. 250 */ 251 public static void compose(Context launcher, Account account) { 252 launch(launcher, account, null, COMPOSE); 253 } 254 255 /** 256 * Can be called from a non-UI thread. 257 */ 258 public static void reply(Context launcher, Account account, Message message) { 259 launch(launcher, account, message, REPLY); 260 } 261 262 /** 263 * Can be called from a non-UI thread. 264 */ 265 public static void replyAll(Context launcher, Account account, Message message) { 266 launch(launcher, account, message, REPLY_ALL); 267 } 268 269 /** 270 * Can be called from a non-UI thread. 271 */ 272 public static void forward(Context launcher, Account account, Message message) { 273 launch(launcher, account, message, FORWARD); 274 } 275 276 private static void launch(Context launcher, Account account, Message message, int action) { 277 Intent intent = new Intent(launcher, ComposeActivity.class); 278 intent.putExtra(EXTRA_FROM_EMAIL_TASK, true); 279 intent.putExtra(EXTRA_ACTION, action); 280 intent.putExtra(Utils.EXTRA_ACCOUNT, account); 281 if (action == EDIT_DRAFT) { 282 intent.putExtra(ORIGINAL_DRAFT_MESSAGE, message); 283 } else { 284 intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE, message); 285 } 286 launcher.startActivity(intent); 287 } 288 289 @Override 290 public void onCreate(Bundle savedInstanceState) { 291 super.onCreate(savedInstanceState); 292 setContentView(R.layout.compose); 293 findViews(); 294 Intent intent = getIntent(); 295 Account account = null; 296 Message message; 297 boolean showQuotedText = false; 298 int action; 299 Object accountExtra = intent != null && intent.getExtras() != null ? intent.getExtras() 300 .get(Utils.EXTRA_ACCOUNT) : null; 301 final Account[] syncingAccounts = AccountUtils.getSyncingAccounts(this); 302 if (hadSavedInstanceStateMessage(savedInstanceState)) { 303 action = savedInstanceState.getInt(EXTRA_ACTION, COMPOSE); 304 account = savedInstanceState.getParcelable(Utils.EXTRA_ACCOUNT); 305 message = (Message) savedInstanceState.getParcelable(EXTRA_MESSAGE); 306 mRefMessage = (Message) savedInstanceState.getParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE); 307 } else { 308 if (accountExtra instanceof Account) { 309 account = (Account) intent.getExtras().get(Utils.EXTRA_ACCOUNT); 310 } else if (accountExtra instanceof String) { 311 // For backwards compatibility 312 String extraAccount = intent.getStringExtra(Utils.EXTRA_ACCOUNT); 313 if (syncingAccounts.length > 0) { 314 if (!TextUtils.isEmpty(extraAccount)) { 315 for (Account a : syncingAccounts) { 316 if (a.name.equals(extraAccount)) { 317 account = a; 318 } 319 } 320 } 321 } 322 } 323 action = intent.getIntExtra(EXTRA_ACTION, COMPOSE); 324 // Initialize the message from the message in the intent 325 message = (Message) intent.getParcelableExtra(ORIGINAL_DRAFT_MESSAGE); 326 mRefMessage = (Message) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE); 327 mRefMessageUri = (Uri) intent.getParcelableExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI); 328 } 329 if (account == null) { 330 if (syncingAccounts != null && syncingAccounts.length > 0) { 331 account = syncingAccounts[0]; 332 } 333 } 334 335 setAccount(account); 336 if (mAccount == null) { 337 return; 338 } 339 340 if (intent.getBooleanExtra(EXTRA_FROM_EMAIL_TASK, false)) { 341 mLaunchedFromEmail = true; 342 } else if (Intent.ACTION_SEND.equals(intent.getAction())) { 343 final Uri dataUri = intent.getData(); 344 if (dataUri != null) { 345 final String dataScheme = intent.getData().getScheme(); 346 final String accountScheme = mAccount.composeIntentUri.getScheme(); 347 mLaunchedFromEmail = TextUtils.equals(dataScheme, accountScheme); 348 } 349 } 350 351 if (mRefMessageUri != null) { 352 // We have a referenced message that we must look up. 353 getLoaderManager().initLoader(REFERENCE_MESSAGE_LOADER, null, this); 354 return; 355 } else if (message != null && action != EDIT_DRAFT) { 356 initFromDraftMessage(message); 357 initQuotedTextFromRefMessage(mRefMessage, action); 358 showCcBcc(savedInstanceState); 359 showQuotedText = message.appendRefMessageContent; 360 } else if (action == EDIT_DRAFT) { 361 initFromDraftMessage(message); 362 boolean showBcc = !TextUtils.isEmpty(message.bcc); 363 boolean showCc = showBcc || !TextUtils.isEmpty(message.cc); 364 mCcBccView.show(false, showCc, showBcc); 365 // Update the action to the draft type of the previous draft 366 switch (message.draftType) { 367 case UIProvider.DraftType.REPLY: 368 action = REPLY; 369 break; 370 case UIProvider.DraftType.REPLY_ALL: 371 action = REPLY_ALL; 372 break; 373 case UIProvider.DraftType.FORWARD: 374 action = FORWARD; 375 break; 376 case UIProvider.DraftType.COMPOSE: 377 default: 378 action = COMPOSE; 379 break; 380 } 381 initQuotedTextFromRefMessage(mRefMessage, action); 382 showQuotedText = message.appendRefMessageContent; 383 } else if ((action == REPLY || action == REPLY_ALL || action == FORWARD)) { 384 if (mRefMessage != null) { 385 initFromRefMessage(action, mAccount.name); 386 showQuotedText = true; 387 } 388 } else { 389 initFromExtras(intent); 390 } 391 finishSetup(action, intent, savedInstanceState, showQuotedText); 392 } 393 394 private void finishSetup(int action, Intent intent, Bundle savedInstanceState, 395 boolean showQuotedText) { 396 if (action == COMPOSE) { 397 mQuotedTextView.setVisibility(View.GONE); 398 } 399 initRecipients(); 400 // Don't bother with the intent if we have procured a message from the 401 // intent already. 402 if (!hadSavedInstanceStateMessage(savedInstanceState)) { 403 initAttachmentsFromIntent(intent); 404 } 405 initActionBar(action); 406 initFromSpinner(savedInstanceState != null ? savedInstanceState : intent.getExtras(), 407 action); 408 initChangeListeners(); 409 setFocus(action); 410 updateHideOrShowCcBcc(); 411 updateHideOrShowQuotedText(showQuotedText); 412 } 413 414 private boolean hadSavedInstanceStateMessage(Bundle savedInstanceState) { 415 return savedInstanceState != null && savedInstanceState.containsKey(EXTRA_MESSAGE); 416 } 417 418 private void updateHideOrShowQuotedText(boolean showQuotedText) { 419 mQuotedTextView.updateCheckedState(showQuotedText); 420 } 421 422 private void setFocus(int action) { 423 if (action == EDIT_DRAFT) { 424 int type = mDraft.draftType; 425 switch (type) { 426 case UIProvider.DraftType.COMPOSE: 427 case UIProvider.DraftType.FORWARD: 428 action = COMPOSE; 429 break; 430 case UIProvider.DraftType.REPLY: 431 case UIProvider.DraftType.REPLY_ALL: 432 default: 433 action = REPLY; 434 break; 435 } 436 } 437 switch (action) { 438 case FORWARD: 439 case COMPOSE: 440 mTo.requestFocus(); 441 break; 442 case REPLY: 443 case REPLY_ALL: 444 default: 445 focusBody(); 446 break; 447 } 448 } 449 450 /** 451 * Focus the body of the message. 452 */ 453 public void focusBody() { 454 mBodyView.requestFocus(); 455 int length = mBodyView.getText().length(); 456 457 int signatureStartPos = getSignatureStartPosition( 458 mSignature, mBodyView.getText().toString()); 459 if (signatureStartPos > -1) { 460 // In case the user deleted the newlines... 461 mBodyView.setSelection(signatureStartPos); 462 } else if (length > 0) { 463 // Move cursor to the end. 464 mBodyView.setSelection(length); 465 } 466 } 467 468 @Override 469 protected void onResume() { 470 super.onResume(); 471 // Update the from spinner as other accounts 472 // may now be available. 473 if (mFromSpinner != null && mAccount != null) { 474 mFromSpinner.asyncInitFromSpinner(mComposeMode, mAccount); 475 } 476 } 477 478 @Override 479 protected void onPause() { 480 super.onPause(); 481 482 if (mSendConfirmDialog != null) { 483 mSendConfirmDialog.dismiss(); 484 } 485 if (mRecipientErrorDialog != null) { 486 mRecipientErrorDialog.dismiss(); 487 } 488 // When the user exits the compose view, see if this draft needs saving. 489 if (isFinishing()) { 490 saveIfNeeded(); 491 } 492 } 493 494 @Override 495 protected final void onActivityResult(int request, int result, Intent data) { 496 mAddingAttachment = false; 497 498 if (result == RESULT_OK && request == RESULT_PICK_ATTACHMENT) { 499 addAttachmentAndUpdateView(data); 500 } 501 } 502 503 @Override 504 public final void onRestoreInstanceState(Bundle savedInstanceState) { 505 super.onRestoreInstanceState(savedInstanceState); 506 if (savedInstanceState != null) { 507 if (savedInstanceState.containsKey(EXTRA_FOCUS_SELECTION_START)) { 508 int selectionStart = savedInstanceState.getInt(EXTRA_FOCUS_SELECTION_START); 509 int selectionEnd = savedInstanceState.getInt(EXTRA_FOCUS_SELECTION_END); 510 // There should be a focus and it should be an EditText since we 511 // only save these extras if these conditions are true. 512 EditText focusEditText = (EditText) getCurrentFocus(); 513 final int length = focusEditText.getText().length(); 514 if (selectionStart < length && selectionEnd < length) { 515 focusEditText.setSelection(selectionStart, selectionEnd); 516 } 517 } 518 } 519 } 520 521 @Override 522 public final void onSaveInstanceState(Bundle state) { 523 super.onSaveInstanceState(state); 524 // The framework is happy to save and restore the selection but only if it also saves and 525 // restores the contents of the edit text. That's a lot of text to put in a bundle so we do 526 // this manually. 527 View focus = getCurrentFocus(); 528 if (focus != null && focus instanceof EditText) { 529 EditText focusEditText = (EditText) focus; 530 state.putInt(EXTRA_FOCUS_SELECTION_START, focusEditText.getSelectionStart()); 531 state.putInt(EXTRA_FOCUS_SELECTION_END, focusEditText.getSelectionEnd()); 532 } 533 534 final List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 535 final int selectedPos = mFromSpinner.getSelectedItemPosition(); 536 final ReplyFromAccount selectedReplyFromAccount = (replyFromAccounts != null 537 && replyFromAccounts.size() > 0 && replyFromAccounts.size() > selectedPos) ? 538 replyFromAccounts.get(selectedPos) : null; 539 if (selectedReplyFromAccount != null) { 540 state.putString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT, selectedReplyFromAccount.serialize() 541 .toString()); 542 state.putParcelable(Utils.EXTRA_ACCOUNT, selectedReplyFromAccount.account); 543 } else { 544 state.putParcelable(Utils.EXTRA_ACCOUNT, mAccount); 545 } 546 547 if (mDraftId == UIProvider.INVALID_MESSAGE_ID && mRequestId !=0) { 548 // We don't have a draft id, and we have a request id, 549 // save the request id. 550 state.putInt(EXTRA_REQUEST_ID, mRequestId); 551 } 552 553 // We want to restore the current mode after a pause 554 // or rotation. 555 int mode = getMode(); 556 state.putInt(EXTRA_ACTION, mode); 557 558 Message message = createMessage(selectedReplyFromAccount, mode); 559 state.putParcelable(EXTRA_MESSAGE, message); 560 561 if (mRefMessage != null) { 562 state.putParcelable(EXTRA_IN_REFERENCE_TO_MESSAGE, mRefMessage); 563 } 564 state.putBoolean(EXTRA_SHOW_CC, mCcBccView.isCcVisible()); 565 state.putBoolean(EXTRA_SHOW_BCC, mCcBccView.isBccVisible()); 566 } 567 568 private int getMode() { 569 int mode = ComposeActivity.COMPOSE; 570 ActionBar actionBar = getActionBar(); 571 if (actionBar != null 572 && actionBar.getNavigationMode() == ActionBar.NAVIGATION_MODE_LIST) { 573 mode = actionBar.getSelectedNavigationIndex(); 574 } 575 return mode; 576 } 577 578 private Message createMessage(ReplyFromAccount selectedReplyFromAccount, int mode) { 579 Message message = new Message(); 580 message.id = UIProvider.INVALID_MESSAGE_ID; 581 message.serverId =UIProvider.INVALID_MESSAGE_ID; 582 message.uri = null; 583 message.conversationUri = null; 584 message.subject = mSubject.getText().toString(); 585 message.snippet = null; 586 message.from = selectedReplyFromAccount != null ? selectedReplyFromAccount.name 587 : mAccount != null ? mAccount.name : null; 588 message.to = mTo.getText().toString(); 589 message.cc = mCc.getText().toString(); 590 message.bcc = mBcc.getText().toString(); 591 message.replyTo = null; 592 message.dateReceivedMs = 0; 593 String htmlBody = Html.toHtml(mBodyView.getText()); 594 StringBuilder fullBody = new StringBuilder(htmlBody); 595 message.bodyHtml = fullBody.toString(); 596 message.bodyText = mBodyView.getText().toString(); 597 message.embedsExternalResources = false; 598 message.refMessageId = mRefMessage != null ? mRefMessage.uri.toString() : null; 599 message.draftType = getDraftType(mode); 600 message.appendRefMessageContent = mQuotedTextView.getQuotedTextIfIncluded() != null; 601 ArrayList<Attachment> attachments = mAttachmentsView.getAttachments(); 602 message.hasAttachments = attachments != null && attachments.size() > 0; 603 message.attachmentListUri = null; 604 message.messageFlags = 0; 605 message.saveUri = null; 606 message.sendUri = null; 607 message.alwaysShowImages = false; 608 message.attachmentsJson = Attachment.toJSONArray(attachments); 609 CharSequence quotedText = mQuotedTextView.getQuotedText(); 610 message.quotedTextOffset = !TextUtils.isEmpty(quotedText) ? QuotedTextView 611 .getQuotedTextOffset(quotedText.toString()) : -1; 612 message.accountUri = null; 613 return message; 614 } 615 616 @VisibleForTesting 617 void setAccount(Account account) { 618 if (account == null) { 619 return; 620 } 621 if (!account.equals(mAccount)) { 622 mAccount = account; 623 mCachedSettings = mAccount.settings; 624 appendSignature(); 625 } 626 } 627 628 private void initFromSpinner(Bundle bundle, int action) { 629 String accountString = null; 630 if (action == EDIT_DRAFT && mDraft.draftType == UIProvider.DraftType.COMPOSE) { 631 action = COMPOSE; 632 } 633 mFromSpinner.asyncInitFromSpinner(action, mAccount); 634 if (bundle != null) { 635 if (bundle.containsKey(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)) { 636 mReplyFromAccount = ReplyFromAccount.deserialize(mAccount, 637 bundle.getString(EXTRA_SELECTED_REPLY_FROM_ACCOUNT)); 638 } else if (bundle.containsKey(EXTRA_FROM_ACCOUNT_STRING)) { 639 accountString = bundle.getString(EXTRA_FROM_ACCOUNT_STRING); 640 mReplyFromAccount = mFromSpinner.getMatchingReplyFromAccount(accountString); 641 } 642 } 643 if (mReplyFromAccount == null) { 644 if (mDraft != null) { 645 mReplyFromAccount = getReplyFromAccountFromDraft(mAccount, mDraft); 646 } else if (mRefMessage != null) { 647 mReplyFromAccount = getReplyFromAccountForReply(mAccount, mRefMessage); 648 } 649 } 650 if (mReplyFromAccount == null) { 651 mReplyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name, 652 mAccount.name, mAccount.name, true, false); 653 } 654 655 mFromSpinner.setCurrentAccount(mReplyFromAccount); 656 657 if (mFromSpinner.getCount() > 1) { 658 // If there is only 1 account, just show that account. 659 // Otherwise, give the user the ability to choose which account to 660 // send mail from / save drafts to. 661 mFromStatic.setVisibility(View.GONE); 662 mFromStaticText.setText(mAccount.name); 663 mFromSpinnerWrapper.setVisibility(View.VISIBLE); 664 } else { 665 mFromStatic.setVisibility(View.VISIBLE); 666 mFromStaticText.setText(mAccount.name); 667 mFromSpinnerWrapper.setVisibility(View.GONE); 668 } 669 } 670 671 private ReplyFromAccount getReplyFromAccountForReply(Account account, Message refMessage) { 672 if (refMessage.accountUri != null) { 673 // This must be from combined inbox. 674 List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 675 for (ReplyFromAccount from : replyFromAccounts) { 676 if (from.account.uri.equals(refMessage.accountUri)) { 677 return from; 678 } 679 } 680 return null; 681 } else { 682 return getReplyFromAccount(account, refMessage); 683 } 684 } 685 686 /** 687 * Given an account and which email address the message was sent to, 688 * return who the message should be sent from. 689 * @param account Account in which the message arrived. 690 * @param sentTo Email address to which the message was sent. 691 * @return the address from which to reply. 692 */ 693 public ReplyFromAccount getReplyFromAccount(Account account, Message refMessage) { 694 // First see if we are supposed to use the default address or 695 // the address it was sentTo. 696 if (mCachedSettings.forceReplyFromDefault) { 697 return getDefaultReplyFromAccount(account); 698 } else { 699 // If we aren't explicitly told which account to look for, look at 700 // all the message recipients and find one that matches 701 // a custom from or account. 702 List<String> allRecipients = new ArrayList<String>(); 703 allRecipients.addAll(Arrays.asList(Utils.splitCommaSeparatedString(refMessage.to))); 704 allRecipients.addAll(Arrays.asList(Utils.splitCommaSeparatedString(refMessage.cc))); 705 return getMatchingRecipient(account, allRecipients); 706 } 707 } 708 709 /** 710 * Compare all the recipients of an email to the current account and all 711 * custom addresses associated with that account. Return the match if there 712 * is one, or the default account if there isn't. 713 */ 714 protected ReplyFromAccount getMatchingRecipient(Account account, List<String> sentTo) { 715 // Tokenize the list and place in a hashmap. 716 ReplyFromAccount matchingReplyFrom = null; 717 Rfc822Token[] tokens; 718 HashSet<String> recipientsMap = new HashSet<String>(); 719 for (String address : sentTo) { 720 tokens = Rfc822Tokenizer.tokenize(address); 721 for (int i = 0; i < tokens.length; i++) { 722 recipientsMap.add(tokens[i].getAddress()); 723 } 724 } 725 726 int matchingAddressCount = 0; 727 List<ReplyFromAccount> customFroms; 728 try { 729 customFroms = FromAddressSpinner.getAccountSpecificFroms(account); 730 if (customFroms != null) { 731 for (ReplyFromAccount entry : customFroms) { 732 if (recipientsMap.contains(entry.address)) { 733 matchingReplyFrom = entry; 734 matchingAddressCount++; 735 } 736 } 737 } 738 } catch (JSONException e) { 739 LogUtils.wtf(LOG_TAG, "Exception parsing from addresses for account %s", 740 account.name); 741 } 742 if (matchingAddressCount > 1) { 743 matchingReplyFrom = getDefaultReplyFromAccount(account); 744 } 745 return matchingReplyFrom; 746 } 747 748 private ReplyFromAccount getDefaultReplyFromAccount(Account account) { 749 List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 750 for (ReplyFromAccount from : replyFromAccounts) { 751 if (from.isDefault) { 752 return from; 753 } 754 } 755 return new ReplyFromAccount(account, account.uri, account.name, account.name, account.name, 756 true, false); 757 } 758 759 private ReplyFromAccount getReplyFromAccountFromDraft(Account account, Message msg) { 760 String sender = msg.from; 761 ReplyFromAccount replyFromAccount = null; 762 List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 763 if (TextUtils.equals(account.name, sender)) { 764 replyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, mAccount.name, 765 mAccount.name, mAccount.name, true, false); 766 } else { 767 for (ReplyFromAccount fromAccount : replyFromAccounts) { 768 if (TextUtils.equals(fromAccount.name, sender)) { 769 replyFromAccount = fromAccount; 770 break; 771 } 772 } 773 } 774 return replyFromAccount; 775 } 776 777 private void findViews() { 778 mCcBccButton = (Button) findViewById(R.id.add_cc_bcc); 779 if (mCcBccButton != null) { 780 mCcBccButton.setOnClickListener(this); 781 } 782 mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper); 783 mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments); 784 mAttachmentsButton = (ImageView) findViewById(R.id.add_attachment); 785 if (mAttachmentsButton != null) { 786 mAttachmentsButton.setOnClickListener(this); 787 } 788 mTo = (RecipientEditTextView) findViewById(R.id.to); 789 mCc = (RecipientEditTextView) findViewById(R.id.cc); 790 mBcc = (RecipientEditTextView) findViewById(R.id.bcc); 791 // TODO: add special chips text change watchers before adding 792 // this as a text changed watcher to the to, cc, bcc fields. 793 mSubject = (TextView) findViewById(R.id.subject); 794 mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view); 795 mQuotedTextView.setRespondInlineListener(this); 796 mBodyView = (EditText) findViewById(R.id.body); 797 mFromStatic = findViewById(R.id.static_from_content); 798 mFromStaticText = (TextView) findViewById(R.id.from_account_name); 799 mFromSpinnerWrapper = findViewById(R.id.spinner_from_content); 800 mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker); 801 } 802 803 protected TextView getBody() { 804 return mBodyView; 805 } 806 807 @VisibleForTesting 808 public Account getFromAccount() { 809 return mReplyFromAccount != null && mReplyFromAccount.account != null ? 810 mReplyFromAccount.account : mAccount; 811 } 812 813 private void clearChangeListeners() { 814 mSubject.removeTextChangedListener(this); 815 mBodyView.removeTextChangedListener(this); 816 mTo.removeTextChangedListener(mToListener); 817 mCc.removeTextChangedListener(mCcListener); 818 mBcc.removeTextChangedListener(mBccListener); 819 mFromSpinner.setOnAccountChangedListener(null); 820 mAttachmentsView.setAttachmentChangesListener(null); 821 } 822 823 // Now that the message has been initialized from any existing draft or 824 // ref message data, set up listeners for any changes that occur to the 825 // message. 826 private void initChangeListeners() { 827 mSubject.addTextChangedListener(this); 828 mBodyView.addTextChangedListener(this); 829 if (mToListener == null) { 830 mToListener = new RecipientTextWatcher(mTo, this); 831 } 832 mTo.addTextChangedListener(mToListener); 833 if (mCcListener == null) { 834 mCcListener = new RecipientTextWatcher(mCc, this); 835 } 836 mCc.addTextChangedListener(mCcListener); 837 if (mBccListener == null) { 838 mBccListener = new RecipientTextWatcher(mBcc, this); 839 } 840 mBcc.addTextChangedListener(mBccListener); 841 mFromSpinner.setOnAccountChangedListener(this); 842 mAttachmentsView.setAttachmentChangesListener(this); 843 } 844 845 private void initActionBar(int action) { 846 mComposeMode = action; 847 ActionBar actionBar = getActionBar(); 848 if (actionBar == null) { 849 return; 850 } 851 if (action == ComposeActivity.COMPOSE) { 852 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); 853 actionBar.setTitle(R.string.compose); 854 } else { 855 actionBar.setTitle(null); 856 if (mComposeModeAdapter == null) { 857 mComposeModeAdapter = new ComposeModeAdapter(this); 858 } 859 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); 860 actionBar.setListNavigationCallbacks(mComposeModeAdapter, this); 861 switch (action) { 862 case ComposeActivity.REPLY: 863 actionBar.setSelectedNavigationItem(0); 864 break; 865 case ComposeActivity.REPLY_ALL: 866 actionBar.setSelectedNavigationItem(1); 867 break; 868 case ComposeActivity.FORWARD: 869 actionBar.setSelectedNavigationItem(2); 870 break; 871 } 872 } 873 actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME, 874 ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME); 875 actionBar.setHomeButtonEnabled(true); 876 } 877 878 private void initFromRefMessage(int action, String recipientAddress) { 879 setFieldsFromRefMessage(action, recipientAddress); 880 if (mRefMessage != null) { 881 // CC field only gets populated when doing REPLY_ALL. 882 // BCC never gets auto-populated, unless the user is editing 883 // a draft with one. 884 if (!TextUtils.isEmpty(mRefMessage.cc) && action == REPLY_ALL) { 885 mCcBccView.show(false, true, false); 886 } 887 } 888 updateHideOrShowCcBcc(); 889 } 890 891 private void setFieldsFromRefMessage(int action, String recipientAddress) { 892 setSubject(mRefMessage, action); 893 // Setup recipients 894 if (action == FORWARD) { 895 mForward = true; 896 } 897 initRecipientsFromRefMessage(recipientAddress, mRefMessage, action); 898 initQuotedTextFromRefMessage(mRefMessage, action); 899 if (action == ComposeActivity.FORWARD || mAttachmentsChanged) { 900 initAttachments(mRefMessage); 901 } 902 } 903 904 private void initFromDraftMessage(Message message) { 905 LogUtils.d(LOG_TAG, "Intializing draft from previous draft message"); 906 907 mDraft = message; 908 mDraftId = message.id; 909 mSubject.setText(message.subject); 910 mForward = message.draftType == UIProvider.DraftType.FORWARD; 911 final List<String> toAddresses = Arrays.asList(message.getToAddresses()); 912 addToAddresses(toAddresses); 913 addCcAddresses(Arrays.asList(message.getCcAddresses()), toAddresses); 914 addBccAddresses(Arrays.asList(message.getBccAddresses())); 915 if (message.hasAttachments) { 916 List<Attachment> attachments = message.getAttachments(); 917 for (Attachment a : attachments) { 918 addAttachmentAndUpdateView(a); 919 } 920 } 921 int quotedTextIndex = message.appendRefMessageContent ? 922 message.quotedTextOffset : -1; 923 // Set the body 924 CharSequence quotedText = null; 925 if (!TextUtils.isEmpty(message.bodyHtml)) { 926 CharSequence htmlText = Html.fromHtml(message.bodyHtml); 927 if (quotedTextIndex > -1) { 928 htmlText = htmlText.subSequence(0, quotedTextIndex); 929 quotedText = message.bodyHtml.subSequence(quotedTextIndex, 930 message.bodyHtml.length()); 931 } 932 mBodyView.setText(htmlText); 933 } else { 934 CharSequence bodyText = quotedTextIndex > -1 ? 935 message.bodyText.substring(0, quotedTextIndex) : message.bodyText; 936 if (quotedTextIndex > -1) { 937 quotedText = message.bodyText.substring(quotedTextIndex); 938 } 939 mBodyView.setText(bodyText); 940 } 941 if (quotedTextIndex > -1 && quotedText != null) { 942 mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward); 943 } 944 } 945 946 /** 947 * Fill all the widgets with the content found in the Intent Extra, if any. 948 * Also apply the same style to all widgets. Note: if initFromExtras is 949 * called as a result of switching between reply, reply all, and forward per 950 * the latest revision of Gmail, and the user has already made changes to 951 * attachments on a previous incarnation of the message (as a reply, reply 952 * all, or forward), the original attachments from the message will not be 953 * re-instantiated. The user's changes will be respected. This follows the 954 * web gmail interaction. 955 */ 956 public void initFromExtras(Intent intent) { 957 // If we were invoked with a SENDTO intent, the value 958 // should take precedence 959 final Uri dataUri = intent.getData(); 960 if (dataUri != null) { 961 if (MAIL_TO.equals(dataUri.getScheme())) { 962 initFromMailTo(dataUri.toString()); 963 } else { 964 if (!mAccount.composeIntentUri.equals(dataUri)) { 965 String toText = dataUri.getSchemeSpecificPart(); 966 if (toText != null) { 967 mTo.setText(""); 968 addToAddresses(Arrays.asList(TextUtils.split(toText, ","))); 969 } 970 } 971 } 972 } 973 974 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 975 if (extraStrings != null) { 976 addToAddresses(Arrays.asList(extraStrings)); 977 } 978 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 979 if (extraStrings != null) { 980 addCcAddresses(Arrays.asList(extraStrings), null); 981 } 982 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 983 if (extraStrings != null) { 984 addBccAddresses(Arrays.asList(extraStrings)); 985 } 986 987 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 988 if (extraString != null) { 989 mSubject.setText(extraString); 990 } 991 992 for (String extra : ALL_EXTRAS) { 993 if (intent.hasExtra(extra)) { 994 String value = intent.getStringExtra(extra); 995 if (EXTRA_TO.equals(extra)) { 996 addToAddresses(Arrays.asList(TextUtils.split(value, ","))); 997 } else if (EXTRA_CC.equals(extra)) { 998 addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null); 999 } else if (EXTRA_BCC.equals(extra)) { 1000 addBccAddresses(Arrays.asList(TextUtils.split(value, ","))); 1001 } else if (EXTRA_SUBJECT.equals(extra)) { 1002 mSubject.setText(value); 1003 } else if (EXTRA_BODY.equals(extra)) { 1004 setBody(value, true /* with signature */); 1005 } 1006 } 1007 } 1008 1009 Bundle extras = intent.getExtras(); 1010 if (extras != null) { 1011 CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT); 1012 if (text != null) { 1013 setBody(text, true /* with signature */); 1014 } 1015 } 1016 } 1017 1018 @VisibleForTesting 1019 protected String decodeEmailInUri(String s) throws UnsupportedEncodingException { 1020 // TODO: handle the case where there are spaces in the display name as 1021 // well as the email such as "Guy with spaces <guy+with+spaces@gmail.com>" 1022 // as they could be encoded ambiguously. 1023 // Since URLDecode.decode changes + into ' ', and + is a valid 1024 // email character, we need to find/ replace these ourselves before 1025 // decoding. 1026 String replacePlus = s.replace("+", "%2B"); 1027 try { 1028 return URLDecoder.decode(replacePlus, UTF8_ENCODING_NAME); 1029 } catch (IllegalArgumentException e) { 1030 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 1031 LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s); 1032 } else { 1033 LogUtils.e(LOG_TAG, e, "Exception while decoding mailto address"); 1034 } 1035 return null; 1036 } 1037 } 1038 1039 /** 1040 * Initialize the compose view from a String representing a mailTo uri. 1041 * @param mailToString The uri as a string. 1042 */ 1043 public void initFromMailTo(String mailToString) { 1044 // We need to disguise this string as a URI in order to parse it 1045 // TODO: Remove this hack when http://b/issue?id=1445295 gets fixed 1046 Uri uri = Uri.parse("foo://" + mailToString); 1047 int index = mailToString.indexOf("?"); 1048 int length = "mailto".length() + 1; 1049 String to; 1050 try { 1051 // Extract the recipient after mailto: 1052 if (index == -1) { 1053 to = decodeEmailInUri(mailToString.substring(length)); 1054 } else { 1055 to = decodeEmailInUri(mailToString.substring(length, index)); 1056 } 1057 if (!TextUtils.isEmpty(to)) { 1058 addToAddresses(Arrays.asList(TextUtils.split(to, ","))); 1059 } 1060 } catch (UnsupportedEncodingException e) { 1061 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 1062 LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString); 1063 } else { 1064 LogUtils.e(LOG_TAG, e, "Exception while decoding mailto address"); 1065 } 1066 } 1067 1068 List<String> cc = uri.getQueryParameters("cc"); 1069 addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null); 1070 1071 List<String> otherTo = uri.getQueryParameters("to"); 1072 addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()]))); 1073 1074 List<String> bcc = uri.getQueryParameters("bcc"); 1075 addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()]))); 1076 1077 List<String> subject = uri.getQueryParameters("subject"); 1078 if (subject.size() > 0) { 1079 try { 1080 mSubject.setText(URLDecoder.decode(subject.get(0), UTF8_ENCODING_NAME)); 1081 } catch (UnsupportedEncodingException e) { 1082 LogUtils.e(LOG_TAG, "%s while decoding subject '%s'", 1083 e.getMessage(), subject); 1084 } 1085 } 1086 1087 List<String> body = uri.getQueryParameters("body"); 1088 if (body.size() > 0) { 1089 try { 1090 setBody(URLDecoder.decode(body.get(0), UTF8_ENCODING_NAME), 1091 true /* with signature */); 1092 } catch (UnsupportedEncodingException e) { 1093 LogUtils.e(LOG_TAG, "%s while decoding body '%s'", e.getMessage(), body); 1094 } 1095 } 1096 } 1097 1098 @VisibleForTesting 1099 protected void initAttachments(Message refMessage) { 1100 mAttachmentsView.addAttachments(mAccount, refMessage); 1101 } 1102 1103 private void initAttachmentsFromIntent(Intent intent) { 1104 Bundle extras = intent.getExtras(); 1105 if (extras == null) { 1106 extras = Bundle.EMPTY; 1107 } 1108 final String action = intent.getAction(); 1109 if (!mAttachmentsChanged) { 1110 long totalSize = 0; 1111 if (extras.containsKey(EXTRA_ATTACHMENTS)) { 1112 String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS); 1113 for (String uriString : uris) { 1114 final Uri uri = Uri.parse(uriString); 1115 long size = 0; 1116 try { 1117 size = mAttachmentsView.addAttachment(mAccount, uri); 1118 } catch (AttachmentFailureException e) { 1119 // A toast has already been shown to the user, 1120 // just break out of the loop. 1121 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1122 } 1123 totalSize += size; 1124 } 1125 } 1126 if (Intent.ACTION_SEND.equals(action) && extras.containsKey(Intent.EXTRA_STREAM)) { 1127 final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); 1128 long size = 0; 1129 try { 1130 size = mAttachmentsView.addAttachment(mAccount, uri); 1131 } catch (AttachmentFailureException e) { 1132 // A toast has already been shown to the user, so just 1133 // exit. 1134 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1135 } 1136 totalSize += size; 1137 } 1138 1139 if (Intent.ACTION_SEND_MULTIPLE.equals(action) 1140 && extras.containsKey(Intent.EXTRA_STREAM)) { 1141 ArrayList<Parcelable> uris = extras.getParcelableArrayList(Intent.EXTRA_STREAM); 1142 for (Parcelable uri : uris) { 1143 long size = 0; 1144 try { 1145 size = mAttachmentsView.addAttachment(mAccount, (Uri) uri); 1146 } catch (AttachmentFailureException e) { 1147 // A toast has already been shown to the user, 1148 // just break out of the loop. 1149 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1150 } 1151 totalSize += size; 1152 } 1153 } 1154 1155 if (totalSize > 0) { 1156 mAttachmentsChanged = true; 1157 updateSaveUi(); 1158 } 1159 } 1160 } 1161 1162 1163 private void initQuotedTextFromRefMessage(Message refMessage, int action) { 1164 if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) { 1165 mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD); 1166 } 1167 } 1168 1169 private void updateHideOrShowCcBcc() { 1170 // Its possible there is a menu item OR a button. 1171 boolean ccVisible = mCcBccView.isCcVisible(); 1172 boolean bccVisible = mCcBccView.isBccVisible(); 1173 if (mCcBccButton != null) { 1174 if (!ccVisible || !bccVisible) { 1175 mCcBccButton.setVisibility(View.VISIBLE); 1176 mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label 1177 : R.string.add_bcc_label)); 1178 } else { 1179 mCcBccButton.setVisibility(View.GONE); 1180 } 1181 } 1182 } 1183 1184 private void showCcBcc(Bundle state) { 1185 if (state != null && state.containsKey(EXTRA_SHOW_CC)) { 1186 boolean showCc = state.getBoolean(EXTRA_SHOW_CC); 1187 boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC); 1188 if (showCc || showBcc) { 1189 mCcBccView.show(false, showCc, showBcc); 1190 } 1191 } 1192 } 1193 1194 /** 1195 * Add attachment and update the compose area appropriately. 1196 * @param data 1197 */ 1198 public void addAttachmentAndUpdateView(Intent data) { 1199 addAttachmentAndUpdateView(data != null ? data.getData() : (Uri) null); 1200 } 1201 1202 public void addAttachmentAndUpdateView(Uri contentUri) { 1203 if (contentUri == null) { 1204 return; 1205 } 1206 try { 1207 addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri)); 1208 } catch (AttachmentFailureException e) { 1209 // A toast has already been shown to the user, no need to do 1210 // anything. 1211 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1212 } 1213 } 1214 1215 public void addAttachmentAndUpdateView(Attachment attachment) { 1216 try { 1217 long size = mAttachmentsView.addAttachment(mAccount, attachment); 1218 if (size > 0) { 1219 mAttachmentsChanged = true; 1220 updateSaveUi(); 1221 } 1222 } catch (AttachmentFailureException e) { 1223 // A toast has already been shown to the user, no need to do 1224 // anything. 1225 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1226 } 1227 } 1228 1229 void initRecipientsFromRefMessage(String recipientAddress, Message refMessage, 1230 int action) { 1231 // Don't populate the address if this is a forward. 1232 if (action == ComposeActivity.FORWARD) { 1233 return; 1234 } 1235 initReplyRecipients(mAccount.name, refMessage, action); 1236 } 1237 1238 @VisibleForTesting 1239 void initReplyRecipients(String account, Message refMessage, int action) { 1240 // This is the email address of the current user, i.e. the one composing 1241 // the reply. 1242 final String accountEmail = Address.getEmailAddress(account).getAddress(); 1243 String fromAddress = getAddress(refMessage.from); 1244 String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage.to); 1245 String replytoAddress = refMessage.replyTo; 1246 final Collection<String> toAddresses; 1247 1248 // If this is a reply, the Cc list is empty. If this is a reply-all, the 1249 // Cc list is the union of the To and Cc recipients of the original 1250 // message, excluding the current user's email address and any addresses 1251 // already on the To list. 1252 if (action == ComposeActivity.REPLY) { 1253 toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress, 1254 sentToAddresses); 1255 addToAddresses(toAddresses); 1256 } else if (action == ComposeActivity.REPLY_ALL) { 1257 final Set<String> ccAddresses = Sets.newHashSet(); 1258 toAddresses = initToRecipients(account, accountEmail, fromAddress, replytoAddress, 1259 new String[0]); 1260 addToAddresses(toAddresses); 1261 addRecipients(accountEmail, ccAddresses, sentToAddresses); 1262 addRecipients(accountEmail, ccAddresses, 1263 Utils.splitCommaSeparatedString(refMessage.cc)); 1264 addCcAddresses(ccAddresses, toAddresses); 1265 } 1266 } 1267 1268 private String getAddress(String toParse) { 1269 if (!TextUtils.isEmpty(toParse)) { 1270 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(toParse); 1271 if (tokens.length > 0) { 1272 return tokens[0].getAddress(); 1273 } 1274 } 1275 return ""; 1276 } 1277 1278 private void addToAddresses(Collection<String> addresses) { 1279 addAddressesToList(addresses, mTo); 1280 } 1281 1282 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 1283 addCcAddressesToList(tokenizeAddressList(addresses), 1284 toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc); 1285 } 1286 1287 private void addBccAddresses(Collection<String> addresses) { 1288 addAddressesToList(addresses, mBcc); 1289 } 1290 1291 @VisibleForTesting 1292 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 1293 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 1294 String address; 1295 1296 if (compareToList == null) { 1297 for (Rfc822Token[] tokens : addresses) { 1298 for (int i = 0; i < tokens.length; i++) { 1299 address = tokens[i].toString(); 1300 list.append(address + END_TOKEN); 1301 } 1302 } 1303 } else { 1304 HashSet<String> compareTo = convertToHashSet(compareToList); 1305 for (Rfc822Token[] tokens : addresses) { 1306 for (int i = 0; i < tokens.length; i++) { 1307 address = tokens[i].toString(); 1308 // Check if this is a duplicate: 1309 if (!compareTo.contains(tokens[i].getAddress())) { 1310 // Get the address here 1311 list.append(address + END_TOKEN); 1312 } 1313 } 1314 } 1315 } 1316 } 1317 1318 private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) { 1319 HashSet<String> hash = new HashSet<String>(); 1320 for (Rfc822Token[] tokens : list) { 1321 for (int i = 0; i < tokens.length; i++) { 1322 hash.add(tokens[i].getAddress()); 1323 } 1324 } 1325 return hash; 1326 } 1327 1328 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 1329 @VisibleForTesting 1330 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 1331 1332 for (String address: addresses) { 1333 tokenized.add(Rfc822Tokenizer.tokenize(address)); 1334 } 1335 return tokenized; 1336 } 1337 1338 @VisibleForTesting 1339 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 1340 for (String address : addresses) { 1341 addAddressToList(address, list); 1342 } 1343 } 1344 1345 private void addAddressToList(String address, RecipientEditTextView list) { 1346 if (address == null || list == null) 1347 return; 1348 1349 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 1350 1351 for (int i = 0; i < tokens.length; i++) { 1352 list.append(tokens[i] + END_TOKEN); 1353 } 1354 } 1355 1356 @VisibleForTesting 1357 protected Collection<String> initToRecipients(String account, String accountEmail, 1358 String senderAddress, String replyToAddress, String[] inToAddresses) { 1359 // The To recipient is the reply-to address specified in the original 1360 // message, unless it is: 1361 // the current user OR a custom from of the current user, in which case 1362 // it's the To recipient list of the original message. 1363 // OR missing, in which case use the sender of the original message 1364 Set<String> toAddresses = Sets.newHashSet(); 1365 if (!TextUtils.isEmpty(replyToAddress)) { 1366 toAddresses.add(replyToAddress); 1367 } else { 1368 if (!TextUtils.equals(senderAddress, accountEmail) 1369 && !ReplyFromAccount.isCustomFrom(senderAddress, 1370 mFromSpinner.getReplyFromAccounts())) { 1371 toAddresses.add(senderAddress); 1372 } else { 1373 // This happens if the user replies to a message they originally 1374 // wrote. In this case, "reply" really means "re-send," so we 1375 // target the original recipients. This works as expected even 1376 // if the user sent the original message to themselves. 1377 toAddresses.addAll(Arrays.asList(inToAddresses)); 1378 } 1379 } 1380 return toAddresses; 1381 } 1382 1383 private static void addRecipients(String account, Set<String> recipients, String[] addresses) { 1384 for (String email : addresses) { 1385 // Do not add this account, or any of the custom froms, to the list 1386 // of recipients. 1387 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 1388 if (!account.equalsIgnoreCase(recipientAddress)) { 1389 recipients.add(email.replace("\"\"", "")); 1390 } 1391 } 1392 } 1393 1394 private void setSubject(Message refMessage, int action) { 1395 String subject = refMessage.subject; 1396 String prefix; 1397 String correctedSubject = null; 1398 if (action == ComposeActivity.COMPOSE) { 1399 prefix = ""; 1400 } else if (action == ComposeActivity.FORWARD) { 1401 prefix = getString(R.string.forward_subject_label); 1402 } else { 1403 prefix = getString(R.string.reply_subject_label); 1404 } 1405 1406 // Don't duplicate the prefix 1407 if (subject.toLowerCase().startsWith(prefix.toLowerCase())) { 1408 correctedSubject = subject; 1409 } else { 1410 correctedSubject = String 1411 .format(getString(R.string.formatted_subject), prefix, subject); 1412 } 1413 mSubject.setText(correctedSubject); 1414 } 1415 1416 private void initRecipients() { 1417 setupRecipients(mTo); 1418 setupRecipients(mCc); 1419 setupRecipients(mBcc); 1420 } 1421 1422 private void setupRecipients(RecipientEditTextView view) { 1423 view.setAdapter(new RecipientAdapter(this, mAccount)); 1424 view.setTokenizer(new Rfc822Tokenizer()); 1425 if (mValidator == null) { 1426 final String accountName = mAccount.name; 1427 int offset = accountName.indexOf("@") + 1; 1428 String account = accountName; 1429 if (offset > -1) { 1430 account = account.substring(accountName.indexOf("@") + 1); 1431 } 1432 mValidator = new Rfc822Validator(account); 1433 } 1434 view.setValidator(mValidator); 1435 } 1436 1437 @Override 1438 public void onClick(View v) { 1439 int id = v.getId(); 1440 switch (id) { 1441 case R.id.add_cc_bcc: 1442 // Verify that cc/ bcc aren't showing. 1443 // Animate in cc/bcc. 1444 showCcBccViews(); 1445 break; 1446 case R.id.add_attachment: 1447 openAttachmentTypeSelectionDialog(); 1448 break; 1449 } 1450 } 1451 1452 @Override 1453 public boolean onCreateOptionsMenu(Menu menu) { 1454 super.onCreateOptionsMenu(menu); 1455 MenuInflater inflater = getMenuInflater(); 1456 inflater.inflate(R.menu.compose_menu, menu); 1457 mSave = menu.findItem(R.id.save); 1458 mSend = menu.findItem(R.id.send); 1459 MenuItem helpItem = menu.findItem(R.id.help_info_menu_item); 1460 MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item); 1461 if (helpItem != null) { 1462 helpItem.setVisible(mAccount != null 1463 && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT)); 1464 } 1465 if (sendFeedbackItem != null) { 1466 sendFeedbackItem.setVisible(mAccount != null 1467 && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK)); 1468 } 1469 return true; 1470 } 1471 1472 @Override 1473 public boolean onPrepareOptionsMenu(Menu menu) { 1474 MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc); 1475 if (ccBcc != null && mCc != null) { 1476 // Its possible there is a menu item OR a button. 1477 boolean ccFieldVisible = mCc.isShown(); 1478 boolean bccFieldVisible = mBcc.isShown(); 1479 if (!ccFieldVisible || !bccFieldVisible) { 1480 ccBcc.setVisible(true); 1481 ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label 1482 : R.string.add_bcc_label)); 1483 } else { 1484 ccBcc.setVisible(false); 1485 } 1486 } 1487 if (mSave != null) { 1488 mSave.setEnabled(shouldSave()); 1489 } 1490 return true; 1491 } 1492 1493 @Override 1494 public boolean onOptionsItemSelected(MenuItem item) { 1495 int id = item.getItemId(); 1496 boolean handled = true; 1497 switch (id) { 1498 case R.id.add_attachment: 1499 openAttachmentTypeSelectionDialog(); 1500 break; 1501 case R.id.add_cc_bcc: 1502 showCcBccViews(); 1503 break; 1504 case R.id.save: 1505 doSave(true); 1506 break; 1507 case R.id.send: 1508 doSend(); 1509 break; 1510 case R.id.discard: 1511 doDiscard(); 1512 break; 1513 case R.id.settings: 1514 Utils.showSettings(this, mAccount); 1515 break; 1516 case android.R.id.home: 1517 onAppUpPressed(); 1518 break; 1519 case R.id.help_info_menu_item: 1520 // TODO: enable context sensitive help 1521 Utils.showHelp(this, mAccount, null); 1522 break; 1523 case R.id.feedback_menu_item: 1524 Utils.sendFeedback(this, mAccount); 1525 break; 1526 default: 1527 handled = false; 1528 break; 1529 } 1530 return !handled ? super.onOptionsItemSelected(item) : handled; 1531 } 1532 1533 private void onAppUpPressed() { 1534 if (mLaunchedFromEmail) { 1535 // If this was started from Gmail, simply treat app up as the system back button, so 1536 // that the last view is restored. 1537 onBackPressed(); 1538 return; 1539 } 1540 1541 // Fire the main activity to ensure it launches the "top" screen of mail. 1542 // Since the main Activity is singleTask, it should revive that task if it was already 1543 // started. 1544 Folder defaultInbox = new Folder(); 1545 defaultInbox.uri = mAccount.settings.defaultInbox; 1546 final Intent mailIntent = 1547 Utils.createViewFolderIntent(defaultInbox, mAccount); 1548 1549 mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 1550 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 1551 startActivity(mailIntent); 1552 finish(); 1553 } 1554 1555 private void doSend() { 1556 sendOrSaveWithSanityChecks(false, true, false); 1557 } 1558 1559 private void doSave(boolean showToast) { 1560 // Clear the IME composing suggestions from the body and subject before saving. 1561 clearImeText(mBodyView); 1562 clearImeText(mSubject); 1563 sendOrSaveWithSanityChecks(true, showToast, false); 1564 } 1565 1566 private void clearImeText(TextView v) { 1567 v.clearComposingText(); 1568 BaseInputConnection.removeComposingSpans(v.getEditableText()); 1569 } 1570 1571 @VisibleForTesting 1572 public interface SendOrSaveCallback { 1573 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask); 1574 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message); 1575 public Message getMessage(); 1576 public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success); 1577 } 1578 1579 @VisibleForTesting 1580 public static class SendOrSaveTask implements Runnable { 1581 private final Context mContext; 1582 @VisibleForTesting 1583 public final SendOrSaveCallback mSendOrSaveCallback; 1584 @VisibleForTesting 1585 public final SendOrSaveMessage mSendOrSaveMessage; 1586 1587 public SendOrSaveTask(Context context, SendOrSaveMessage message, 1588 SendOrSaveCallback callback) { 1589 mContext = context; 1590 mSendOrSaveCallback = callback; 1591 mSendOrSaveMessage = message; 1592 } 1593 1594 @Override 1595 public void run() { 1596 final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage; 1597 1598 final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount; 1599 Message message = mSendOrSaveCallback.getMessage(); 1600 long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID; 1601 // If a previous draft has been saved, in an account that is different 1602 // than what the user wants to send from, remove the old draft, and treat this 1603 // as a new message 1604 if (!selectedAccount.equals(sendOrSaveMessage.mAccount)) { 1605 if (messageId != UIProvider.INVALID_MESSAGE_ID) { 1606 ContentResolver resolver = mContext.getContentResolver(); 1607 ContentValues values = new ContentValues(); 1608 values.put(BaseColumns._ID, messageId); 1609 if (selectedAccount.account.expungeMessageUri != null) { 1610 resolver.update(selectedAccount.account.expungeMessageUri, values, null, 1611 null); 1612 } else { 1613 // TODO(mindyp) delete the conversation. 1614 } 1615 // reset messageId to 0, so a new message will be created 1616 messageId = UIProvider.INVALID_MESSAGE_ID; 1617 } 1618 } 1619 1620 final long messageIdToSave = messageId; 1621 if (messageIdToSave != UIProvider.INVALID_MESSAGE_ID) { 1622 sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave); 1623 mContext.getContentResolver().update( 1624 Uri.parse(sendOrSaveMessage.mSave ? message.saveUri : message.sendUri), 1625 sendOrSaveMessage.mValues, null, null); 1626 } else { 1627 ContentResolver resolver = mContext.getContentResolver(); 1628 Uri messageUri = resolver 1629 .insert(sendOrSaveMessage.mSave ? selectedAccount.account.saveDraftUri 1630 : selectedAccount.account.sendMessageUri, 1631 sendOrSaveMessage.mValues); 1632 if (sendOrSaveMessage.mSave && messageUri != null) { 1633 Cursor messageCursor = resolver.query(messageUri, 1634 UIProvider.MESSAGE_PROJECTION, null, null, null); 1635 if (messageCursor != null) { 1636 try { 1637 if (messageCursor.moveToFirst()) { 1638 // Broadcast notification that a new message has 1639 // been allocated 1640 mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, 1641 new Message(messageCursor)); 1642 } 1643 } finally { 1644 messageCursor.close(); 1645 } 1646 } 1647 } 1648 } 1649 1650 if (!sendOrSaveMessage.mSave) { 1651 UIProvider.incrementRecipientsTimesContacted(mContext, 1652 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO)); 1653 UIProvider.incrementRecipientsTimesContacted(mContext, 1654 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC)); 1655 UIProvider.incrementRecipientsTimesContacted(mContext, 1656 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC)); 1657 } 1658 mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true); 1659 } 1660 } 1661 1662 // Array of the outstanding send or save tasks. Access is synchronized 1663 // with the object itself 1664 /* package for testing */ 1665 @VisibleForTesting 1666 public ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList(); 1667 private int mRequestId; 1668 private String mSignature; 1669 private AttachmentTypeSelectorAdapter mAttachmentTypeSelectorAdapter; 1670 1671 @VisibleForTesting 1672 public static class SendOrSaveMessage { 1673 final ReplyFromAccount mAccount; 1674 final ContentValues mValues; 1675 final String mRefMessageId; 1676 @VisibleForTesting 1677 public final boolean mSave; 1678 final int mRequestId; 1679 1680 public SendOrSaveMessage(ReplyFromAccount account, ContentValues values, 1681 String refMessageId, boolean save) { 1682 mAccount = account; 1683 mValues = values; 1684 mRefMessageId = refMessageId; 1685 mSave = save; 1686 mRequestId = mValues.hashCode() ^ hashCode(); 1687 } 1688 1689 int requestId() { 1690 return mRequestId; 1691 } 1692 } 1693 1694 /** 1695 * Get the to recipients. 1696 */ 1697 public String[] getToAddresses() { 1698 return getAddressesFromList(mTo); 1699 } 1700 1701 /** 1702 * Get the cc recipients. 1703 */ 1704 public String[] getCcAddresses() { 1705 return getAddressesFromList(mCc); 1706 } 1707 1708 /** 1709 * Get the bcc recipients. 1710 */ 1711 public String[] getBccAddresses() { 1712 return getAddressesFromList(mBcc); 1713 } 1714 1715 public String[] getAddressesFromList(RecipientEditTextView list) { 1716 if (list == null) { 1717 return new String[0]; 1718 } 1719 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText()); 1720 int count = tokens.length; 1721 String[] result = new String[count]; 1722 for (int i = 0; i < count; i++) { 1723 result[i] = tokens[i].toString(); 1724 } 1725 return result; 1726 } 1727 1728 /** 1729 * Check for invalid email addresses. 1730 * @param to String array of email addresses to check. 1731 * @param wrongEmailsOut Emails addresses that were invalid. 1732 */ 1733 public void checkInvalidEmails(String[] to, List<String> wrongEmailsOut) { 1734 if (mValidator == null) { 1735 return; 1736 } 1737 for (String email : to) { 1738 if (!mValidator.isValid(email)) { 1739 wrongEmailsOut.add(email); 1740 } 1741 } 1742 } 1743 1744 /** 1745 * Show an error because the user has entered an invalid recipient. 1746 * @param message 1747 */ 1748 public void showRecipientErrorDialog(String message) { 1749 // Only 1 invalid recipients error dialog should be allowed up at a 1750 // time. 1751 if (mRecipientErrorDialog != null) { 1752 mRecipientErrorDialog.dismiss(); 1753 } 1754 mRecipientErrorDialog = new AlertDialog.Builder(this).setMessage(message).setTitle( 1755 R.string.recipient_error_dialog_title) 1756 .setIconAttribute(android.R.attr.alertDialogIcon) 1757 .setPositiveButton( 1758 R.string.ok, new Dialog.OnClickListener() { 1759 @Override 1760 public void onClick(DialogInterface dialog, int which) { 1761 // after the user dismisses the recipient error 1762 // dialog we want to make sure to refocus the 1763 // recipient to field so they can fix the issue 1764 // easily 1765 if (mTo != null) { 1766 mTo.requestFocus(); 1767 } 1768 mRecipientErrorDialog = null; 1769 } 1770 }).show(); 1771 } 1772 1773 /** 1774 * Update the state of the UI based on whether or not the current draft 1775 * needs to be saved and the message is not empty. 1776 */ 1777 public void updateSaveUi() { 1778 if (mSave != null) { 1779 mSave.setEnabled((shouldSave() && !isBlank())); 1780 } 1781 } 1782 1783 /** 1784 * Returns true if we need to save the current draft. 1785 */ 1786 private boolean shouldSave() { 1787 synchronized (mDraftLock) { 1788 // The message should only be saved if: 1789 // It hasn't been sent AND 1790 // Some text has been added to the message OR 1791 // an attachment has been added or removed 1792 // AND there is actually something in the draft to save. 1793 return (mTextChanged || mAttachmentsChanged || mReplyFromChanged) 1794 && !isBlank(); 1795 } 1796 } 1797 1798 /** 1799 * Check if all fields are blank. 1800 * @return boolean 1801 */ 1802 public boolean isBlank() { 1803 return mSubject.getText().length() == 0 1804 && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature, 1805 mBodyView.getText().toString()) == 0) 1806 && mTo.length() == 0 1807 && mCc.length() == 0 && mBcc.length() == 0 1808 && mAttachmentsView.getAttachments().size() == 0; 1809 } 1810 1811 @VisibleForTesting 1812 protected int getSignatureStartPosition(String signature, String bodyText) { 1813 int startPos = -1; 1814 1815 if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) { 1816 return startPos; 1817 } 1818 1819 int bodyLength = bodyText.length(); 1820 int signatureLength = signature.length(); 1821 String printableVersion = convertToPrintableSignature(signature); 1822 int printableLength = printableVersion.length(); 1823 1824 if (bodyLength >= printableLength 1825 && bodyText.substring(bodyLength - printableLength) 1826 .equals(printableVersion)) { 1827 startPos = bodyLength - printableLength; 1828 } else if (bodyLength >= signatureLength 1829 && bodyText.substring(bodyLength - signatureLength) 1830 .equals(signature)) { 1831 startPos = bodyLength - signatureLength; 1832 } 1833 return startPos; 1834 } 1835 1836 /** 1837 * Allows any changes made by the user to be ignored. Called when the user 1838 * decides to discard a draft. 1839 */ 1840 private void discardChanges() { 1841 mTextChanged = false; 1842 mAttachmentsChanged = false; 1843 mReplyFromChanged = false; 1844 } 1845 1846 /** 1847 * @param body 1848 * @param save 1849 * @param showToast 1850 * @return Whether the send or save succeeded. 1851 */ 1852 protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast, 1853 final boolean orientationChanged) { 1854 String[] to, cc, bcc; 1855 Editable body = mBodyView.getEditableText(); 1856 if (orientationChanged) { 1857 to = cc = bcc = new String[0]; 1858 } else { 1859 to = getToAddresses(); 1860 cc = getCcAddresses(); 1861 bcc = getBccAddresses(); 1862 } 1863 1864 // Don't let the user send to nobody (but it's okay to save a message 1865 // with no recipients) 1866 if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) { 1867 showRecipientErrorDialog(getString(R.string.recipient_needed)); 1868 return false; 1869 } 1870 1871 List<String> wrongEmails = new ArrayList<String>(); 1872 if (!save) { 1873 checkInvalidEmails(to, wrongEmails); 1874 checkInvalidEmails(cc, wrongEmails); 1875 checkInvalidEmails(bcc, wrongEmails); 1876 } 1877 1878 // Don't let the user send an email with invalid recipients 1879 if (wrongEmails.size() > 0) { 1880 String errorText = String.format(getString(R.string.invalid_recipient), 1881 wrongEmails.get(0)); 1882 showRecipientErrorDialog(errorText); 1883 return false; 1884 } 1885 1886 DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { 1887 @Override 1888 public void onClick(DialogInterface dialog, int which) { 1889 sendOrSave(mBodyView.getEditableText(), save, showToast, orientationChanged); 1890 } 1891 }; 1892 1893 // Show a warning before sending only if there are no attachments. 1894 if (!save) { 1895 if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) { 1896 boolean warnAboutEmptySubject = isSubjectEmpty(); 1897 boolean emptyBody = TextUtils.getTrimmedLength(body) == 0; 1898 1899 // A warning about an empty body may not be warranted when 1900 // forwarding mails, since a common use case is to forward 1901 // quoted text and not append any more text. 1902 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty()); 1903 1904 // When we bring up a dialog warning the user about a send, 1905 // assume that they accept sending the message. If they do not, 1906 // the dialog listener is required to enable sending again. 1907 if (warnAboutEmptySubject) { 1908 showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, listener); 1909 return true; 1910 } 1911 1912 if (warnAboutEmptyBody) { 1913 showSendConfirmDialog(R.string.confirm_send_message_with_no_body, listener); 1914 return true; 1915 } 1916 } 1917 // Ask for confirmation to send (if always required) 1918 if (showSendConfirmation()) { 1919 showSendConfirmDialog(R.string.confirm_send_message, listener); 1920 return true; 1921 } 1922 } 1923 1924 sendOrSave(body, save, showToast, false); 1925 return true; 1926 } 1927 1928 /** 1929 * Returns a boolean indicating whether warnings should be shown for empty 1930 * subject and body fields 1931 * 1932 * @return True if a warning should be shown for empty text fields 1933 */ 1934 protected boolean showEmptyTextWarnings() { 1935 return mAttachmentsView.getAttachments().size() == 0; 1936 } 1937 1938 /** 1939 * Returns a boolean indicating whether the user should confirm each send 1940 * 1941 * @return True if a warning should be on each send 1942 */ 1943 protected boolean showSendConfirmation() { 1944 return mCachedSettings != null ? mCachedSettings.confirmSend : false; 1945 } 1946 1947 private void showSendConfirmDialog(int messageId, DialogInterface.OnClickListener listener) { 1948 if (mSendConfirmDialog != null) { 1949 mSendConfirmDialog.dismiss(); 1950 mSendConfirmDialog = null; 1951 } 1952 mSendConfirmDialog = new AlertDialog.Builder(this).setMessage(messageId) 1953 .setTitle(R.string.confirm_send_title) 1954 .setIconAttribute(android.R.attr.alertDialogIcon) 1955 .setPositiveButton(R.string.send, listener) 1956 .setNegativeButton(R.string.cancel, this) 1957 .show(); 1958 } 1959 1960 /** 1961 * Returns whether the ComposeArea believes there is any text in the body of 1962 * the composition. TODO: When ComposeArea controls the Body as well, add 1963 * that here. 1964 */ 1965 public boolean isBodyEmpty() { 1966 return !mQuotedTextView.isTextIncluded(); 1967 } 1968 1969 /** 1970 * Test to see if the subject is empty. 1971 * 1972 * @return boolean. 1973 */ 1974 // TODO: this will likely go away when composeArea.focus() is implemented 1975 // after all the widget control is moved over. 1976 public boolean isSubjectEmpty() { 1977 return TextUtils.getTrimmedLength(mSubject.getText()) == 0; 1978 } 1979 1980 /* package */ 1981 static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount, 1982 Message message, final Message refMessage, Spanned body, final CharSequence quotedText, 1983 SendOrSaveCallback callback, Handler handler, boolean save, int composeMode) { 1984 ContentValues values = new ContentValues(); 1985 1986 String refMessageId = refMessage != null ? refMessage.uri.toString() : ""; 1987 1988 MessageModification.putToAddresses(values, message.getToAddresses()); 1989 MessageModification.putCcAddresses(values, message.getCcAddresses()); 1990 MessageModification.putBccAddresses(values, message.getBccAddresses()); 1991 1992 MessageModification.putCustomFromAddress(values, message.from); 1993 1994 MessageModification.putSubject(values, message.subject); 1995 String htmlBody = Html.toHtml(body); 1996 1997 boolean includeQuotedText = !TextUtils.isEmpty(quotedText); 1998 StringBuilder fullBody = new StringBuilder(htmlBody); 1999 if (includeQuotedText) { 2000 // HTML gets converted to text for now 2001 final String text = quotedText.toString(); 2002 if (QuotedTextView.containsQuotedText(text)) { 2003 int pos = QuotedTextView.getQuotedTextOffset(text); 2004 final int quoteStartPos = fullBody.length() + pos; 2005 fullBody.append(text); 2006 MessageModification.putQuoteStartPos(values, quoteStartPos); 2007 MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD); 2008 MessageModification.putAppendRefMessageContent(values, includeQuotedText); 2009 } else { 2010 LogUtils.w(LOG_TAG, "Couldn't find quoted text"); 2011 // This shouldn't happen, but just use what we have, 2012 // and don't do server-side expansion 2013 fullBody.append(text); 2014 } 2015 } 2016 int draftType = getDraftType(composeMode); 2017 MessageModification.putDraftType(values, draftType); 2018 if (refMessage != null) { 2019 if (!TextUtils.isEmpty(refMessage.bodyHtml)) { 2020 MessageModification.putBodyHtml(values, fullBody.toString()); 2021 } 2022 if (!TextUtils.isEmpty(refMessage.bodyText)) { 2023 MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString()); 2024 } 2025 } else { 2026 MessageModification.putBodyHtml(values, fullBody.toString()); 2027 MessageModification.putBody(values, Html.fromHtml(fullBody.toString()).toString()); 2028 } 2029 MessageModification.putAttachments(values, message.getAttachments()); 2030 if (!TextUtils.isEmpty(refMessageId)) { 2031 MessageModification.putRefMessageId(values, refMessageId); 2032 } 2033 2034 SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(replyFromAccount, 2035 values, refMessageId, save); 2036 SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback); 2037 2038 callback.initializeSendOrSave(sendOrSaveTask); 2039 2040 // Do the send/save action on the specified handler to avoid possible 2041 // ANRs 2042 handler.post(sendOrSaveTask); 2043 2044 return sendOrSaveMessage.requestId(); 2045 } 2046 2047 private static int getDraftType(int mode) { 2048 int draftType = -1; 2049 switch (mode) { 2050 case ComposeActivity.COMPOSE: 2051 draftType = DraftType.COMPOSE; 2052 break; 2053 case ComposeActivity.REPLY: 2054 draftType = DraftType.REPLY; 2055 break; 2056 case ComposeActivity.REPLY_ALL: 2057 draftType = DraftType.REPLY_ALL; 2058 break; 2059 case ComposeActivity.FORWARD: 2060 draftType = DraftType.FORWARD; 2061 break; 2062 } 2063 return draftType; 2064 } 2065 2066 private void sendOrSave(Spanned body, boolean save, boolean showToast, 2067 boolean orientationChanged) { 2068 // Check if user is a monkey. Monkeys can compose and hit send 2069 // button but are not allowed to send anything off the device. 2070 if (ActivityManager.isUserAMonkey()) { 2071 return; 2072 } 2073 2074 String[] to, cc, bcc; 2075 if (orientationChanged) { 2076 to = cc = bcc = new String[0]; 2077 } else { 2078 to = getToAddresses(); 2079 cc = getCcAddresses(); 2080 bcc = getBccAddresses(); 2081 } 2082 2083 SendOrSaveCallback callback = new SendOrSaveCallback() { 2084 private int mRestoredRequestId; 2085 2086 @Override 2087 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) { 2088 synchronized (mActiveTasks) { 2089 int numTasks = mActiveTasks.size(); 2090 if (numTasks == 0) { 2091 // Start service so we won't be killed if this app is 2092 // put in the background. 2093 startService(new Intent(ComposeActivity.this, EmptyService.class)); 2094 } 2095 2096 mActiveTasks.add(sendOrSaveTask); 2097 } 2098 if (sTestSendOrSaveCallback != null) { 2099 sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask); 2100 } 2101 } 2102 2103 @Override 2104 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, 2105 Message message) { 2106 synchronized (mDraftLock) { 2107 mDraftId = message.id; 2108 mDraft = message; 2109 if (sRequestMessageIdMap != null) { 2110 sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId); 2111 } 2112 // Cache request message map, in case the process is killed 2113 saveRequestMap(); 2114 } 2115 if (sTestSendOrSaveCallback != null) { 2116 sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message); 2117 } 2118 } 2119 2120 @Override 2121 public Message getMessage() { 2122 synchronized (mDraftLock) { 2123 return mDraft; 2124 } 2125 } 2126 2127 @Override 2128 public void sendOrSaveFinished(SendOrSaveTask task, boolean success) { 2129 if (success) { 2130 // Successfully sent or saved so reset change markers 2131 discardChanges(); 2132 } else { 2133 // A failure happened with saving/sending the draft 2134 // TODO(pwestbro): add a better string that should be used 2135 // when failing to send or save 2136 Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT) 2137 .show(); 2138 } 2139 2140 int numTasks; 2141 synchronized (mActiveTasks) { 2142 // Remove the task from the list of active tasks 2143 mActiveTasks.remove(task); 2144 numTasks = mActiveTasks.size(); 2145 } 2146 2147 if (numTasks == 0) { 2148 // Stop service so we can be killed. 2149 stopService(new Intent(ComposeActivity.this, EmptyService.class)); 2150 } 2151 if (sTestSendOrSaveCallback != null) { 2152 sTestSendOrSaveCallback.sendOrSaveFinished(task, success); 2153 } 2154 } 2155 }; 2156 2157 // Get the selected account if the from spinner has been setup. 2158 ReplyFromAccount selectedAccount = mReplyFromAccount; 2159 String fromAddress = selectedAccount.name; 2160 if (selectedAccount == null || fromAddress == null) { 2161 // We don't have either the selected account or from address, 2162 // use mAccount. 2163 selectedAccount = mReplyFromAccount; 2164 fromAddress = mAccount.name; 2165 } 2166 2167 if (mSendSaveTaskHandler == null) { 2168 HandlerThread handlerThread = new HandlerThread("Send Message Task Thread"); 2169 handlerThread.start(); 2170 2171 mSendSaveTaskHandler = new Handler(handlerThread.getLooper()); 2172 } 2173 2174 Message msg = createMessage(mReplyFromAccount, getMode()); 2175 mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body, 2176 mQuotedTextView.getQuotedTextIfIncluded(), callback, 2177 mSendSaveTaskHandler, save, mComposeMode); 2178 2179 if (mRecipient != null && mRecipient.equals(mAccount.name)) { 2180 mRecipient = selectedAccount.name; 2181 } 2182 setAccount(selectedAccount.account); 2183 2184 // Don't display the toast if the user is just changing the orientation, 2185 // but we still need to save the draft to the cursor because this is how we restore 2186 // the attachments when the configuration change completes. 2187 if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 2188 Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, 2189 Toast.LENGTH_LONG).show(); 2190 } 2191 2192 // Need to update variables here because the send or save completes 2193 // asynchronously even though the toast shows right away. 2194 discardChanges(); 2195 updateSaveUi(); 2196 2197 // If we are sending, finish the activity 2198 if (!save) { 2199 finish(); 2200 } 2201 } 2202 2203 /** 2204 * Save the state of the request messageid map. This allows for the Gmail 2205 * process to be killed, but and still allow for ComposeActivity instances 2206 * to be recreated correctly. 2207 */ 2208 private void saveRequestMap() { 2209 // TODO: store the request map in user preferences. 2210 } 2211 2212 public void openAttachmentTypeSelectionDialog() { 2213 AlertDialog.Builder builder = new AlertDialog.Builder(this); 2214 builder.setTitle(R.string.add_file_attachment); 2215 builder.setAdapter(new AttachmentTypeSelectorAdapter(this), 2216 new DialogInterface.OnClickListener() { 2217 public void onClick(DialogInterface dialog, int position) { 2218 doAttach(position); 2219 } 2220 }); 2221 builder.show(); 2222 } 2223 2224 private void doAttach(int position) { 2225 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 2226 i.addCategory(Intent.CATEGORY_OPENABLE); 2227 i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 2228 i.setType(AttachmentTypeSelectorAdapter.ITEMS.get(position).mMimeType); 2229 mAddingAttachment = true; 2230 startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)), 2231 RESULT_PICK_ATTACHMENT); 2232 } 2233 2234 private void showCcBccViews() { 2235 mCcBccView.show(true, true, true); 2236 if (mCcBccButton != null) { 2237 mCcBccButton.setVisibility(View.GONE); 2238 } 2239 } 2240 2241 @Override 2242 public boolean onNavigationItemSelected(int position, long itemId) { 2243 int initialComposeMode = mComposeMode; 2244 if (position == ComposeActivity.REPLY) { 2245 mComposeMode = ComposeActivity.REPLY; 2246 } else if (position == ComposeActivity.REPLY_ALL) { 2247 mComposeMode = ComposeActivity.REPLY_ALL; 2248 } else if (position == ComposeActivity.FORWARD) { 2249 mComposeMode = ComposeActivity.FORWARD; 2250 } 2251 clearChangeListeners(); 2252 if (initialComposeMode != mComposeMode) { 2253 resetMessageForModeChange(); 2254 if (mDraft == null && mRefMessage != null) { 2255 setFieldsFromRefMessage(mComposeMode, mAccount.name); 2256 } 2257 boolean showCc = false; 2258 boolean showBcc = false; 2259 if (mDraft != null) { 2260 // Following desktop behavior, if the user has added a BCC 2261 // field to a draft, we show it regardless of compose mode. 2262 showBcc = !TextUtils.isEmpty(mDraft.bcc); 2263 // Use the draft to determine what to populate. 2264 // If the Bcc field is showing, show the Cc field whether it is populated or not. 2265 showCc = showBcc || (!TextUtils.isEmpty(mDraft.cc) && mComposeMode == REPLY_ALL); 2266 } else if (mRefMessage != null) { 2267 showCc = mComposeMode == REPLY_ALL && !TextUtils.isEmpty(mRefMessage.cc); 2268 } 2269 mCcBccView.show(false, showCc, showBcc); 2270 } 2271 updateHideOrShowCcBcc(); 2272 initChangeListeners(); 2273 return true; 2274 } 2275 2276 @VisibleForTesting 2277 protected void resetMessageForModeChange() { 2278 // When switching between reply, reply all, forward, 2279 // follow the behavior of webview. 2280 // The contents of the following fields are cleared 2281 // so that they can be populated directly from the 2282 // ref message: 2283 // 1) Any recipient fields 2284 // 2) The subject 2285 mTo.setText(""); 2286 mCc.setText(""); 2287 mBcc.setText(""); 2288 // Any edits to the subject are replaced with the original subject. 2289 mSubject.setText(""); 2290 2291 // Any changes to the contents of the following fields are kept: 2292 // 1) Body 2293 // 2) Attachments 2294 // If the user made changes to attachments, keep their changes. 2295 if (!mAttachmentsChanged) { 2296 mAttachmentsView.deleteAllAttachments(); 2297 } 2298 } 2299 2300 private class ComposeModeAdapter extends ArrayAdapter<String> { 2301 2302 private LayoutInflater mInflater; 2303 2304 public ComposeModeAdapter(Context context) { 2305 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 2306 .getStringArray(R.array.compose_modes)); 2307 } 2308 2309 private LayoutInflater getInflater() { 2310 if (mInflater == null) { 2311 mInflater = LayoutInflater.from(getContext()); 2312 } 2313 return mInflater; 2314 } 2315 2316 @Override 2317 public View getView(int position, View convertView, ViewGroup parent) { 2318 if (convertView == null) { 2319 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 2320 } 2321 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 2322 return super.getView(position, convertView, parent); 2323 } 2324 } 2325 2326 @Override 2327 public void onRespondInline(String text) { 2328 appendToBody(text, false); 2329 } 2330 2331 /** 2332 * Append text to the body of the message. If there is no existing body 2333 * text, just sets the body to text. 2334 * 2335 * @param text 2336 * @param withSignature True to append a signature. 2337 */ 2338 public void appendToBody(CharSequence text, boolean withSignature) { 2339 Editable bodyText = mBodyView.getEditableText(); 2340 if (bodyText != null && bodyText.length() > 0) { 2341 bodyText.append(text); 2342 } else { 2343 setBody(text, withSignature); 2344 } 2345 } 2346 2347 /** 2348 * Set the body of the message. 2349 * 2350 * @param text 2351 * @param withSignature True to append a signature. 2352 */ 2353 public void setBody(CharSequence text, boolean withSignature) { 2354 mBodyView.setText(text); 2355 if (withSignature) { 2356 appendSignature(); 2357 } 2358 } 2359 2360 private void appendSignature() { 2361 String newSignature = mCachedSettings != null ? mCachedSettings.signature : null; 2362 boolean hasFocus = mBodyView.hasFocus(); 2363 if (!TextUtils.equals(newSignature, mSignature)) { 2364 mSignature = newSignature; 2365 if (!TextUtils.isEmpty(mSignature) 2366 && getSignatureStartPosition(mSignature, 2367 mBodyView.getText().toString()) < 0) { 2368 // Appending a signature does not count as changing text. 2369 mBodyView.removeTextChangedListener(this); 2370 mBodyView.append(convertToPrintableSignature(mSignature)); 2371 mBodyView.addTextChangedListener(this); 2372 } 2373 if (hasFocus) { 2374 focusBody(); 2375 } 2376 } 2377 } 2378 2379 private String convertToPrintableSignature(String signature) { 2380 String signatureResource = getResources().getString(R.string.signature); 2381 if (signature == null) { 2382 signature = ""; 2383 } 2384 return String.format(signatureResource, signature); 2385 } 2386 2387 @Override 2388 public void onAccountChanged() { 2389 mReplyFromAccount = mFromSpinner.getCurrentAccount(); 2390 if (!mAccount.equals(mReplyFromAccount.account)) { 2391 setAccount(mReplyFromAccount.account); 2392 2393 // TODO: handle discarding attachments when switching accounts. 2394 // Only enable save for this draft if there is any other content 2395 // in the message. 2396 if (!isBlank()) { 2397 enableSave(true); 2398 } 2399 mReplyFromChanged = true; 2400 initRecipients(); 2401 } 2402 } 2403 2404 public void enableSave(boolean enabled) { 2405 if (mSave != null) { 2406 mSave.setEnabled(enabled); 2407 } 2408 } 2409 2410 public void enableSend(boolean enabled) { 2411 if (mSend != null) { 2412 mSend.setEnabled(enabled); 2413 } 2414 } 2415 2416 /** 2417 * Handles button clicks from any error dialogs dealing with sending 2418 * a message. 2419 */ 2420 @Override 2421 public void onClick(DialogInterface dialog, int which) { 2422 switch (which) { 2423 case DialogInterface.BUTTON_POSITIVE: { 2424 doDiscardWithoutConfirmation(true /* show toast */ ); 2425 break; 2426 } 2427 case DialogInterface.BUTTON_NEGATIVE: { 2428 // If the user cancels the send, re-enable the send button. 2429 enableSend(true); 2430 break; 2431 } 2432 } 2433 2434 } 2435 2436 private void doDiscard() { 2437 new AlertDialog.Builder(this).setMessage(R.string.confirm_discard_text) 2438 .setPositiveButton(R.string.ok, this) 2439 .setNegativeButton(R.string.cancel, null) 2440 .create().show(); 2441 } 2442 /** 2443 * Effectively discard the current message. 2444 * 2445 * This method is either invoked from the menu or from the dialog 2446 * once the user has confirmed that they want to discard the message. 2447 * @param showToast show "Message discarded" toast if true 2448 */ 2449 private void doDiscardWithoutConfirmation(boolean showToast) { 2450 synchronized (mDraftLock) { 2451 if (mDraftId != UIProvider.INVALID_MESSAGE_ID) { 2452 ContentValues values = new ContentValues(); 2453 values.put(BaseColumns._ID, mDraftId); 2454 if (mAccount.expungeMessageUri != null) { 2455 getContentResolver().update(mAccount.expungeMessageUri, values, null, null); 2456 } else { 2457 getContentResolver().delete(mDraft.uri, null, null); 2458 } 2459 // This is not strictly necessary (since we should not try to 2460 // save the draft after calling this) but it ensures that if we 2461 // do save again for some reason we make a new draft rather than 2462 // trying to resave an expunged draft. 2463 mDraftId = UIProvider.INVALID_MESSAGE_ID; 2464 } 2465 } 2466 2467 if (showToast) { 2468 // Display a toast to let the user know 2469 Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show(); 2470 } 2471 2472 // This prevents the draft from being saved in onPause(). 2473 discardChanges(); 2474 finish(); 2475 } 2476 2477 private void saveIfNeeded() { 2478 if (mAccount == null) { 2479 // We have not chosen an account yet so there's no way that we can save. This is ok, 2480 // though, since we are saving our state before AccountsActivity is activated. Thus, the 2481 // user has not interacted with us yet and there is no real state to save. 2482 return; 2483 } 2484 2485 if (shouldSave()) { 2486 doSave(!mAddingAttachment /* show toast */); 2487 } 2488 } 2489 2490 @Override 2491 public void onAttachmentDeleted() { 2492 mAttachmentsChanged = true; 2493 updateSaveUi(); 2494 } 2495 2496 2497 /** 2498 * This is called any time one of our text fields changes. 2499 */ 2500 @Override 2501 public void afterTextChanged(Editable s) { 2502 mTextChanged = true; 2503 updateSaveUi(); 2504 } 2505 2506 @Override 2507 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 2508 // Do nothing. 2509 } 2510 2511 @Override 2512 public void onTextChanged(CharSequence s, int start, int before, int count) { 2513 // Do nothing. 2514 } 2515 2516 2517 // There is a big difference between the text associated with an address changing 2518 // to add the display name or to format properly and a recipient being added or deleted. 2519 // Make sure we only notify of changes when a recipient has been added or deleted. 2520 private class RecipientTextWatcher implements TextWatcher { 2521 private HashMap<String, Integer> mContent = new HashMap<String, Integer>(); 2522 2523 private RecipientEditTextView mView; 2524 2525 private TextWatcher mListener; 2526 2527 public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) { 2528 mView = view; 2529 mListener = listener; 2530 } 2531 2532 @Override 2533 public void afterTextChanged(Editable s) { 2534 if (hasChanged()) { 2535 mListener.afterTextChanged(s); 2536 } 2537 } 2538 2539 private boolean hasChanged() { 2540 String[] currRecips = tokenizeRecips(getAddressesFromList(mView)); 2541 int totalCount = currRecips.length; 2542 int totalPrevCount = 0; 2543 for (Entry<String, Integer> entry : mContent.entrySet()) { 2544 totalPrevCount += entry.getValue(); 2545 } 2546 if (totalCount != totalPrevCount) { 2547 return true; 2548 } 2549 2550 for (String recip : currRecips) { 2551 if (!mContent.containsKey(recip)) { 2552 return true; 2553 } else { 2554 int count = mContent.get(recip) - 1; 2555 if (count < 0) { 2556 return true; 2557 } else { 2558 mContent.put(recip, count); 2559 } 2560 } 2561 } 2562 return false; 2563 } 2564 2565 private String[] tokenizeRecips(String[] recips) { 2566 // Tokenize them all and put them in the list. 2567 String[] recipAddresses = new String[recips.length]; 2568 for (int i = 0; i < recips.length; i++) { 2569 recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress(); 2570 } 2571 return recipAddresses; 2572 } 2573 2574 @Override 2575 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 2576 String[] recips = tokenizeRecips(getAddressesFromList(mView)); 2577 for (String recip : recips) { 2578 if (!mContent.containsKey(recip)) { 2579 mContent.put(recip, 1); 2580 } else { 2581 mContent.put(recip, (mContent.get(recip)) + 1); 2582 } 2583 } 2584 } 2585 2586 @Override 2587 public void onTextChanged(CharSequence s, int start, int before, int count) { 2588 // Do nothing. 2589 } 2590 } 2591 2592 public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) { 2593 if (sTestSendOrSaveCallback != null && testCallback != null) { 2594 throw new IllegalStateException("Attempting to register more than one test callback"); 2595 } 2596 sTestSendOrSaveCallback = testCallback; 2597 } 2598 2599 @VisibleForTesting 2600 protected ArrayList<Attachment> getAttachments() { 2601 return mAttachmentsView.getAttachments(); 2602 } 2603 2604 @Override 2605 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 2606 switch (id) { 2607 case REFERENCE_MESSAGE_LOADER: 2608 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 2609 null, null); 2610 } 2611 return null; 2612 } 2613 2614 @Override 2615 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 2616 if (data != null && data.moveToFirst()) { 2617 mRefMessage = new Message(data); 2618 // We set these based on EXTRA_TO. 2619 mRefMessage.to = null; 2620 mRefMessage.from = null; 2621 Intent intent = getIntent(); 2622 int action = intent.getIntExtra(EXTRA_ACTION, COMPOSE); 2623 initFromRefMessage(action, mAccount.name); 2624 finishSetup(action, intent, null, true); 2625 if (action != FORWARD) { 2626 String to = intent.getStringExtra(EXTRA_TO); 2627 if (!TextUtils.isEmpty(to)) { 2628 clearChangeListeners(); 2629 mTo.append(to); 2630 initChangeListeners(); 2631 } 2632 } 2633 } else { 2634 finish(); 2635 } 2636 } 2637 2638 @Override 2639 public void onLoaderReset(Loader<Cursor> arg0) { 2640 // Do nothing. 2641 } 2642}