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