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