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