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