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