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