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