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