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