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