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