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