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 17 package com.android.mail.compose; 18 19 import android.app.ActionBar; 20 import android.app.ActionBar.OnNavigationListener; 21 import android.app.Activity; 22 import android.app.ActivityManager; 23 import android.app.AlertDialog; 24 import android.app.Dialog; 25 import android.app.DialogFragment; 26 import android.app.Fragment; 27 import android.app.FragmentTransaction; 28 import android.app.LoaderManager; 29 import android.content.ContentResolver; 30 import android.content.ContentValues; 31 import android.content.Context; 32 import android.content.CursorLoader; 33 import android.content.DialogInterface; 34 import android.content.Intent; 35 import android.content.Loader; 36 import android.content.pm.ActivityInfo; 37 import android.content.res.Resources; 38 import android.database.Cursor; 39 import android.net.Uri; 40 import android.os.Bundle; 41 import android.os.Handler; 42 import android.os.HandlerThread; 43 import android.os.ParcelFileDescriptor; 44 import android.os.Parcelable; 45 import android.provider.BaseColumns; 46 import android.text.Editable; 47 import android.text.Html; 48 import android.text.SpannableString; 49 import android.text.Spanned; 50 import android.text.TextUtils; 51 import android.text.TextWatcher; 52 import android.text.util.Rfc822Token; 53 import android.text.util.Rfc822Tokenizer; 54 import android.view.Gravity; 55 import android.view.KeyEvent; 56 import android.view.LayoutInflater; 57 import android.view.Menu; 58 import android.view.MenuInflater; 59 import android.view.MenuItem; 60 import android.view.View; 61 import android.view.View.OnClickListener; 62 import android.view.ViewGroup; 63 import android.view.inputmethod.BaseInputConnection; 64 import android.view.inputmethod.EditorInfo; 65 import android.widget.ArrayAdapter; 66 import android.widget.Button; 67 import android.widget.EditText; 68 import android.widget.TextView; 69 import android.widget.Toast; 70 71 import com.android.common.Rfc822Validator; 72 import com.android.common.contacts.DataUsageStatUpdater; 73 import com.android.ex.chips.RecipientEditTextView; 74 import com.android.mail.MailIntentService; 75 import com.android.mail.R; 76 import com.android.mail.analytics.Analytics; 77 import com.android.mail.browse.MessageHeaderView; 78 import com.android.mail.compose.AttachmentsView.AttachmentAddedOrDeletedListener; 79 import com.android.mail.compose.AttachmentsView.AttachmentFailureException; 80 import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener; 81 import com.android.mail.compose.QuotedTextView.RespondInlineListener; 82 import com.android.mail.providers.Account; 83 import com.android.mail.providers.Address; 84 import com.android.mail.providers.Attachment; 85 import com.android.mail.providers.Folder; 86 import com.android.mail.providers.MailAppProvider; 87 import com.android.mail.providers.Message; 88 import com.android.mail.providers.MessageModification; 89 import com.android.mail.providers.ReplyFromAccount; 90 import com.android.mail.providers.Settings; 91 import com.android.mail.providers.UIProvider; 92 import com.android.mail.providers.UIProvider.AccountCapabilities; 93 import com.android.mail.providers.UIProvider.DraftType; 94 import com.android.mail.ui.AttachmentTile.AttachmentPreview; 95 import com.android.mail.ui.FeedbackEnabledActivity; 96 import com.android.mail.ui.MailActivity; 97 import com.android.mail.ui.WaitFragment; 98 import com.android.mail.utils.AccountUtils; 99 import com.android.mail.utils.AttachmentUtils; 100 import com.android.mail.utils.ContentProviderTask; 101 import com.android.mail.utils.LogTag; 102 import com.android.mail.utils.LogUtils; 103 import com.android.mail.utils.Utils; 104 import com.google.common.annotations.VisibleForTesting; 105 import com.google.common.collect.Lists; 106 import com.google.common.collect.Sets; 107 108 import java.io.FileNotFoundException; 109 import java.io.IOException; 110 import java.io.UnsupportedEncodingException; 111 import java.net.URLDecoder; 112 import java.util.ArrayList; 113 import java.util.Arrays; 114 import java.util.Collection; 115 import java.util.HashMap; 116 import java.util.HashSet; 117 import java.util.List; 118 import java.util.Map.Entry; 119 import java.util.Set; 120 import java.util.concurrent.ConcurrentHashMap; 121 122 public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener, 123 RespondInlineListener, TextWatcher, 124 AttachmentAddedOrDeletedListener, OnAccountChangedListener, 125 LoaderManager.LoaderCallbacks<Cursor>, TextView.OnEditorActionListener, 126 FeedbackEnabledActivity { 127 // Identifiers for which type of composition this is 128 public static final int COMPOSE = -1; 129 public static final int REPLY = 0; 130 public static final int REPLY_ALL = 1; 131 public static final int FORWARD = 2; 132 public static final int EDIT_DRAFT = 3; 133 134 // Integer extra holding one of the above compose action 135 protected static final String EXTRA_ACTION = "action"; 136 137 private static final String EXTRA_SHOW_CC = "showCc"; 138 private static final String EXTRA_SHOW_BCC = "showBcc"; 139 private static final String EXTRA_RESPONDED_INLINE = "respondedInline"; 140 private static final String EXTRA_SAVE_ENABLED = "saveEnabled"; 141 142 private static final String UTF8_ENCODING_NAME = "UTF-8"; 143 144 private static final String MAIL_TO = "mailto"; 145 146 private static final String EXTRA_SUBJECT = "subject"; 147 148 private static final String EXTRA_BODY = "body"; 149 150 /** 151 * Expected to be html formatted text. 152 */ 153 private static final String EXTRA_QUOTED_TEXT = "quotedText"; 154 155 protected static final String EXTRA_FROM_ACCOUNT_STRING = "fromAccountString"; 156 157 private static final String EXTRA_ATTACHMENT_PREVIEWS = "attachmentPreviews"; 158 159 // Extra that we can get passed from other activities 160 @VisibleForTesting 161 protected static final String EXTRA_TO = "to"; 162 private static final String EXTRA_CC = "cc"; 163 private static final String EXTRA_BCC = "bcc"; 164 165 /** 166 * An optional extra containing a {@link ContentValues} of values to be added to 167 * {@link SendOrSaveMessage#mValues}. 168 */ 169 public static final String EXTRA_VALUES = "extra-values"; 170 171 // List of all the fields 172 static final String[] ALL_EXTRAS = { EXTRA_SUBJECT, EXTRA_BODY, EXTRA_TO, EXTRA_CC, EXTRA_BCC, 173 EXTRA_QUOTED_TEXT }; 174 175 private static SendOrSaveCallback sTestSendOrSaveCallback = null; 176 // Map containing information about requests to create new messages, and the id of the 177 // messages that were the result of those requests. 178 // 179 // This map is used when the activity that initiated the save a of a new message, is killed 180 // before the save has completed (and when we know the id of the newly created message). When 181 // a save is completed, the service that is running in the background, will update the map 182 // 183 // When a new ComposeActivity instance is created, it will attempt to use the information in 184 // the previously instantiated map. If ComposeActivity.onCreate() is called, with a bundle 185 // (restoring data from a previous instance), and the map hasn't been created, we will attempt 186 // to populate the map with data stored in shared preferences. 187 // FIXME: values in this map are never read. 188 private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null; 189 /** 190 * Notifies the {@code Activity} that the caller is an Email 191 * {@code Activity}, so that the back behavior may be modified accordingly. 192 * 193 * @see #onAppUpPressed 194 */ 195 public static final String EXTRA_FROM_EMAIL_TASK = "fromemail"; 196 197 public static final String EXTRA_ATTACHMENTS = "attachments"; 198 199 /** If set, we will clear notifications for this folder. */ 200 public static final String EXTRA_NOTIFICATION_FOLDER = "extra-notification-folder"; 201 202 // If this is a reply/forward then this extra will hold the original message 203 private static final String EXTRA_IN_REFERENCE_TO_MESSAGE = "in-reference-to-message"; 204 // If this is a reply/forward then this extra will hold a uri we must query 205 // to get the original message. 206 protected static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-message-uri"; 207 // If this is an action to edit an existing draft message, this extra will hold the 208 // draft message 209 private static final String ORIGINAL_DRAFT_MESSAGE = "original-draft-message"; 210 private static final String END_TOKEN = ", "; 211 private static final String LOG_TAG = LogTag.getLogTag(); 212 // Request numbers for activities we start 213 private static final int RESULT_PICK_ATTACHMENT = 1; 214 private static final int RESULT_CREATE_ACCOUNT = 2; 215 // TODO(mindyp) set mime-type for auto send? 216 public static final String AUTO_SEND_ACTION = "com.android.mail.action.AUTO_SEND"; 217 218 private static final String EXTRA_SELECTED_REPLY_FROM_ACCOUNT = "replyFromAccount"; 219 private static final String EXTRA_REQUEST_ID = "requestId"; 220 private static final String EXTRA_FOCUS_SELECTION_START = "focusSelectionStart"; 221 private static final String EXTRA_FOCUS_SELECTION_END = "focusSelectionEnd"; 222 private static final String EXTRA_MESSAGE = "extraMessage"; 223 private static final int REFERENCE_MESSAGE_LOADER = 0; 224 private static final int LOADER_ACCOUNT_CURSOR = 1; 225 private static final int INIT_DRAFT_USING_REFERENCE_MESSAGE = 2; 226 private static final String EXTRA_SELECTED_ACCOUNT = "selectedAccount"; 227 private static final String TAG_WAIT = "wait-fragment"; 228 private static final String MIME_TYPE_PHOTO = "image/*"; 229 private static final String MIME_TYPE_VIDEO = "video/*"; 230 231 private static final String KEY_INNER_SAVED_STATE = "compose_state"; 232 233 /** 234 * A single thread for running tasks in the background. 235 */ 236 private Handler mSendSaveTaskHandler = null; 237 private RecipientEditTextView mTo; 238 private RecipientEditTextView mCc; 239 private RecipientEditTextView mBcc; 240 private Button mCcBccButton; 241 private CcBccView mCcBccView; 242 private AttachmentsView mAttachmentsView; 243 protected Account mAccount; 244 protected ReplyFromAccount mReplyFromAccount; 245 private Settings mCachedSettings; 246 private Rfc822Validator mValidator; 247 private TextView mSubject; 248 249 private ComposeModeAdapter mComposeModeAdapter; 250 protected int mComposeMode = -1; 251 private boolean mForward; 252 private QuotedTextView mQuotedTextView; 253 protected EditText mBodyView; 254 private View mFromStatic; 255 private TextView mFromStaticText; 256 private View mFromSpinnerWrapper; 257 @VisibleForTesting 258 protected FromAddressSpinner mFromSpinner; 259 private boolean mAddingAttachment; 260 private boolean mAttachmentsChanged; 261 private boolean mTextChanged; 262 private boolean mReplyFromChanged; 263 private MenuItem mSave; 264 private MenuItem mSend; 265 @VisibleForTesting 266 protected Message mRefMessage; 267 private long mDraftId = UIProvider.INVALID_MESSAGE_ID; 268 private Message mDraft; 269 private ReplyFromAccount mDraftAccount; 270 private Object mDraftLock = new Object(); 271 private View mPhotoAttachmentsButton; 272 private View mVideoAttachmentsButton; 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 (at) 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 // TODO: this behavior is wrong. Pull the name from selectedReplyFromAccount.name 944 final String senderName = mAccount != null ? mAccount.getSenderName() : null; 945 final Address address = new Address(senderName, email); 946 message.setFrom(address.pack()); 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(), account.name, 1096 account.getEmailAddress(), true, false); 1097 } 1098 1099 private ReplyFromAccount getReplyFromAccountFromDraft(Account account, Message msg) { 1100 String sender = msg.getFrom(); 1101 ReplyFromAccount replyFromAccount = null; 1102 List<ReplyFromAccount> replyFromAccounts = mFromSpinner.getReplyFromAccounts(); 1103 if (TextUtils.equals(account.getEmailAddress(), sender)) { 1104 replyFromAccount = new ReplyFromAccount(mAccount, mAccount.uri, 1105 mAccount.getEmailAddress(), mAccount.name, mAccount.getEmailAddress(), 1106 true, false); 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 mPhotoAttachmentsButton = findViewById(R.id.add_photo_attachment); 1127 if (mPhotoAttachmentsButton != null) { 1128 mPhotoAttachmentsButton.setOnClickListener(this); 1129 } 1130 mVideoAttachmentsButton = findViewById(R.id.add_video_attachment); 1131 if (mVideoAttachmentsButton != null) { 1132 mVideoAttachmentsButton.setOnClickListener(this); 1133 } 1134 mTo = (RecipientEditTextView) findViewById(R.id.to); 1135 mTo.setTokenizer(new Rfc822Tokenizer()); 1136 mCc = (RecipientEditTextView) findViewById(R.id.cc); 1137 mCc.setTokenizer(new Rfc822Tokenizer()); 1138 mBcc = (RecipientEditTextView) findViewById(R.id.bcc); 1139 mBcc.setTokenizer(new Rfc822Tokenizer()); 1140 // TODO: add special chips text change watchers before adding 1141 // this as a text changed watcher to the to, cc, bcc fields. 1142 mSubject = (TextView) findViewById(R.id.subject); 1143 mSubject.setOnEditorActionListener(this); 1144 mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view); 1145 mQuotedTextView.setRespondInlineListener(this); 1146 mBodyView = (EditText) findViewById(R.id.body); 1147 mFromStatic = findViewById(R.id.static_from_content); 1148 mFromStaticText = (TextView) findViewById(R.id.from_account_name); 1149 mFromSpinnerWrapper = findViewById(R.id.spinner_from_content); 1150 mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker); 1151 } 1152 1153 @Override 1154 public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) { 1155 if (action == EditorInfo.IME_ACTION_DONE) { 1156 focusBody(); 1157 return true; 1158 } 1159 return false; 1160 } 1161 1162 protected TextView getBody() { 1163 return mBodyView; 1164 } 1165 1166 @VisibleForTesting 1167 public Account getFromAccount() { 1168 return mReplyFromAccount != null && mReplyFromAccount.account != null ? 1169 mReplyFromAccount.account : mAccount; 1170 } 1171 1172 private void clearChangeListeners() { 1173 mSubject.removeTextChangedListener(this); 1174 mBodyView.removeTextChangedListener(this); 1175 mTo.removeTextChangedListener(mToListener); 1176 mCc.removeTextChangedListener(mCcListener); 1177 mBcc.removeTextChangedListener(mBccListener); 1178 mFromSpinner.setOnAccountChangedListener(null); 1179 mAttachmentsView.setAttachmentChangesListener(null); 1180 } 1181 1182 // Now that the message has been initialized from any existing draft or 1183 // ref message data, set up listeners for any changes that occur to the 1184 // message. 1185 private void initChangeListeners() { 1186 // Make sure we only add text changed listeners once! 1187 clearChangeListeners(); 1188 mSubject.addTextChangedListener(this); 1189 mBodyView.addTextChangedListener(this); 1190 if (mToListener == null) { 1191 mToListener = new RecipientTextWatcher(mTo, this); 1192 } 1193 mTo.addTextChangedListener(mToListener); 1194 if (mCcListener == null) { 1195 mCcListener = new RecipientTextWatcher(mCc, this); 1196 } 1197 mCc.addTextChangedListener(mCcListener); 1198 if (mBccListener == null) { 1199 mBccListener = new RecipientTextWatcher(mBcc, this); 1200 } 1201 mBcc.addTextChangedListener(mBccListener); 1202 mFromSpinner.setOnAccountChangedListener(this); 1203 mAttachmentsView.setAttachmentChangesListener(this); 1204 } 1205 1206 private void initActionBar() { 1207 LogUtils.d(LOG_TAG, "initializing action bar in ComposeActivity"); 1208 ActionBar actionBar = getActionBar(); 1209 if (actionBar == null) { 1210 return; 1211 } 1212 if (mComposeMode == ComposeActivity.COMPOSE) { 1213 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); 1214 actionBar.setTitle(R.string.compose); 1215 } else { 1216 actionBar.setTitle(null); 1217 if (mComposeModeAdapter == null) { 1218 mComposeModeAdapter = new ComposeModeAdapter(this); 1219 } 1220 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); 1221 actionBar.setListNavigationCallbacks(mComposeModeAdapter, this); 1222 switch (mComposeMode) { 1223 case ComposeActivity.REPLY: 1224 actionBar.setSelectedNavigationItem(0); 1225 break; 1226 case ComposeActivity.REPLY_ALL: 1227 actionBar.setSelectedNavigationItem(1); 1228 break; 1229 case ComposeActivity.FORWARD: 1230 actionBar.setSelectedNavigationItem(2); 1231 break; 1232 } 1233 } 1234 actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME, 1235 ActionBar.DISPLAY_HOME_AS_UP | ActionBar.DISPLAY_SHOW_HOME); 1236 actionBar.setHomeButtonEnabled(true); 1237 } 1238 1239 private void initFromRefMessage(int action) { 1240 setFieldsFromRefMessage(action); 1241 1242 // Check if To: address and email body needs to be prefilled based on extras. 1243 // This is used for reporting rendering feedback. 1244 if (MessageHeaderView.ENABLE_REPORT_RENDERING_PROBLEM) { 1245 Intent intent = getIntent(); 1246 if (intent.getExtras() != null) { 1247 String toAddresses = intent.getStringExtra(EXTRA_TO); 1248 if (toAddresses != null) { 1249 addToAddresses(Arrays.asList(TextUtils.split(toAddresses, ","))); 1250 } 1251 String body = intent.getStringExtra(EXTRA_BODY); 1252 if (body != null) { 1253 setBody(body, false /* withSignature */); 1254 } 1255 } 1256 } 1257 1258 if (mRefMessage != null) { 1259 // CC field only gets populated when doing REPLY_ALL. 1260 // BCC never gets auto-populated, unless the user is editing 1261 // a draft with one. 1262 if (!TextUtils.isEmpty(mCc.getText()) && action == REPLY_ALL) { 1263 mCcBccView.show(false, true, false); 1264 } 1265 } 1266 updateHideOrShowCcBcc(); 1267 } 1268 1269 private void setFieldsFromRefMessage(int action) { 1270 setSubject(mRefMessage, action); 1271 // Setup recipients 1272 if (action == FORWARD) { 1273 mForward = true; 1274 } 1275 initRecipientsFromRefMessage(mRefMessage, action); 1276 initQuotedTextFromRefMessage(mRefMessage, action); 1277 if (action == ComposeActivity.FORWARD || mAttachmentsChanged) { 1278 initAttachments(mRefMessage); 1279 } 1280 } 1281 1282 private void initFromDraftMessage(Message message) { 1283 LogUtils.d(LOG_TAG, "Intializing draft from previous draft message: %s", message); 1284 1285 mDraft = message; 1286 mDraftId = message.id; 1287 mSubject.setText(message.subject); 1288 mForward = message.draftType == UIProvider.DraftType.FORWARD; 1289 final List<String> toAddresses = Arrays.asList(message.getToAddressesUnescaped()); 1290 addToAddresses(toAddresses); 1291 addCcAddresses(Arrays.asList(message.getCcAddressesUnescaped()), toAddresses); 1292 addBccAddresses(Arrays.asList(message.getBccAddressesUnescaped())); 1293 if (message.hasAttachments) { 1294 List<Attachment> attachments = message.getAttachments(); 1295 for (Attachment a : attachments) { 1296 addAttachmentAndUpdateView(a); 1297 } 1298 } 1299 int quotedTextIndex = message.appendRefMessageContent ? 1300 message.quotedTextOffset : -1; 1301 // Set the body 1302 CharSequence quotedText = null; 1303 if (!TextUtils.isEmpty(message.bodyHtml)) { 1304 CharSequence htmlText = ""; 1305 if (quotedTextIndex > -1) { 1306 // Find the offset in the htmltext of the actual quoted text and strip it out. 1307 quotedTextIndex = QuotedTextView.findQuotedTextIndex(message.bodyHtml); 1308 if (quotedTextIndex > -1) { 1309 htmlText = Utils.convertHtmlToPlainText(message.bodyHtml.substring(0, 1310 quotedTextIndex)); 1311 quotedText = message.bodyHtml.subSequence(quotedTextIndex, 1312 message.bodyHtml.length()); 1313 } 1314 } else { 1315 htmlText = Utils.convertHtmlToPlainText(message.bodyHtml); 1316 } 1317 mBodyView.setText(htmlText); 1318 } else { 1319 final String body = message.bodyText; 1320 final CharSequence bodyText = !TextUtils.isEmpty(body) ? 1321 (quotedTextIndex > -1 ? 1322 message.bodyText.substring(0, quotedTextIndex) : message.bodyText) 1323 : ""; 1324 if (quotedTextIndex > -1) { 1325 quotedText = !TextUtils.isEmpty(body) ? message.bodyText.substring(quotedTextIndex) 1326 : null; 1327 } 1328 mBodyView.setText(bodyText); 1329 } 1330 if (quotedTextIndex > -1 && quotedText != null) { 1331 mQuotedTextView.setQuotedTextFromDraft(quotedText, mForward); 1332 } 1333 } 1334 1335 /** 1336 * Fill all the widgets with the content found in the Intent Extra, if any. 1337 * Also apply the same style to all widgets. Note: if initFromExtras is 1338 * called as a result of switching between reply, reply all, and forward per 1339 * the latest revision of Gmail, and the user has already made changes to 1340 * attachments on a previous incarnation of the message (as a reply, reply 1341 * all, or forward), the original attachments from the message will not be 1342 * re-instantiated. The user's changes will be respected. This follows the 1343 * web gmail interaction. 1344 * @return {@code true} if the activity should not call {@link #finishSetup}. 1345 */ 1346 public boolean initFromExtras(Intent intent) { 1347 // If we were invoked with a SENDTO intent, the value 1348 // should take precedence 1349 final Uri dataUri = intent.getData(); 1350 if (dataUri != null) { 1351 if (MAIL_TO.equals(dataUri.getScheme())) { 1352 initFromMailTo(dataUri.toString()); 1353 } else { 1354 if (!mAccount.composeIntentUri.equals(dataUri)) { 1355 String toText = dataUri.getSchemeSpecificPart(); 1356 if (toText != null) { 1357 mTo.setText(""); 1358 addToAddresses(Arrays.asList(TextUtils.split(toText, ","))); 1359 } 1360 } 1361 } 1362 } 1363 1364 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 1365 if (extraStrings != null) { 1366 addToAddresses(Arrays.asList(extraStrings)); 1367 } 1368 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 1369 if (extraStrings != null) { 1370 addCcAddresses(Arrays.asList(extraStrings), null); 1371 } 1372 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 1373 if (extraStrings != null) { 1374 addBccAddresses(Arrays.asList(extraStrings)); 1375 } 1376 1377 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 1378 if (extraString != null) { 1379 mSubject.setText(extraString); 1380 } 1381 1382 for (String extra : ALL_EXTRAS) { 1383 if (intent.hasExtra(extra)) { 1384 String value = intent.getStringExtra(extra); 1385 if (EXTRA_TO.equals(extra)) { 1386 addToAddresses(Arrays.asList(TextUtils.split(value, ","))); 1387 } else if (EXTRA_CC.equals(extra)) { 1388 addCcAddresses(Arrays.asList(TextUtils.split(value, ",")), null); 1389 } else if (EXTRA_BCC.equals(extra)) { 1390 addBccAddresses(Arrays.asList(TextUtils.split(value, ","))); 1391 } else if (EXTRA_SUBJECT.equals(extra)) { 1392 mSubject.setText(value); 1393 } else if (EXTRA_BODY.equals(extra)) { 1394 setBody(value, true /* with signature */); 1395 } else if (EXTRA_QUOTED_TEXT.equals(extra)) { 1396 initQuotedText(value, true /* shouldQuoteText */); 1397 } 1398 } 1399 } 1400 1401 Bundle extras = intent.getExtras(); 1402 if (extras != null) { 1403 CharSequence text = extras.getCharSequence(Intent.EXTRA_TEXT); 1404 if (text != null) { 1405 setBody(text, true /* with signature */); 1406 } 1407 1408 // TODO - support EXTRA_HTML_TEXT 1409 } 1410 1411 mExtraValues = intent.getParcelableExtra(EXTRA_VALUES); 1412 if (mExtraValues != null) { 1413 LogUtils.d(LOG_TAG, "Launched with extra values: %s", mExtraValues.toString()); 1414 initExtraValues(mExtraValues); 1415 return true; 1416 } 1417 1418 return false; 1419 } 1420 1421 protected void initExtraValues(ContentValues extraValues) { 1422 // DO NOTHING - Gmail will override 1423 } 1424 1425 1426 @VisibleForTesting 1427 protected String decodeEmailInUri(String s) throws UnsupportedEncodingException { 1428 // TODO: handle the case where there are spaces in the display name as 1429 // well as the email such as "Guy with spaces <guy+with+spaces (at) gmail.com>" 1430 // as they could be encoded ambiguously. 1431 // Since URLDecode.decode changes + into ' ', and + is a valid 1432 // email character, we need to find/ replace these ourselves before 1433 // decoding. 1434 try { 1435 return URLDecoder.decode(replacePlus(s), UTF8_ENCODING_NAME); 1436 } catch (IllegalArgumentException e) { 1437 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 1438 LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), s); 1439 } else { 1440 LogUtils.e(LOG_TAG, e, "Exception while decoding mailto address"); 1441 } 1442 return null; 1443 } 1444 } 1445 1446 /** 1447 * Replaces all occurrences of '+' with "%2B", to prevent URLDecode.decode from 1448 * changing '+' into ' ' 1449 * 1450 * @param toReplace Input string 1451 * @return The string with all "+" characters replaced with "%2B" 1452 */ 1453 private static String replacePlus(String toReplace) { 1454 return toReplace.replace("+", "%2B"); 1455 } 1456 1457 /** 1458 * Initialize the compose view from a String representing a mailTo uri. 1459 * @param mailToString The uri as a string. 1460 */ 1461 public void initFromMailTo(String mailToString) { 1462 // We need to disguise this string as a URI in order to parse it 1463 // TODO: Remove this hack when http://b/issue?id=1445295 gets fixed 1464 Uri uri = Uri.parse("foo://" + mailToString); 1465 int index = mailToString.indexOf("?"); 1466 int length = "mailto".length() + 1; 1467 String to; 1468 try { 1469 // Extract the recipient after mailto: 1470 if (index == -1) { 1471 to = decodeEmailInUri(mailToString.substring(length)); 1472 } else { 1473 to = decodeEmailInUri(mailToString.substring(length, index)); 1474 } 1475 if (!TextUtils.isEmpty(to)) { 1476 addToAddresses(Arrays.asList(TextUtils.split(to, ","))); 1477 } 1478 } catch (UnsupportedEncodingException e) { 1479 if (LogUtils.isLoggable(LOG_TAG, LogUtils.VERBOSE)) { 1480 LogUtils.e(LOG_TAG, "%s while decoding '%s'", e.getMessage(), mailToString); 1481 } else { 1482 LogUtils.e(LOG_TAG, e, "Exception while decoding mailto address"); 1483 } 1484 } 1485 1486 List<String> cc = uri.getQueryParameters("cc"); 1487 addCcAddresses(Arrays.asList(cc.toArray(new String[cc.size()])), null); 1488 1489 List<String> otherTo = uri.getQueryParameters("to"); 1490 addToAddresses(Arrays.asList(otherTo.toArray(new String[otherTo.size()]))); 1491 1492 List<String> bcc = uri.getQueryParameters("bcc"); 1493 addBccAddresses(Arrays.asList(bcc.toArray(new String[bcc.size()]))); 1494 1495 List<String> subject = uri.getQueryParameters("subject"); 1496 if (subject.size() > 0) { 1497 try { 1498 mSubject.setText(URLDecoder.decode(replacePlus(subject.get(0)), 1499 UTF8_ENCODING_NAME)); 1500 } catch (UnsupportedEncodingException e) { 1501 LogUtils.e(LOG_TAG, "%s while decoding subject '%s'", 1502 e.getMessage(), subject); 1503 } 1504 } 1505 1506 List<String> body = uri.getQueryParameters("body"); 1507 if (body.size() > 0) { 1508 try { 1509 setBody(URLDecoder.decode(replacePlus(body.get(0)), UTF8_ENCODING_NAME), 1510 true /* with signature */); 1511 } catch (UnsupportedEncodingException e) { 1512 LogUtils.e(LOG_TAG, "%s while decoding body '%s'", e.getMessage(), body); 1513 } 1514 } 1515 } 1516 1517 @VisibleForTesting 1518 protected void initAttachments(Message refMessage) { 1519 addAttachments(refMessage.getAttachments()); 1520 } 1521 1522 public long addAttachments(List<Attachment> attachments) { 1523 long size = 0; 1524 AttachmentFailureException error = null; 1525 for (Attachment a : attachments) { 1526 try { 1527 size += mAttachmentsView.addAttachment(mAccount, a); 1528 } catch (AttachmentFailureException e) { 1529 error = e; 1530 } 1531 } 1532 if (error != null) { 1533 LogUtils.e(LOG_TAG, error, "Error adding attachment"); 1534 if (attachments.size() > 1) { 1535 showAttachmentTooBigToast(R.string.too_large_to_attach_multiple); 1536 } else { 1537 showAttachmentTooBigToast(error.getErrorRes()); 1538 } 1539 } 1540 return size; 1541 } 1542 1543 /** 1544 * When an attachment is too large to be added to a message, show a toast. 1545 * This method also updates the position of the toast so that it is shown 1546 * clearly above they keyboard if it happens to be open. 1547 */ 1548 private void showAttachmentTooBigToast(int errorRes) { 1549 String maxSize = AttachmentUtils.convertToHumanReadableSize( 1550 getApplicationContext(), mAccount.settings.getMaxAttachmentSize()); 1551 showErrorToast(getString(errorRes, maxSize)); 1552 } 1553 1554 private void showErrorToast(String message) { 1555 Toast t = Toast.makeText(this, message, Toast.LENGTH_LONG); 1556 t.setText(message); 1557 t.setGravity(Gravity.CENTER_HORIZONTAL, 0, 1558 getResources().getDimensionPixelSize(R.dimen.attachment_toast_yoffset)); 1559 t.show(); 1560 } 1561 1562 private void initAttachmentsFromIntent(Intent intent) { 1563 Bundle extras = intent.getExtras(); 1564 if (extras == null) { 1565 extras = Bundle.EMPTY; 1566 } 1567 final String action = intent.getAction(); 1568 if (!mAttachmentsChanged) { 1569 long totalSize = 0; 1570 if (extras.containsKey(EXTRA_ATTACHMENTS)) { 1571 String[] uris = (String[]) extras.getSerializable(EXTRA_ATTACHMENTS); 1572 for (String uriString : uris) { 1573 final Uri uri = Uri.parse(uriString); 1574 long size = 0; 1575 try { 1576 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1577 size = mAttachmentsView.addAttachment(mAccount, a); 1578 1579 Analytics.getInstance().sendEvent("send_intent_attachment", 1580 Utils.normalizeMimeType(a.getContentType()), null, size); 1581 1582 } catch (AttachmentFailureException e) { 1583 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1584 showAttachmentTooBigToast(e.getErrorRes()); 1585 } 1586 totalSize += size; 1587 } 1588 } 1589 if (extras.containsKey(Intent.EXTRA_STREAM)) { 1590 if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { 1591 ArrayList<Parcelable> uris = extras 1592 .getParcelableArrayList(Intent.EXTRA_STREAM); 1593 ArrayList<Attachment> attachments = new ArrayList<Attachment>(); 1594 for (Parcelable uri : uris) { 1595 try { 1596 final Attachment a = mAttachmentsView.generateLocalAttachment( 1597 (Uri) uri); 1598 attachments.add(a); 1599 1600 Analytics.getInstance().sendEvent("send_intent_attachment", 1601 Utils.normalizeMimeType(a.getContentType()), null, a.size); 1602 1603 } catch (AttachmentFailureException e) { 1604 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1605 String maxSize = AttachmentUtils.convertToHumanReadableSize( 1606 getApplicationContext(), 1607 mAccount.settings.getMaxAttachmentSize()); 1608 showErrorToast(getString 1609 (R.string.generic_attachment_problem, maxSize)); 1610 } 1611 } 1612 totalSize += addAttachments(attachments); 1613 } else { 1614 final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); 1615 long size = 0; 1616 try { 1617 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1618 size = mAttachmentsView.addAttachment(mAccount, a); 1619 1620 Analytics.getInstance().sendEvent("send_intent_attachment", 1621 Utils.normalizeMimeType(a.getContentType()), null, size); 1622 1623 } catch (AttachmentFailureException e) { 1624 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1625 showAttachmentTooBigToast(e.getErrorRes()); 1626 } 1627 totalSize += size; 1628 } 1629 } 1630 1631 if (totalSize > 0) { 1632 mAttachmentsChanged = true; 1633 updateSaveUi(); 1634 1635 Analytics.getInstance().sendEvent("send_intent_with_attachments", 1636 Integer.toString(getAttachments().size()), null, totalSize); 1637 } 1638 } 1639 } 1640 1641 protected void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) { 1642 mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText); 1643 mShowQuotedText = true; 1644 } 1645 1646 private void initQuotedTextFromRefMessage(Message refMessage, int action) { 1647 if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) { 1648 mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD); 1649 } 1650 } 1651 1652 private void updateHideOrShowCcBcc() { 1653 // Its possible there is a menu item OR a button. 1654 boolean ccVisible = mCcBccView.isCcVisible(); 1655 boolean bccVisible = mCcBccView.isBccVisible(); 1656 if (mCcBccButton != null) { 1657 if (!ccVisible || !bccVisible) { 1658 mCcBccButton.setVisibility(View.VISIBLE); 1659 mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label 1660 : R.string.add_bcc_label)); 1661 } else { 1662 mCcBccButton.setVisibility(View.INVISIBLE); 1663 } 1664 } 1665 } 1666 1667 private void showCcBcc(Bundle state) { 1668 if (state != null && state.containsKey(EXTRA_SHOW_CC)) { 1669 boolean showCc = state.getBoolean(EXTRA_SHOW_CC); 1670 boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC); 1671 if (showCc || showBcc) { 1672 mCcBccView.show(false, showCc, showBcc); 1673 } 1674 } 1675 } 1676 1677 /** 1678 * Add attachment and update the compose area appropriately. 1679 * @param data 1680 */ 1681 public void addAttachmentAndUpdateView(Intent data) { 1682 addAttachmentAndUpdateView(data != null ? data.getData() : (Uri) null); 1683 } 1684 1685 public void addAttachmentAndUpdateView(Uri contentUri) { 1686 if (contentUri == null) { 1687 return; 1688 } 1689 try { 1690 addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri)); 1691 } catch (AttachmentFailureException e) { 1692 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1693 showErrorToast(getResources().getString( 1694 e.getErrorRes(), 1695 AttachmentUtils.convertToHumanReadableSize( 1696 getApplicationContext(), mAccount.settings.getMaxAttachmentSize()))); 1697 } 1698 } 1699 1700 public void addAttachmentAndUpdateView(Attachment attachment) { 1701 try { 1702 long size = mAttachmentsView.addAttachment(mAccount, attachment); 1703 if (size > 0) { 1704 mAttachmentsChanged = true; 1705 updateSaveUi(); 1706 } 1707 } catch (AttachmentFailureException e) { 1708 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1709 showAttachmentTooBigToast(e.getErrorRes()); 1710 } 1711 } 1712 1713 void initRecipientsFromRefMessage(Message refMessage, int action) { 1714 // Don't populate the address if this is a forward. 1715 if (action == ComposeActivity.FORWARD) { 1716 return; 1717 } 1718 initReplyRecipients(refMessage, action); 1719 } 1720 1721 // TODO: This should be private. This method shouldn't be used by ComposeActivityTests, as 1722 // it doesn't setup the state of the activity correctly 1723 @VisibleForTesting 1724 void initReplyRecipients(final Message refMessage, final int action) { 1725 String[] sentToAddresses = refMessage.getToAddressesUnescaped(); 1726 final Collection<String> toAddresses; 1727 final String[] replyToAddresses = refMessage.getReplyToAddressesUnescaped(); 1728 String replyToAddress = replyToAddresses.length > 0 ? replyToAddresses[0] : null; 1729 final String[] fromAddresses = refMessage.getFromAddressesUnescaped(); 1730 final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null; 1731 1732 // If there is no reply to address, the reply to address is the sender. 1733 if (TextUtils.isEmpty(replyToAddress)) { 1734 replyToAddress = fromAddress; 1735 } 1736 1737 // If this is a reply, the Cc list is empty. If this is a reply-all, the 1738 // Cc list is the union of the To and Cc recipients of the original 1739 // message, excluding the current user's email address and any addresses 1740 // already on the To list. 1741 if (action == ComposeActivity.REPLY) { 1742 toAddresses = initToRecipients(fromAddress, replyToAddress, sentToAddresses); 1743 addToAddresses(toAddresses); 1744 } else if (action == ComposeActivity.REPLY_ALL) { 1745 final Set<String> ccAddresses = Sets.newHashSet(); 1746 toAddresses = initToRecipients(fromAddress, replyToAddress, sentToAddresses); 1747 addToAddresses(toAddresses); 1748 addRecipients(ccAddresses, sentToAddresses); 1749 addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped()); 1750 addCcAddresses(ccAddresses, toAddresses); 1751 } 1752 } 1753 1754 private void addToAddresses(Collection<String> addresses) { 1755 addAddressesToList(addresses, mTo); 1756 } 1757 1758 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 1759 addCcAddressesToList(tokenizeAddressList(addresses), 1760 toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc); 1761 } 1762 1763 private void addBccAddresses(Collection<String> addresses) { 1764 addAddressesToList(addresses, mBcc); 1765 } 1766 1767 @VisibleForTesting 1768 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 1769 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 1770 String address; 1771 1772 if (compareToList == null) { 1773 for (Rfc822Token[] tokens : addresses) { 1774 for (int i = 0; i < tokens.length; i++) { 1775 address = tokens[i].toString(); 1776 list.append(address + END_TOKEN); 1777 } 1778 } 1779 } else { 1780 HashSet<String> compareTo = convertToHashSet(compareToList); 1781 for (Rfc822Token[] tokens : addresses) { 1782 for (int i = 0; i < tokens.length; i++) { 1783 address = tokens[i].toString(); 1784 // Check if this is a duplicate: 1785 if (!compareTo.contains(tokens[i].getAddress())) { 1786 // Get the address here 1787 list.append(address + END_TOKEN); 1788 } 1789 } 1790 } 1791 } 1792 } 1793 1794 private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) { 1795 final HashSet<String> hash = new HashSet<String>(); 1796 for (final Rfc822Token[] tokens : list) { 1797 for (int i = 0; i < tokens.length; i++) { 1798 hash.add(tokens[i].getAddress()); 1799 } 1800 } 1801 return hash; 1802 } 1803 1804 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 1805 @VisibleForTesting 1806 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 1807 1808 for (String address: addresses) { 1809 tokenized.add(Rfc822Tokenizer.tokenize(address)); 1810 } 1811 return tokenized; 1812 } 1813 1814 @VisibleForTesting 1815 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 1816 for (String address : addresses) { 1817 addAddressToList(address, list); 1818 } 1819 } 1820 1821 private static void addAddressToList(final String address, final RecipientEditTextView list) { 1822 if (address == null || list == null) 1823 return; 1824 1825 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 1826 1827 for (int i = 0; i < tokens.length; i++) { 1828 list.append(tokens[i] + END_TOKEN); 1829 } 1830 } 1831 1832 @VisibleForTesting 1833 protected Collection<String> initToRecipients(final String fullSenderAddress, 1834 final String replyToAddress, final String[] inToAddresses) { 1835 // The To recipient is the reply-to address specified in the original 1836 // message, unless it is: 1837 // the current user OR a custom from of the current user, in which case 1838 // it's the To recipient list of the original message. 1839 // OR missing, in which case use the sender of the original message 1840 Set<String> toAddresses = Sets.newHashSet(); 1841 if (!TextUtils.isEmpty(replyToAddress) && !recipientMatchesThisAccount(replyToAddress)) { 1842 toAddresses.add(replyToAddress); 1843 } else { 1844 // In this case, the user is replying to a message in which their 1845 // current account or one of their custom from addresses is the only 1846 // recipient and they sent the original message. 1847 if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress) 1848 && recipientMatchesThisAccount(inToAddresses[0])) { 1849 toAddresses.add(inToAddresses[0]); 1850 return toAddresses; 1851 } 1852 // This happens if the user replies to a message they originally 1853 // wrote. In this case, "reply" really means "re-send," so we 1854 // target the original recipients. This works as expected even 1855 // if the user sent the original message to themselves. 1856 for (String address : inToAddresses) { 1857 if (!recipientMatchesThisAccount(address)) { 1858 toAddresses.add(address); 1859 } 1860 } 1861 } 1862 return toAddresses; 1863 } 1864 1865 private void addRecipients(final Set<String> recipients, final String[] addresses) { 1866 for (final String email : addresses) { 1867 // Do not add this account, or any of its custom from addresses, to 1868 // the list of recipients. 1869 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 1870 if (!recipientMatchesThisAccount(recipientAddress)) { 1871 recipients.add(email.replace("\"\"", "")); 1872 } 1873 } 1874 } 1875 1876 /** 1877 * A recipient matches this account if it has the same address as the 1878 * currently selected account OR one of the custom from addresses associated 1879 * with the currently selected account. 1880 * @param recipientAddress address we are comparing with the currently selected account 1881 * @return 1882 */ 1883 protected boolean recipientMatchesThisAccount(String recipientAddress) { 1884 return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress, 1885 mAccount.getReplyFroms()); 1886 } 1887 1888 /** 1889 * Returns a formatted subject string with the appropriate prefix for the action type. 1890 * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}. 1891 */ 1892 public static String buildFormattedSubject(Resources res, String subject, int action) { 1893 String prefix; 1894 String correctedSubject = null; 1895 if (action == ComposeActivity.COMPOSE) { 1896 prefix = ""; 1897 } else if (action == ComposeActivity.FORWARD) { 1898 prefix = res.getString(R.string.forward_subject_label); 1899 } else { 1900 prefix = res.getString(R.string.reply_subject_label); 1901 } 1902 1903 // Don't duplicate the prefix 1904 if (!TextUtils.isEmpty(subject) 1905 && subject.toLowerCase().startsWith(prefix.toLowerCase())) { 1906 correctedSubject = subject; 1907 } else { 1908 correctedSubject = String.format( 1909 res.getString(R.string.formatted_subject), prefix, subject); 1910 } 1911 1912 return correctedSubject; 1913 } 1914 1915 private void setSubject(Message refMessage, int action) { 1916 mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action)); 1917 } 1918 1919 private void initRecipients() { 1920 setupRecipients(mTo); 1921 setupRecipients(mCc); 1922 setupRecipients(mBcc); 1923 } 1924 1925 private void setupRecipients(RecipientEditTextView view) { 1926 view.setAdapter(new RecipientAdapter(this, mAccount)); 1927 if (mValidator == null) { 1928 final String accountName = mAccount.getEmailAddress(); 1929 int offset = accountName.indexOf("@") + 1; 1930 String account = accountName; 1931 if (offset > 0) { 1932 account = account.substring(offset); 1933 } 1934 mValidator = new Rfc822Validator(account); 1935 } 1936 view.setValidator(mValidator); 1937 } 1938 1939 @Override 1940 public void onClick(View v) { 1941 final int id = v.getId(); 1942 if (id == R.id.add_cc_bcc) { 1943 // Verify that cc/ bcc aren't showing. 1944 // Animate in cc/bcc. 1945 showCcBccViews(); 1946 } else if (id == R.id.add_photo_attachment) { 1947 doAttach(MIME_TYPE_PHOTO); 1948 } else if (id == R.id.add_video_attachment) { 1949 doAttach(MIME_TYPE_VIDEO); 1950 } 1951 } 1952 1953 @Override 1954 public boolean onCreateOptionsMenu(Menu menu) { 1955 final boolean superCreated = super.onCreateOptionsMenu(menu); 1956 // Don't render any menu items when there are no accounts. 1957 if (mAccounts == null || mAccounts.length == 0) { 1958 return superCreated; 1959 } 1960 MenuInflater inflater = getMenuInflater(); 1961 inflater.inflate(R.menu.compose_menu, menu); 1962 1963 /* 1964 * Start save in the correct enabled state. 1965 * 1) If a user launches compose from within gmail, save is disabled 1966 * until they add something, at which point, save is enabled, auto save 1967 * on exit; if the user empties everything, save is disabled, exiting does not 1968 * auto-save 1969 * 2) if a user replies/ reply all/ forwards from within gmail, save is 1970 * disabled until they change something, at which point, save is 1971 * enabled, auto save on exit; if the user empties everything, save is 1972 * disabled, exiting does not auto-save. 1973 * 3) If a user launches compose from another application and something 1974 * gets populated (attachments, recipients, body, subject, etc), save is 1975 * enabled, auto save on exit; if the user empties everything, save is 1976 * disabled, exiting does not auto-save 1977 */ 1978 mSave = menu.findItem(R.id.save); 1979 String action = getIntent() != null ? getIntent().getAction() : null; 1980 enableSave(mInnerSavedState != null ? 1981 mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED) 1982 : (Intent.ACTION_SEND.equals(action) 1983 || Intent.ACTION_SEND_MULTIPLE.equals(action) 1984 || Intent.ACTION_SENDTO.equals(action) 1985 || shouldSave())); 1986 1987 mSend = menu.findItem(R.id.send); 1988 MenuItem helpItem = menu.findItem(R.id.help_info_menu_item); 1989 MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item); 1990 if (helpItem != null) { 1991 helpItem.setVisible(mAccount != null 1992 && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT)); 1993 } 1994 if (sendFeedbackItem != null) { 1995 sendFeedbackItem.setVisible(mAccount != null 1996 && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK)); 1997 } 1998 return true; 1999 } 2000 2001 @Override 2002 public boolean onPrepareOptionsMenu(Menu menu) { 2003 MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc); 2004 if (ccBcc != null && mCc != null) { 2005 // Its possible there is a menu item OR a button. 2006 boolean ccFieldVisible = mCc.isShown(); 2007 boolean bccFieldVisible = mBcc.isShown(); 2008 if (!ccFieldVisible || !bccFieldVisible) { 2009 ccBcc.setVisible(true); 2010 ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label 2011 : R.string.add_bcc_label)); 2012 } else { 2013 ccBcc.setVisible(false); 2014 } 2015 } 2016 return true; 2017 } 2018 2019 @Override 2020 public boolean onOptionsItemSelected(MenuItem item) { 2021 final int id = item.getItemId(); 2022 2023 Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id, null, 0); 2024 2025 boolean handled = true; 2026 if (id == R.id.add_photo_attachment) { 2027 doAttach(MIME_TYPE_PHOTO); 2028 } else if (id == R.id.add_video_attachment) { 2029 doAttach(MIME_TYPE_VIDEO); 2030 } else if (id == R.id.add_cc_bcc) { 2031 showCcBccViews(); 2032 } else if (id == R.id.save) { 2033 doSave(true); 2034 } else if (id == R.id.send) { 2035 doSend(); 2036 } else if (id == R.id.discard) { 2037 doDiscard(); 2038 } else if (id == R.id.settings) { 2039 Utils.showSettings(this, mAccount); 2040 } else if (id == android.R.id.home) { 2041 onAppUpPressed(); 2042 } else if (id == R.id.help_info_menu_item) { 2043 Utils.showHelp(this, mAccount, getString(R.string.compose_help_context)); 2044 } else if (id == R.id.feedback_menu_item) { 2045 Utils.sendFeedback(this, mAccount, false); 2046 } else { 2047 handled = false; 2048 } 2049 return !handled ? super.onOptionsItemSelected(item) : handled; 2050 } 2051 2052 @Override 2053 public void onBackPressed() { 2054 // If we are showing the wait fragment, just exit. 2055 if (getWaitFragment() != null) { 2056 finish(); 2057 } else { 2058 super.onBackPressed(); 2059 } 2060 } 2061 2062 /** 2063 * Carries out the "up" action in the action bar. 2064 */ 2065 private void onAppUpPressed() { 2066 if (mLaunchedFromEmail) { 2067 // If this was started from Gmail, simply treat app up as the system back button, so 2068 // that the last view is restored. 2069 onBackPressed(); 2070 return; 2071 } 2072 2073 // Fire the main activity to ensure it launches the "top" screen of mail. 2074 // Since the main Activity is singleTask, it should revive that task if it was already 2075 // started. 2076 final Intent mailIntent = Utils.createViewInboxIntent(mAccount); 2077 mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 2078 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 2079 startActivity(mailIntent); 2080 finish(); 2081 } 2082 2083 private void doSend() { 2084 sendOrSaveWithSanityChecks(false, true, false, false); 2085 logSendOrSave(false /* save */); 2086 mPerformedSendOrDiscard = true; 2087 } 2088 2089 private void doSave(boolean showToast) { 2090 sendOrSaveWithSanityChecks(true, showToast, false, false); 2091 } 2092 2093 @VisibleForTesting 2094 public interface SendOrSaveCallback { 2095 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask); 2096 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message); 2097 public Message getMessage(); 2098 public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success); 2099 } 2100 2101 @VisibleForTesting 2102 public static class SendOrSaveTask implements Runnable { 2103 private final Context mContext; 2104 @VisibleForTesting 2105 public final SendOrSaveCallback mSendOrSaveCallback; 2106 @VisibleForTesting 2107 public final SendOrSaveMessage mSendOrSaveMessage; 2108 private ReplyFromAccount mExistingDraftAccount; 2109 2110 public SendOrSaveTask(Context context, SendOrSaveMessage message, 2111 SendOrSaveCallback callback, ReplyFromAccount draftAccount) { 2112 mContext = context; 2113 mSendOrSaveCallback = callback; 2114 mSendOrSaveMessage = message; 2115 mExistingDraftAccount = draftAccount; 2116 } 2117 2118 @Override 2119 public void run() { 2120 final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage; 2121 2122 final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount; 2123 Message message = mSendOrSaveCallback.getMessage(); 2124 long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID; 2125 // If a previous draft has been saved, in an account that is different 2126 // than what the user wants to send from, remove the old draft, and treat this 2127 // as a new message 2128 if (mExistingDraftAccount != null 2129 && !selectedAccount.account.uri.equals(mExistingDraftAccount.account.uri)) { 2130 if (messageId != UIProvider.INVALID_MESSAGE_ID) { 2131 ContentResolver resolver = mContext.getContentResolver(); 2132 ContentValues values = new ContentValues(); 2133 values.put(BaseColumns._ID, messageId); 2134 if (mExistingDraftAccount.account.expungeMessageUri != null) { 2135 new ContentProviderTask.UpdateTask() 2136 .run(resolver, mExistingDraftAccount.account.expungeMessageUri, 2137 values, null, null); 2138 } else { 2139 // TODO(mindyp) delete the conversation. 2140 } 2141 // reset messageId to 0, so a new message will be created 2142 messageId = UIProvider.INVALID_MESSAGE_ID; 2143 } 2144 } 2145 2146 final long messageIdToSave = messageId; 2147 sendOrSaveMessage(messageIdToSave, sendOrSaveMessage, selectedAccount); 2148 2149 if (!sendOrSaveMessage.mSave) { 2150 incrementRecipientsTimesContacted(mContext, 2151 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO)); 2152 incrementRecipientsTimesContacted(mContext, 2153 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC)); 2154 incrementRecipientsTimesContacted(mContext, 2155 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC)); 2156 } 2157 mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true); 2158 } 2159 2160 private static void incrementRecipientsTimesContacted(final Context context, 2161 final String addressString) { 2162 if (TextUtils.isEmpty(addressString)) { 2163 return; 2164 } 2165 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString); 2166 final ArrayList<String> recipients = new ArrayList<String>(tokens.length); 2167 for (int i = 0; i < tokens.length;i++) { 2168 recipients.add(tokens[i].getAddress()); 2169 } 2170 final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(context); 2171 statsUpdater.updateWithAddress(recipients); 2172 } 2173 2174 /** 2175 * Send or Save a message. 2176 */ 2177 private void sendOrSaveMessage(final long messageIdToSave, 2178 final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) { 2179 final ContentResolver resolver = mContext.getContentResolver(); 2180 final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID; 2181 2182 final String accountMethod = sendOrSaveMessage.mSave ? 2183 UIProvider.AccountCallMethods.SAVE_MESSAGE : 2184 UIProvider.AccountCallMethods.SEND_MESSAGE; 2185 2186 try { 2187 if (updateExistingMessage) { 2188 sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave); 2189 2190 callAccountSendSaveMethod(resolver, 2191 selectedAccount.account, accountMethod, sendOrSaveMessage); 2192 } else { 2193 Uri messageUri = null; 2194 final Bundle result = callAccountSendSaveMethod(resolver, 2195 selectedAccount.account, accountMethod, sendOrSaveMessage); 2196 if (result != null) { 2197 // If a non-null value was returned, then the provider handled the call 2198 // method 2199 messageUri = result.getParcelable(UIProvider.MessageColumns.URI); 2200 } 2201 if (sendOrSaveMessage.mSave && messageUri != null) { 2202 final Cursor messageCursor = resolver.query(messageUri, 2203 UIProvider.MESSAGE_PROJECTION, null, null, null); 2204 if (messageCursor != null) { 2205 try { 2206 if (messageCursor.moveToFirst()) { 2207 // Broadcast notification that a new message has 2208 // been allocated 2209 mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, 2210 new Message(messageCursor)); 2211 } 2212 } finally { 2213 messageCursor.close(); 2214 } 2215 } 2216 } 2217 } 2218 } finally { 2219 // Close any opened file descriptors 2220 closeOpenedAttachmentFds(sendOrSaveMessage); 2221 } 2222 } 2223 2224 private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) { 2225 final Bundle openedFds = sendOrSaveMessage.attachmentFds(); 2226 if (openedFds != null) { 2227 final Set<String> keys = openedFds.keySet(); 2228 for (final String key : keys) { 2229 final ParcelFileDescriptor fd = openedFds.getParcelable(key); 2230 if (fd != null) { 2231 try { 2232 fd.close(); 2233 } catch (IOException e) { 2234 // Do nothing 2235 } 2236 } 2237 } 2238 } 2239 } 2240 2241 /** 2242 * Use the {@link ContentResolver#call} method to send or save the message. 2243 * 2244 * If this was successful, this method will return an non-null Bundle instance 2245 */ 2246 private static Bundle callAccountSendSaveMethod(final ContentResolver resolver, 2247 final Account account, final String method, 2248 final SendOrSaveMessage sendOrSaveMessage) { 2249 // Copy all of the values from the content values to the bundle 2250 final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size()); 2251 final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet(); 2252 2253 for (Entry<String, Object> entry : valueSet) { 2254 final Object entryValue = entry.getValue(); 2255 final String key = entry.getKey(); 2256 if (entryValue instanceof String) { 2257 methodExtras.putString(key, (String)entryValue); 2258 } else if (entryValue instanceof Boolean) { 2259 methodExtras.putBoolean(key, (Boolean)entryValue); 2260 } else if (entryValue instanceof Integer) { 2261 methodExtras.putInt(key, (Integer)entryValue); 2262 } else if (entryValue instanceof Long) { 2263 methodExtras.putLong(key, (Long)entryValue); 2264 } else { 2265 LogUtils.wtf(LOG_TAG, "Unexpected object type: %s", 2266 entryValue.getClass().getName()); 2267 } 2268 } 2269 2270 // If the SendOrSaveMessage has some opened fds, add them to the bundle 2271 final Bundle fdMap = sendOrSaveMessage.attachmentFds(); 2272 if (fdMap != null) { 2273 methodExtras.putParcelable( 2274 UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap); 2275 } 2276 2277 return resolver.call(account.uri, method, account.uri.toString(), methodExtras); 2278 } 2279 } 2280 2281 @VisibleForTesting 2282 public static class SendOrSaveMessage { 2283 final ReplyFromAccount mAccount; 2284 final ContentValues mValues; 2285 final String mRefMessageId; 2286 @VisibleForTesting 2287 public final boolean mSave; 2288 final int mRequestId; 2289 private final Bundle mAttachmentFds; 2290 2291 public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values, 2292 String refMessageId, List<Attachment> attachments, boolean save) { 2293 mAccount = account; 2294 mValues = values; 2295 mRefMessageId = refMessageId; 2296 mSave = save; 2297 mRequestId = mValues.hashCode() ^ hashCode(); 2298 2299 mAttachmentFds = initializeAttachmentFds(context, attachments); 2300 } 2301 2302 int requestId() { 2303 return mRequestId; 2304 } 2305 2306 Bundle attachmentFds() { 2307 return mAttachmentFds; 2308 } 2309 2310 /** 2311 * Opens {@link ParcelFileDescriptor} for each of the attachments. This method must be 2312 * called before the ComposeActivity finishes. 2313 * Note: The caller is responsible for closing these file descriptors. 2314 */ 2315 private static Bundle initializeAttachmentFds(final Context context, 2316 final List<Attachment> attachments) { 2317 if (attachments == null || attachments.size() == 0) { 2318 return null; 2319 } 2320 2321 final Bundle result = new Bundle(attachments.size()); 2322 final ContentResolver resolver = context.getContentResolver(); 2323 2324 for (Attachment attachment : attachments) { 2325 if (attachment == null || Utils.isEmpty(attachment.contentUri)) { 2326 continue; 2327 } 2328 2329 ParcelFileDescriptor fileDescriptor; 2330 try { 2331 fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r"); 2332 } catch (FileNotFoundException e) { 2333 LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment"); 2334 fileDescriptor = null; 2335 } catch (SecurityException e) { 2336 // We have encountered a security exception when attempting to open the file 2337 // specified by the content uri. If the attachment has been cached, this 2338 // isn't a problem, as even through the original permission may have been 2339 // revoked, we have cached the file. This will happen when saving/sending 2340 // a previously saved draft. 2341 // TODO(markwei): Expose whether the attachment has been cached through the 2342 // attachment object. This would allow us to limit when the log is made, as 2343 // if the attachment has been cached, this really isn't an error 2344 LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment"); 2345 // Just set the file descriptor to null, as the underlying provider needs 2346 // to handle the file descriptor not being set. 2347 fileDescriptor = null; 2348 } 2349 2350 if (fileDescriptor != null) { 2351 result.putParcelable(attachment.contentUri.toString(), fileDescriptor); 2352 } 2353 } 2354 2355 return result; 2356 } 2357 } 2358 2359 /** 2360 * Get the to recipients. 2361 */ 2362 public String[] getToAddresses() { 2363 return getAddressesFromList(mTo); 2364 } 2365 2366 /** 2367 * Get the cc recipients. 2368 */ 2369 public String[] getCcAddresses() { 2370 return getAddressesFromList(mCc); 2371 } 2372 2373 /** 2374 * Get the bcc recipients. 2375 */ 2376 public String[] getBccAddresses() { 2377 return getAddressesFromList(mBcc); 2378 } 2379 2380 public String[] getAddressesFromList(RecipientEditTextView list) { 2381 if (list == null) { 2382 return new String[0]; 2383 } 2384 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText()); 2385 int count = tokens.length; 2386 String[] result = new String[count]; 2387 for (int i = 0; i < count; i++) { 2388 result[i] = tokens[i].toString(); 2389 } 2390 return result; 2391 } 2392 2393 /** 2394 * Check for invalid email addresses. 2395 * @param to String array of email addresses to check. 2396 * @param wrongEmailsOut Emails addresses that were invalid. 2397 */ 2398 public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) { 2399 if (mValidator == null) { 2400 return; 2401 } 2402 for (final String email : to) { 2403 if (!mValidator.isValid(email)) { 2404 wrongEmailsOut.add(email); 2405 } 2406 } 2407 } 2408 2409 public static class RecipientErrorDialogFragment extends DialogFragment { 2410 // Public no-args constructor needed for fragment re-instantiation 2411 public RecipientErrorDialogFragment() {} 2412 2413 public static RecipientErrorDialogFragment newInstance(final String message) { 2414 final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment(); 2415 final Bundle args = new Bundle(1); 2416 args.putString("message", message); 2417 frag.setArguments(args); 2418 return frag; 2419 } 2420 2421 @Override 2422 public Dialog onCreateDialog(Bundle savedInstanceState) { 2423 final String message = getArguments().getString("message"); 2424 return new AlertDialog.Builder(getActivity()).setMessage(message).setTitle( 2425 R.string.recipient_error_dialog_title) 2426 .setIconAttribute(android.R.attr.alertDialogIcon) 2427 .setPositiveButton( 2428 R.string.ok, new Dialog.OnClickListener() { 2429 @Override 2430 public void onClick(DialogInterface dialog, int which) { 2431 ((ComposeActivity)getActivity()).finishRecipientErrorDialog(); 2432 } 2433 }).create(); 2434 } 2435 } 2436 2437 private void finishRecipientErrorDialog() { 2438 // after the user dismisses the recipient error 2439 // dialog we want to make sure to refocus the 2440 // recipient to field so they can fix the issue 2441 // easily 2442 if (mTo != null) { 2443 mTo.requestFocus(); 2444 } 2445 } 2446 2447 /** 2448 * Show an error because the user has entered an invalid recipient. 2449 * @param message 2450 */ 2451 private void showRecipientErrorDialog(final String message) { 2452 final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message); 2453 frag.show(getFragmentManager(), "recipient error"); 2454 } 2455 2456 /** 2457 * Update the state of the UI based on whether or not the current draft 2458 * needs to be saved and the message is not empty. 2459 */ 2460 public void updateSaveUi() { 2461 if (mSave != null) { 2462 mSave.setEnabled((shouldSave() && !isBlank())); 2463 } 2464 } 2465 2466 /** 2467 * Returns true if we need to save the current draft. 2468 */ 2469 private boolean shouldSave() { 2470 synchronized (mDraftLock) { 2471 // The message should only be saved if: 2472 // It hasn't been sent AND 2473 // Some text has been added to the message OR 2474 // an attachment has been added or removed 2475 // AND there is actually something in the draft to save. 2476 return (mTextChanged || mAttachmentsChanged || mReplyFromChanged) 2477 && !isBlank(); 2478 } 2479 } 2480 2481 /** 2482 * Check if all fields are blank. 2483 * @return boolean 2484 */ 2485 public boolean isBlank() { 2486 // Need to check for null since isBlank() can be called from onPause() 2487 // before findViews() is called 2488 if (mSubject == null || mBodyView == null || mTo == null || mCc == null || 2489 mAttachmentsView == null) { 2490 LogUtils.w(LOG_TAG, "null views in isBlank check"); 2491 return true; 2492 } 2493 return mSubject.getText().length() == 0 2494 && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature, 2495 mBodyView.getText().toString()) == 0) 2496 && mTo.length() == 0 2497 && mCc.length() == 0 && mBcc.length() == 0 2498 && mAttachmentsView.getAttachments().size() == 0; 2499 } 2500 2501 @VisibleForTesting 2502 protected int getSignatureStartPosition(String signature, String bodyText) { 2503 int startPos = -1; 2504 2505 if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) { 2506 return startPos; 2507 } 2508 2509 int bodyLength = bodyText.length(); 2510 int signatureLength = signature.length(); 2511 String printableVersion = convertToPrintableSignature(signature); 2512 int printableLength = printableVersion.length(); 2513 2514 if (bodyLength >= printableLength 2515 && bodyText.substring(bodyLength - printableLength) 2516 .equals(printableVersion)) { 2517 startPos = bodyLength - printableLength; 2518 } else if (bodyLength >= signatureLength 2519 && bodyText.substring(bodyLength - signatureLength) 2520 .equals(signature)) { 2521 startPos = bodyLength - signatureLength; 2522 } 2523 return startPos; 2524 } 2525 2526 /** 2527 * Allows any changes made by the user to be ignored. Called when the user 2528 * decides to discard a draft. 2529 */ 2530 private void discardChanges() { 2531 mTextChanged = false; 2532 mAttachmentsChanged = false; 2533 mReplyFromChanged = false; 2534 } 2535 2536 /** 2537 * @param save 2538 * @param showToast 2539 * @return Whether the send or save succeeded. 2540 */ 2541 protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast, 2542 final boolean orientationChanged, final boolean autoSend) { 2543 if (mAccounts == null || mAccount == null) { 2544 Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show(); 2545 if (autoSend) { 2546 finish(); 2547 } 2548 return false; 2549 } 2550 2551 final String[] to, cc, bcc; 2552 if (orientationChanged) { 2553 to = cc = bcc = new String[0]; 2554 } else { 2555 to = getToAddresses(); 2556 cc = getCcAddresses(); 2557 bcc = getBccAddresses(); 2558 } 2559 2560 // Don't let the user send to nobody (but it's okay to save a message 2561 // with no recipients) 2562 if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) { 2563 showRecipientErrorDialog(getString(R.string.recipient_needed)); 2564 return false; 2565 } 2566 2567 List<String> wrongEmails = new ArrayList<String>(); 2568 if (!save) { 2569 checkInvalidEmails(to, wrongEmails); 2570 checkInvalidEmails(cc, wrongEmails); 2571 checkInvalidEmails(bcc, wrongEmails); 2572 } 2573 2574 // Don't let the user send an email with invalid recipients 2575 if (wrongEmails.size() > 0) { 2576 String errorText = String.format(getString(R.string.invalid_recipient), 2577 wrongEmails.get(0)); 2578 showRecipientErrorDialog(errorText); 2579 return false; 2580 } 2581 2582 // Show a warning before sending only if there are no attachments. 2583 if (!save) { 2584 if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) { 2585 boolean warnAboutEmptySubject = isSubjectEmpty(); 2586 boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0; 2587 2588 // A warning about an empty body may not be warranted when 2589 // forwarding mails, since a common use case is to forward 2590 // quoted text and not append any more text. 2591 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty()); 2592 2593 // When we bring up a dialog warning the user about a send, 2594 // assume that they accept sending the message. If they do not, 2595 // the dialog listener is required to enable sending again. 2596 if (warnAboutEmptySubject) { 2597 showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, save, 2598 showToast); 2599 return true; 2600 } 2601 2602 if (warnAboutEmptyBody) { 2603 showSendConfirmDialog(R.string.confirm_send_message_with_no_body, save, 2604 showToast); 2605 return true; 2606 } 2607 } 2608 // Ask for confirmation to send (if always required) 2609 if (showSendConfirmation()) { 2610 showSendConfirmDialog(R.string.confirm_send_message, save, showToast); 2611 return true; 2612 } 2613 } 2614 2615 sendOrSave(save, showToast); 2616 return true; 2617 } 2618 2619 /** 2620 * Returns a boolean indicating whether warnings should be shown for empty 2621 * subject and body fields 2622 * 2623 * @return True if a warning should be shown for empty text fields 2624 */ 2625 protected boolean showEmptyTextWarnings() { 2626 return mAttachmentsView.getAttachments().size() == 0; 2627 } 2628 2629 /** 2630 * Returns a boolean indicating whether the user should confirm each send 2631 * 2632 * @return True if a warning should be on each send 2633 */ 2634 protected boolean showSendConfirmation() { 2635 return mCachedSettings != null ? mCachedSettings.confirmSend : false; 2636 } 2637 2638 public static class SendConfirmDialogFragment extends DialogFragment { 2639 // Public no-args constructor needed for fragment re-instantiation 2640 public SendConfirmDialogFragment() {} 2641 2642 public static SendConfirmDialogFragment newInstance(final int messageId, 2643 final boolean save, final boolean showToast) { 2644 final SendConfirmDialogFragment frag = new SendConfirmDialogFragment(); 2645 final Bundle args = new Bundle(3); 2646 args.putInt("messageId", messageId); 2647 args.putBoolean("save", save); 2648 args.putBoolean("showToast", showToast); 2649 frag.setArguments(args); 2650 return frag; 2651 } 2652 2653 @Override 2654 public Dialog onCreateDialog(Bundle savedInstanceState) { 2655 final int messageId = getArguments().getInt("messageId"); 2656 final boolean save = getArguments().getBoolean("save"); 2657 final boolean showToast = getArguments().getBoolean("showToast"); 2658 2659 return new AlertDialog.Builder(getActivity()) 2660 .setMessage(messageId) 2661 .setTitle(R.string.confirm_send_title) 2662 .setIconAttribute(android.R.attr.alertDialogIcon) 2663 .setPositiveButton(R.string.send, 2664 new DialogInterface.OnClickListener() { 2665 @Override 2666 public void onClick(DialogInterface dialog, int whichButton) { 2667 ((ComposeActivity)getActivity()).finishSendConfirmDialog(save, 2668 showToast); 2669 } 2670 }) 2671 .create(); 2672 } 2673 } 2674 2675 private void finishSendConfirmDialog(final boolean save, final boolean showToast) { 2676 sendOrSave(save, showToast); 2677 } 2678 2679 private void showSendConfirmDialog(final int messageId, final boolean save, 2680 final boolean showToast) { 2681 final DialogFragment frag = SendConfirmDialogFragment.newInstance(messageId, save, 2682 showToast); 2683 frag.show(getFragmentManager(), "send confirm"); 2684 } 2685 2686 /** 2687 * Returns whether the ComposeArea believes there is any text in the body of 2688 * the composition. TODO: When ComposeArea controls the Body as well, add 2689 * that here. 2690 */ 2691 public boolean isBodyEmpty() { 2692 return !mQuotedTextView.isTextIncluded(); 2693 } 2694 2695 /** 2696 * Test to see if the subject is empty. 2697 * 2698 * @return boolean. 2699 */ 2700 // TODO: this will likely go away when composeArea.focus() is implemented 2701 // after all the widget control is moved over. 2702 public boolean isSubjectEmpty() { 2703 return TextUtils.getTrimmedLength(mSubject.getText()) == 0; 2704 } 2705 2706 /* package */ 2707 static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount, 2708 Message message, final Message refMessage, Spanned body, final CharSequence quotedText, 2709 SendOrSaveCallback callback, Handler handler, boolean save, int composeMode, 2710 ReplyFromAccount draftAccount, final ContentValues extraValues) { 2711 final ContentValues values = new ContentValues(); 2712 2713 final String refMessageId = refMessage != null ? refMessage.uri.toString() : ""; 2714 2715 MessageModification.putToAddresses(values, message.getToAddresses()); 2716 MessageModification.putCcAddresses(values, message.getCcAddresses()); 2717 MessageModification.putBccAddresses(values, message.getBccAddresses()); 2718 2719 MessageModification.putCustomFromAddress(values, message.getFrom()); 2720 2721 MessageModification.putSubject(values, message.subject); 2722 // Make sure to remove only the composing spans from the Spannable before saving. 2723 final String htmlBody = Html.toHtml(removeComposingSpans(body)); 2724 2725 boolean includeQuotedText = !TextUtils.isEmpty(quotedText); 2726 StringBuilder fullBody = new StringBuilder(htmlBody); 2727 if (includeQuotedText) { 2728 // HTML gets converted to text for now 2729 final String text = quotedText.toString(); 2730 if (QuotedTextView.containsQuotedText(text)) { 2731 int pos = QuotedTextView.getQuotedTextOffset(text); 2732 final int quoteStartPos = fullBody.length() + pos; 2733 fullBody.append(text); 2734 MessageModification.putQuoteStartPos(values, quoteStartPos); 2735 MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD); 2736 MessageModification.putAppendRefMessageContent(values, includeQuotedText); 2737 } else { 2738 LogUtils.w(LOG_TAG, "Couldn't find quoted text"); 2739 // This shouldn't happen, but just use what we have, 2740 // and don't do server-side expansion 2741 fullBody.append(text); 2742 } 2743 } 2744 int draftType = getDraftType(composeMode); 2745 MessageModification.putDraftType(values, draftType); 2746 if (refMessage != null) { 2747 if (!TextUtils.isEmpty(refMessage.bodyHtml)) { 2748 MessageModification.putBodyHtml(values, fullBody.toString()); 2749 } 2750 if (!TextUtils.isEmpty(refMessage.bodyText)) { 2751 MessageModification.putBody(values, 2752 Utils.convertHtmlToPlainText(fullBody.toString()).toString()); 2753 } 2754 } else { 2755 MessageModification.putBodyHtml(values, fullBody.toString()); 2756 MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString()) 2757 .toString()); 2758 } 2759 MessageModification.putAttachments(values, message.getAttachments()); 2760 if (!TextUtils.isEmpty(refMessageId)) { 2761 MessageModification.putRefMessageId(values, refMessageId); 2762 } 2763 if (extraValues != null) { 2764 values.putAll(extraValues); 2765 } 2766 SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount, 2767 values, refMessageId, message.getAttachments(), save); 2768 SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback, 2769 draftAccount); 2770 2771 callback.initializeSendOrSave(sendOrSaveTask); 2772 // Do the send/save action on the specified handler to avoid possible 2773 // ANRs 2774 handler.post(sendOrSaveTask); 2775 2776 return sendOrSaveMessage.requestId(); 2777 } 2778 2779 /** 2780 * Removes any composing spans from the specified string. This will create a new 2781 * SpannableString instance, as to not modify the behavior of the EditText view. 2782 */ 2783 private static SpannableString removeComposingSpans(Spanned body) { 2784 final SpannableString messageBody = new SpannableString(body); 2785 BaseInputConnection.removeComposingSpans(messageBody); 2786 return messageBody; 2787 } 2788 2789 private static int getDraftType(int mode) { 2790 int draftType = -1; 2791 switch (mode) { 2792 case ComposeActivity.COMPOSE: 2793 draftType = DraftType.COMPOSE; 2794 break; 2795 case ComposeActivity.REPLY: 2796 draftType = DraftType.REPLY; 2797 break; 2798 case ComposeActivity.REPLY_ALL: 2799 draftType = DraftType.REPLY_ALL; 2800 break; 2801 case ComposeActivity.FORWARD: 2802 draftType = DraftType.FORWARD; 2803 break; 2804 } 2805 return draftType; 2806 } 2807 2808 private void sendOrSave(final boolean save, final boolean showToast) { 2809 // Check if user is a monkey. Monkeys can compose and hit send 2810 // button but are not allowed to send anything off the device. 2811 if (ActivityManager.isUserAMonkey()) { 2812 return; 2813 } 2814 2815 final Spanned body = mBodyView.getEditableText(); 2816 2817 SendOrSaveCallback callback = new SendOrSaveCallback() { 2818 // FIXME: unused 2819 private int mRestoredRequestId; 2820 2821 @Override 2822 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) { 2823 synchronized (mActiveTasks) { 2824 int numTasks = mActiveTasks.size(); 2825 if (numTasks == 0) { 2826 // Start service so we won't be killed if this app is 2827 // put in the background. 2828 startService(new Intent(ComposeActivity.this, EmptyService.class)); 2829 } 2830 2831 mActiveTasks.add(sendOrSaveTask); 2832 } 2833 if (sTestSendOrSaveCallback != null) { 2834 sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask); 2835 } 2836 } 2837 2838 @Override 2839 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, 2840 Message message) { 2841 synchronized (mDraftLock) { 2842 mDraftAccount = sendOrSaveMessage.mAccount; 2843 mDraftId = message.id; 2844 mDraft = message; 2845 if (sRequestMessageIdMap != null) { 2846 sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId); 2847 } 2848 // Cache request message map, in case the process is killed 2849 saveRequestMap(); 2850 } 2851 if (sTestSendOrSaveCallback != null) { 2852 sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message); 2853 } 2854 } 2855 2856 @Override 2857 public Message getMessage() { 2858 synchronized (mDraftLock) { 2859 return mDraft; 2860 } 2861 } 2862 2863 @Override 2864 public void sendOrSaveFinished(SendOrSaveTask task, boolean success) { 2865 // Update the last sent from account. 2866 if (mAccount != null) { 2867 MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString()); 2868 } 2869 if (success) { 2870 // Successfully sent or saved so reset change markers 2871 discardChanges(); 2872 } else { 2873 // A failure happened with saving/sending the draft 2874 // TODO(pwestbro): add a better string that should be used 2875 // when failing to send or save 2876 Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT) 2877 .show(); 2878 } 2879 2880 int numTasks; 2881 synchronized (mActiveTasks) { 2882 // Remove the task from the list of active tasks 2883 mActiveTasks.remove(task); 2884 numTasks = mActiveTasks.size(); 2885 } 2886 2887 if (numTasks == 0) { 2888 // Stop service so we can be killed. 2889 stopService(new Intent(ComposeActivity.this, EmptyService.class)); 2890 } 2891 if (sTestSendOrSaveCallback != null) { 2892 sTestSendOrSaveCallback.sendOrSaveFinished(task, success); 2893 } 2894 } 2895 }; 2896 2897 setAccount(mReplyFromAccount.account); 2898 2899 if (mSendSaveTaskHandler == null) { 2900 HandlerThread handlerThread = new HandlerThread("Send Message Task Thread"); 2901 handlerThread.start(); 2902 2903 mSendSaveTaskHandler = new Handler(handlerThread.getLooper()); 2904 } 2905 2906 Message msg = createMessage(mReplyFromAccount, getMode()); 2907 mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body, 2908 mQuotedTextView.getQuotedTextIfIncluded(), callback, 2909 mSendSaveTaskHandler, save, mComposeMode, mDraftAccount, mExtraValues); 2910 2911 // Don't display the toast if the user is just changing the orientation, 2912 // but we still need to save the draft to the cursor because this is how we restore 2913 // the attachments when the configuration change completes. 2914 if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 2915 Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, 2916 Toast.LENGTH_LONG).show(); 2917 } 2918 2919 // Need to update variables here because the send or save completes 2920 // asynchronously even though the toast shows right away. 2921 discardChanges(); 2922 updateSaveUi(); 2923 2924 // If we are sending, finish the activity 2925 if (!save) { 2926 finish(); 2927 } 2928 } 2929 2930 /** 2931 * Save the state of the request messageid map. This allows for the Gmail 2932 * process to be killed, but and still allow for ComposeActivity instances 2933 * to be recreated correctly. 2934 */ 2935 private void saveRequestMap() { 2936 // TODO: store the request map in user preferences. 2937 } 2938 2939 private void doAttach(String type) { 2940 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 2941 i.addCategory(Intent.CATEGORY_OPENABLE); 2942 i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 2943 i.setType(type); 2944 mAddingAttachment = true; 2945 startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)), 2946 RESULT_PICK_ATTACHMENT); 2947 } 2948 2949 private void showCcBccViews() { 2950 mCcBccView.show(true, true, true); 2951 if (mCcBccButton != null) { 2952 mCcBccButton.setVisibility(View.INVISIBLE); 2953 } 2954 } 2955 2956 private static String getActionString(int action) { 2957 final String msgType; 2958 switch (action) { 2959 case COMPOSE: 2960 msgType = "new_message"; 2961 break; 2962 case REPLY: 2963 msgType = "reply"; 2964 break; 2965 case REPLY_ALL: 2966 msgType = "reply_all"; 2967 break; 2968 case FORWARD: 2969 msgType = "forward"; 2970 break; 2971 default: 2972 msgType = "unknown"; 2973 break; 2974 } 2975 return msgType; 2976 } 2977 2978 private void logSendOrSave(boolean save) { 2979 if (!Analytics.isLoggable() || mAttachmentsView == null) { 2980 return; 2981 } 2982 2983 final String category = (save) ? "message_save" : "message_send"; 2984 final int attachmentCount = getAttachments().size(); 2985 final String msgType = getActionString(mComposeMode); 2986 final String label; 2987 final long value; 2988 if (mComposeMode == COMPOSE) { 2989 label = Integer.toString(attachmentCount); 2990 value = attachmentCount; 2991 } else { 2992 label = null; 2993 value = 0; 2994 } 2995 Analytics.getInstance().sendEvent(category, msgType, label, value); 2996 } 2997 2998 @Override 2999 public boolean onNavigationItemSelected(int position, long itemId) { 3000 int initialComposeMode = mComposeMode; 3001 if (position == ComposeActivity.REPLY) { 3002 mComposeMode = ComposeActivity.REPLY; 3003 } else if (position == ComposeActivity.REPLY_ALL) { 3004 mComposeMode = ComposeActivity.REPLY_ALL; 3005 } else if (position == ComposeActivity.FORWARD) { 3006 mComposeMode = ComposeActivity.FORWARD; 3007 } 3008 clearChangeListeners(); 3009 if (initialComposeMode != mComposeMode) { 3010 resetMessageForModeChange(); 3011 if (mRefMessage != null) { 3012 setFieldsFromRefMessage(mComposeMode); 3013 } 3014 boolean showCc = false; 3015 boolean showBcc = false; 3016 if (mDraft != null) { 3017 // Following desktop behavior, if the user has added a BCC 3018 // field to a draft, we show it regardless of compose mode. 3019 showBcc = !TextUtils.isEmpty(mDraft.getBcc()); 3020 // Use the draft to determine what to populate. 3021 // If the Bcc field is showing, show the Cc field whether it is populated or not. 3022 showCc = showBcc 3023 || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL); 3024 } 3025 if (mRefMessage != null) { 3026 showCc = !TextUtils.isEmpty(mCc.getText()); 3027 showBcc = !TextUtils.isEmpty(mBcc.getText()); 3028 } 3029 mCcBccView.show(false, showCc, showBcc); 3030 } 3031 updateHideOrShowCcBcc(); 3032 initChangeListeners(); 3033 return true; 3034 } 3035 3036 @VisibleForTesting 3037 protected void resetMessageForModeChange() { 3038 // When switching between reply, reply all, forward, 3039 // follow the behavior of webview. 3040 // The contents of the following fields are cleared 3041 // so that they can be populated directly from the 3042 // ref message: 3043 // 1) Any recipient fields 3044 // 2) The subject 3045 mTo.setText(""); 3046 mCc.setText(""); 3047 mBcc.setText(""); 3048 // Any edits to the subject are replaced with the original subject. 3049 mSubject.setText(""); 3050 3051 // Any changes to the contents of the following fields are kept: 3052 // 1) Body 3053 // 2) Attachments 3054 // If the user made changes to attachments, keep their changes. 3055 if (!mAttachmentsChanged) { 3056 mAttachmentsView.deleteAllAttachments(); 3057 } 3058 } 3059 3060 private class ComposeModeAdapter extends ArrayAdapter<String> { 3061 3062 private LayoutInflater mInflater; 3063 3064 public ComposeModeAdapter(Context context) { 3065 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 3066 .getStringArray(R.array.compose_modes)); 3067 } 3068 3069 private LayoutInflater getInflater() { 3070 if (mInflater == null) { 3071 mInflater = LayoutInflater.from(getContext()); 3072 } 3073 return mInflater; 3074 } 3075 3076 @Override 3077 public View getView(int position, View convertView, ViewGroup parent) { 3078 if (convertView == null) { 3079 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 3080 } 3081 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 3082 return super.getView(position, convertView, parent); 3083 } 3084 } 3085 3086 @Override 3087 public void onRespondInline(String text) { 3088 appendToBody(text, false); 3089 mQuotedTextView.setUpperDividerVisible(false); 3090 mRespondedInline = true; 3091 if (!mBodyView.hasFocus()) { 3092 mBodyView.requestFocus(); 3093 } 3094 } 3095 3096 /** 3097 * Append text to the body of the message. If there is no existing body 3098 * text, just sets the body to text. 3099 * 3100 * @param text 3101 * @param withSignature True to append a signature. 3102 */ 3103 public void appendToBody(CharSequence text, boolean withSignature) { 3104 Editable bodyText = mBodyView.getEditableText(); 3105 if (bodyText != null && bodyText.length() > 0) { 3106 bodyText.append(text); 3107 } else { 3108 setBody(text, withSignature); 3109 } 3110 } 3111 3112 /** 3113 * Set the body of the message. 3114 * 3115 * @param text 3116 * @param withSignature True to append a signature. 3117 */ 3118 public void setBody(CharSequence text, boolean withSignature) { 3119 mBodyView.setText(text); 3120 if (withSignature) { 3121 appendSignature(); 3122 } 3123 } 3124 3125 private void appendSignature() { 3126 String newSignature = mCachedSettings != null ? mCachedSettings.signature : null; 3127 boolean hasFocus = mBodyView.hasFocus(); 3128 int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString()); 3129 if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) { 3130 mSignature = newSignature; 3131 if (!TextUtils.isEmpty(mSignature)) { 3132 // Appending a signature does not count as changing text. 3133 mBodyView.removeTextChangedListener(this); 3134 mBodyView.append(convertToPrintableSignature(mSignature)); 3135 mBodyView.addTextChangedListener(this); 3136 } 3137 if (hasFocus) { 3138 focusBody(); 3139 } 3140 } 3141 } 3142 3143 private String convertToPrintableSignature(String signature) { 3144 String signatureResource = getResources().getString(R.string.signature); 3145 if (signature == null) { 3146 signature = ""; 3147 } 3148 return String.format(signatureResource, signature); 3149 } 3150 3151 @Override 3152 public void onAccountChanged() { 3153 mReplyFromAccount = mFromSpinner.getCurrentAccount(); 3154 if (!mAccount.equals(mReplyFromAccount.account)) { 3155 // Clear a signature, if there was one. 3156 mBodyView.removeTextChangedListener(this); 3157 String oldSignature = mSignature; 3158 String bodyText = getBody().getText().toString(); 3159 if (!TextUtils.isEmpty(oldSignature)) { 3160 int pos = getSignatureStartPosition(oldSignature, bodyText); 3161 if (pos > -1) { 3162 mBodyView.setText(bodyText.substring(0, pos)); 3163 } 3164 } 3165 setAccount(mReplyFromAccount.account); 3166 mBodyView.addTextChangedListener(this); 3167 // TODO: handle discarding attachments when switching accounts. 3168 // Only enable save for this draft if there is any other content 3169 // in the message. 3170 if (!isBlank()) { 3171 enableSave(true); 3172 } 3173 mReplyFromChanged = true; 3174 initRecipients(); 3175 } 3176 } 3177 3178 public void enableSave(boolean enabled) { 3179 if (mSave != null) { 3180 mSave.setEnabled(enabled); 3181 } 3182 } 3183 3184 public static class DiscardConfirmDialogFragment extends DialogFragment { 3185 // Public no-args constructor needed for fragment re-instantiation 3186 public DiscardConfirmDialogFragment() {} 3187 3188 @Override 3189 public Dialog onCreateDialog(Bundle savedInstanceState) { 3190 return new AlertDialog.Builder(getActivity()) 3191 .setMessage(R.string.confirm_discard_text) 3192 .setPositiveButton(R.string.discard, 3193 new DialogInterface.OnClickListener() { 3194 @Override 3195 public void onClick(DialogInterface dialog, int which) { 3196 ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation(); 3197 } 3198 }) 3199 .setNegativeButton(R.string.cancel, null) 3200 .create(); 3201 } 3202 } 3203 3204 private void doDiscard() { 3205 final DialogFragment frag = new DiscardConfirmDialogFragment(); 3206 frag.show(getFragmentManager(), "discard confirm"); 3207 } 3208 /** 3209 * Effectively discard the current message. 3210 * 3211 * This method is either invoked from the menu or from the dialog 3212 * once the user has confirmed that they want to discard the message. 3213 */ 3214 private void doDiscardWithoutConfirmation() { 3215 synchronized (mDraftLock) { 3216 if (mDraftId != UIProvider.INVALID_MESSAGE_ID) { 3217 ContentValues values = new ContentValues(); 3218 values.put(BaseColumns._ID, mDraftId); 3219 if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) { 3220 getContentResolver().update(mAccount.expungeMessageUri, values, null, null); 3221 } else { 3222 getContentResolver().delete(mDraft.uri, null, null); 3223 } 3224 // This is not strictly necessary (since we should not try to 3225 // save the draft after calling this) but it ensures that if we 3226 // do save again for some reason we make a new draft rather than 3227 // trying to resave an expunged draft. 3228 mDraftId = UIProvider.INVALID_MESSAGE_ID; 3229 } 3230 } 3231 3232 // Display a toast to let the user know 3233 Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show(); 3234 3235 // This prevents the draft from being saved in onPause(). 3236 discardChanges(); 3237 mPerformedSendOrDiscard = true; 3238 finish(); 3239 } 3240 3241 private void saveIfNeeded() { 3242 if (mAccount == null) { 3243 // We have not chosen an account yet so there's no way that we can save. This is ok, 3244 // though, since we are saving our state before AccountsActivity is activated. Thus, the 3245 // user has not interacted with us yet and there is no real state to save. 3246 return; 3247 } 3248 3249 if (shouldSave()) { 3250 doSave(!mAddingAttachment /* show toast */); 3251 } 3252 } 3253 3254 @Override 3255 public void onAttachmentDeleted() { 3256 mAttachmentsChanged = true; 3257 // If we are showing any attachments, make sure we have an upper 3258 // divider. 3259 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3260 updateSaveUi(); 3261 } 3262 3263 @Override 3264 public void onAttachmentAdded() { 3265 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3266 mAttachmentsView.focusLastAttachment(); 3267 } 3268 3269 /** 3270 * This is called any time one of our text fields changes. 3271 */ 3272 @Override 3273 public void afterTextChanged(Editable s) { 3274 mTextChanged = true; 3275 updateSaveUi(); 3276 } 3277 3278 @Override 3279 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3280 // Do nothing. 3281 } 3282 3283 @Override 3284 public void onTextChanged(CharSequence s, int start, int before, int count) { 3285 // Do nothing. 3286 } 3287 3288 3289 // There is a big difference between the text associated with an address changing 3290 // to add the display name or to format properly and a recipient being added or deleted. 3291 // Make sure we only notify of changes when a recipient has been added or deleted. 3292 private class RecipientTextWatcher implements TextWatcher { 3293 private HashMap<String, Integer> mContent = new HashMap<String, Integer>(); 3294 3295 private RecipientEditTextView mView; 3296 3297 private TextWatcher mListener; 3298 3299 public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) { 3300 mView = view; 3301 mListener = listener; 3302 } 3303 3304 @Override 3305 public void afterTextChanged(Editable s) { 3306 if (hasChanged()) { 3307 mListener.afterTextChanged(s); 3308 } 3309 } 3310 3311 private boolean hasChanged() { 3312 String[] currRecips = tokenizeRecips(getAddressesFromList(mView)); 3313 int totalCount = currRecips.length; 3314 int totalPrevCount = 0; 3315 for (Entry<String, Integer> entry : mContent.entrySet()) { 3316 totalPrevCount += entry.getValue(); 3317 } 3318 if (totalCount != totalPrevCount) { 3319 return true; 3320 } 3321 3322 for (String recip : currRecips) { 3323 if (!mContent.containsKey(recip)) { 3324 return true; 3325 } else { 3326 int count = mContent.get(recip) - 1; 3327 if (count < 0) { 3328 return true; 3329 } else { 3330 mContent.put(recip, count); 3331 } 3332 } 3333 } 3334 return false; 3335 } 3336 3337 private String[] tokenizeRecips(String[] recips) { 3338 // Tokenize them all and put them in the list. 3339 String[] recipAddresses = new String[recips.length]; 3340 for (int i = 0; i < recips.length; i++) { 3341 recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress(); 3342 } 3343 return recipAddresses; 3344 } 3345 3346 @Override 3347 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3348 String[] recips = tokenizeRecips(getAddressesFromList(mView)); 3349 for (String recip : recips) { 3350 if (!mContent.containsKey(recip)) { 3351 mContent.put(recip, 1); 3352 } else { 3353 mContent.put(recip, (mContent.get(recip)) + 1); 3354 } 3355 } 3356 } 3357 3358 @Override 3359 public void onTextChanged(CharSequence s, int start, int before, int count) { 3360 // Do nothing. 3361 } 3362 } 3363 3364 public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) { 3365 if (sTestSendOrSaveCallback != null && testCallback != null) { 3366 throw new IllegalStateException("Attempting to register more than one test callback"); 3367 } 3368 sTestSendOrSaveCallback = testCallback; 3369 } 3370 3371 @VisibleForTesting 3372 protected ArrayList<Attachment> getAttachments() { 3373 return mAttachmentsView.getAttachments(); 3374 } 3375 3376 @Override 3377 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 3378 switch (id) { 3379 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3380 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3381 null, null); 3382 case REFERENCE_MESSAGE_LOADER: 3383 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3384 null, null); 3385 case LOADER_ACCOUNT_CURSOR: 3386 return new CursorLoader(this, MailAppProvider.getAccountsUri(), 3387 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 3388 } 3389 return null; 3390 } 3391 3392 @Override 3393 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 3394 int id = loader.getId(); 3395 switch (id) { 3396 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3397 if (data != null && data.moveToFirst()) { 3398 mRefMessage = new Message(data); 3399 Intent intent = getIntent(); 3400 initFromRefMessage(mComposeMode); 3401 finishSetup(mComposeMode, intent, null); 3402 if (mComposeMode != FORWARD) { 3403 String to = intent.getStringExtra(EXTRA_TO); 3404 if (!TextUtils.isEmpty(to)) { 3405 mRefMessage.setTo(null); 3406 mRefMessage.setFrom(null); 3407 clearChangeListeners(); 3408 mTo.append(to); 3409 initChangeListeners(); 3410 } 3411 } 3412 } else { 3413 finish(); 3414 } 3415 break; 3416 case REFERENCE_MESSAGE_LOADER: 3417 // Only populate mRefMessage and leave other fields untouched. 3418 if (data != null && data.moveToFirst()) { 3419 mRefMessage = new Message(data); 3420 } 3421 finishSetup(mComposeMode, getIntent(), mInnerSavedState); 3422 break; 3423 case LOADER_ACCOUNT_CURSOR: 3424 if (data != null && data.moveToFirst()) { 3425 // there are accounts now! 3426 Account account; 3427 final ArrayList<Account> accounts = new ArrayList<Account>(); 3428 final ArrayList<Account> initializedAccounts = new ArrayList<Account>(); 3429 do { 3430 account = new Account(data); 3431 if (account.isAccountReady()) { 3432 initializedAccounts.add(account); 3433 } 3434 accounts.add(account); 3435 } while (data.moveToNext()); 3436 if (initializedAccounts.size() > 0) { 3437 findViewById(R.id.wait).setVisibility(View.GONE); 3438 getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR); 3439 findViewById(R.id.compose).setVisibility(View.VISIBLE); 3440 mAccounts = initializedAccounts.toArray( 3441 new Account[initializedAccounts.size()]); 3442 3443 finishCreate(); 3444 invalidateOptionsMenu(); 3445 } else { 3446 // Show "waiting" 3447 account = accounts.size() > 0 ? accounts.get(0) : null; 3448 showWaitFragment(account); 3449 } 3450 } 3451 break; 3452 } 3453 } 3454 3455 private void showWaitFragment(Account account) { 3456 WaitFragment fragment = getWaitFragment(); 3457 if (fragment != null) { 3458 fragment.updateAccount(account); 3459 } else { 3460 findViewById(R.id.wait).setVisibility(View.VISIBLE); 3461 replaceFragment(WaitFragment.newInstance(account, true), 3462 FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT); 3463 } 3464 } 3465 3466 private WaitFragment getWaitFragment() { 3467 return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT); 3468 } 3469 3470 private int replaceFragment(Fragment fragment, int transition, String tag) { 3471 FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction(); 3472 fragmentTransaction.setTransition(transition); 3473 fragmentTransaction.replace(R.id.wait, fragment, tag); 3474 final int transactionId = fragmentTransaction.commitAllowingStateLoss(); 3475 return transactionId; 3476 } 3477 3478 @Override 3479 public void onLoaderReset(Loader<Cursor> arg0) { 3480 // Do nothing. 3481 } 3482 3483 @Override 3484 public Context getActivityContext() { 3485 return this; 3486 } 3487 } 3488