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