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