ComposeActivity.java revision b479f0f1beb2203f821bc8ce07d5b62d65694349
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 if (uri == null) { 1803 continue; 1804 } 1805 try { 1806 if (handleSpecialAttachmentUri(uri)) { 1807 continue; 1808 } 1809 1810 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1811 attachments.add(a); 1812 1813 Analytics.getInstance().sendEvent("send_intent_attachment", 1814 Utils.normalizeMimeType(a.getContentType()), null, a.size); 1815 1816 } catch (AttachmentFailureException e) { 1817 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1818 String maxSize = AttachmentUtils.convertToHumanReadableSize( 1819 getApplicationContext(), 1820 mAccount.settings.getMaxAttachmentSize()); 1821 showErrorToast(getString 1822 (R.string.generic_attachment_problem, maxSize)); 1823 } 1824 } 1825 totalSize += addAttachments(attachments); 1826 } else { 1827 final Uri uri = extras.getParcelable(Intent.EXTRA_STREAM); 1828 if (uri != null) { 1829 long size = 0; 1830 try { 1831 if (!handleSpecialAttachmentUri(uri)) { 1832 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1833 size = mAttachmentsView.addAttachment(mAccount, a); 1834 1835 Analytics.getInstance().sendEvent("send_intent_attachment", 1836 Utils.normalizeMimeType(a.getContentType()), null, size); 1837 } 1838 1839 } catch (AttachmentFailureException e) { 1840 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1841 showAttachmentTooBigToast(e.getErrorRes()); 1842 } 1843 totalSize += size; 1844 } 1845 } 1846 } 1847 1848 if (totalSize > 0) { 1849 mAttachmentsChanged = true; 1850 updateSaveUi(); 1851 1852 Analytics.getInstance().sendEvent("send_intent_with_attachments", 1853 Integer.toString(getAttachments().size()), null, totalSize); 1854 } 1855 } 1856 } 1857 1858 protected void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) { 1859 mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText); 1860 mShowQuotedText = true; 1861 } 1862 1863 private void initQuotedTextFromRefMessage(Message refMessage, int action) { 1864 if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) { 1865 mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD); 1866 } 1867 } 1868 1869 private void updateHideOrShowCcBcc() { 1870 // Its possible there is a menu item OR a button. 1871 boolean ccVisible = mCcBccView.isCcVisible(); 1872 boolean bccVisible = mCcBccView.isBccVisible(); 1873 if (mCcBccButton != null) { 1874 if (!ccVisible || !bccVisible) { 1875 mCcBccButton.setVisibility(View.VISIBLE); 1876 mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label 1877 : R.string.add_bcc_label)); 1878 } else { 1879 mCcBccButton.setVisibility(View.INVISIBLE); 1880 } 1881 } 1882 } 1883 1884 /** 1885 * Add attachment and update the compose area appropriately. 1886 */ 1887 private void addAttachmentAndUpdateView(Intent data) { 1888 if (data == null) { 1889 return; 1890 } 1891 1892 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { 1893 final ClipData clipData = data.getClipData(); 1894 if (clipData != null) { 1895 for (int i = 0, size = clipData.getItemCount(); i < size; i++) { 1896 addAttachmentAndUpdateView(clipData.getItemAt(i).getUri()); 1897 } 1898 return; 1899 } 1900 } 1901 1902 addAttachmentAndUpdateView(data.getData()); 1903 } 1904 1905 private void addAttachmentAndUpdateView(Uri contentUri) { 1906 if (contentUri == null) { 1907 return; 1908 } 1909 try { 1910 1911 if (handleSpecialAttachmentUri(contentUri)) { 1912 return; 1913 } 1914 1915 addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri)); 1916 } catch (AttachmentFailureException e) { 1917 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1918 showErrorToast(getResources().getString( 1919 e.getErrorRes(), 1920 AttachmentUtils.convertToHumanReadableSize( 1921 getApplicationContext(), mAccount.settings.getMaxAttachmentSize()))); 1922 } 1923 } 1924 1925 /** 1926 * Allow subclasses to implement custom handling of attachments. 1927 * 1928 * @param contentUri a passed-in URI from a pick intent 1929 * @return true iff handled 1930 */ 1931 protected boolean handleSpecialAttachmentUri(final Uri contentUri) { 1932 return false; 1933 } 1934 1935 private void addAttachmentAndUpdateView(Attachment attachment) { 1936 try { 1937 long size = mAttachmentsView.addAttachment(mAccount, attachment); 1938 if (size > 0) { 1939 mAttachmentsChanged = true; 1940 updateSaveUi(); 1941 } 1942 } catch (AttachmentFailureException e) { 1943 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1944 showAttachmentTooBigToast(e.getErrorRes()); 1945 } 1946 } 1947 1948 void initRecipientsFromRefMessage(Message refMessage, int action) { 1949 // Don't populate the address if this is a forward. 1950 if (action == ComposeActivity.FORWARD) { 1951 return; 1952 } 1953 initReplyRecipients(refMessage, action); 1954 } 1955 1956 // TODO: This should be private. This method shouldn't be used by ComposeActivityTests, as 1957 // it doesn't setup the state of the activity correctly 1958 @VisibleForTesting 1959 void initReplyRecipients(final Message refMessage, final int action) { 1960 String[] sentToAddresses = refMessage.getToAddressesUnescaped(); 1961 final Collection<String> toAddresses; 1962 final String[] fromAddresses = refMessage.getFromAddressesUnescaped(); 1963 final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null; 1964 final String[] replyToAddresses = getReplyToAddresses( 1965 refMessage.getReplyToAddressesUnescaped(), fromAddress); 1966 1967 // If this is a reply, the Cc list is empty. If this is a reply-all, the 1968 // Cc list is the union of the To and Cc recipients of the original 1969 // message, excluding the current user's email address and any addresses 1970 // already on the To list. 1971 if (action == ComposeActivity.REPLY) { 1972 toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses); 1973 addToAddresses(toAddresses); 1974 } else if (action == ComposeActivity.REPLY_ALL) { 1975 final Set<String> ccAddresses = Sets.newHashSet(); 1976 toAddresses = initToRecipients(fromAddress, replyToAddresses, sentToAddresses); 1977 addToAddresses(toAddresses); 1978 addRecipients(ccAddresses, sentToAddresses); 1979 addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped()); 1980 addCcAddresses(ccAddresses, toAddresses); 1981 } 1982 } 1983 1984 // If there is no reply to address, the reply to address is the sender. 1985 private static String[] getReplyToAddresses(String[] replyTo, String from) { 1986 boolean hasReplyTo = false; 1987 for (final String replyToAddress : replyTo) { 1988 if (!TextUtils.isEmpty(replyToAddress)) { 1989 hasReplyTo = true; 1990 } 1991 } 1992 return hasReplyTo ? replyTo : new String[] {from}; 1993 } 1994 1995 private void addToAddresses(Collection<String> addresses) { 1996 addAddressesToList(addresses, mTo); 1997 } 1998 1999 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 2000 addCcAddressesToList(tokenizeAddressList(addresses), 2001 toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc); 2002 } 2003 2004 private void addBccAddresses(Collection<String> addresses) { 2005 addAddressesToList(addresses, mBcc); 2006 } 2007 2008 @VisibleForTesting 2009 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 2010 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 2011 String address; 2012 2013 if (compareToList == null) { 2014 for (final Rfc822Token[] tokens : addresses) { 2015 for (final Rfc822Token token : tokens) { 2016 address = token.toString(); 2017 list.append(address + END_TOKEN); 2018 } 2019 } 2020 } else { 2021 HashSet<String> compareTo = convertToHashSet(compareToList); 2022 for (final Rfc822Token[] tokens : addresses) { 2023 for (final Rfc822Token token : tokens) { 2024 address = token.toString(); 2025 // Check if this is a duplicate: 2026 if (!compareTo.contains(token.getAddress())) { 2027 // Get the address here 2028 list.append(address + END_TOKEN); 2029 } 2030 } 2031 } 2032 } 2033 } 2034 2035 private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) { 2036 final HashSet<String> hash = new HashSet<String>(); 2037 for (final Rfc822Token[] tokens : list) { 2038 for (final Rfc822Token token : tokens) { 2039 hash.add(token.getAddress()); 2040 } 2041 } 2042 return hash; 2043 } 2044 2045 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 2046 @VisibleForTesting 2047 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 2048 2049 for (String address: addresses) { 2050 tokenized.add(Rfc822Tokenizer.tokenize(address)); 2051 } 2052 return tokenized; 2053 } 2054 2055 @VisibleForTesting 2056 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 2057 for (String address : addresses) { 2058 addAddressToList(address, list); 2059 } 2060 } 2061 2062 private static void addAddressToList(final String address, final RecipientEditTextView list) { 2063 if (address == null || list == null) 2064 return; 2065 2066 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 2067 2068 for (final Rfc822Token token : tokens) { 2069 list.append(token + END_TOKEN); 2070 } 2071 } 2072 2073 @VisibleForTesting 2074 protected Collection<String> initToRecipients(final String fullSenderAddress, 2075 final String[] replyToAddresses, final String[] inToAddresses) { 2076 // The To recipient is the reply-to address specified in the original 2077 // message, unless it is: 2078 // the current user OR a custom from of the current user, in which case 2079 // it's the To recipient list of the original message. 2080 // OR missing, in which case use the sender of the original message 2081 Set<String> toAddresses = Sets.newHashSet(); 2082 for (final String replyToAddress : replyToAddresses) { 2083 if (!TextUtils.isEmpty(replyToAddress) 2084 && !recipientMatchesThisAccount(replyToAddress)) { 2085 toAddresses.add(replyToAddress); 2086 } 2087 } 2088 if (toAddresses.size() == 0) { 2089 // In this case, the user is replying to a message in which their 2090 // current account or some of their custom from addresses are the only 2091 // recipients and they sent the original message. 2092 if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress) 2093 && recipientMatchesThisAccount(inToAddresses[0])) { 2094 toAddresses.add(inToAddresses[0]); 2095 return toAddresses; 2096 } 2097 // This happens if the user replies to a message they originally 2098 // wrote. In this case, "reply" really means "re-send," so we 2099 // target the original recipients. This works as expected even 2100 // if the user sent the original message to themselves. 2101 for (String address : inToAddresses) { 2102 if (!recipientMatchesThisAccount(address)) { 2103 toAddresses.add(address); 2104 } 2105 } 2106 } 2107 return toAddresses; 2108 } 2109 2110 private void addRecipients(final Set<String> recipients, final String[] addresses) { 2111 for (final String email : addresses) { 2112 // Do not add this account, or any of its custom from addresses, to 2113 // the list of recipients. 2114 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 2115 if (!recipientMatchesThisAccount(recipientAddress)) { 2116 recipients.add(email.replace("\"\"", "")); 2117 } 2118 } 2119 } 2120 2121 /** 2122 * A recipient matches this account if it has the same address as the 2123 * currently selected account OR one of the custom from addresses associated 2124 * with the currently selected account. 2125 * @param recipientAddress address we are comparing with the currently selected account 2126 */ 2127 protected boolean recipientMatchesThisAccount(String recipientAddress) { 2128 return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress, 2129 mAccount.getReplyFroms()); 2130 } 2131 2132 /** 2133 * Returns a formatted subject string with the appropriate prefix for the action type. 2134 * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}. 2135 */ 2136 public static String buildFormattedSubject(final Resources res, final String subject, 2137 final int action) { 2138 final String prefix; 2139 final String correctedSubject; 2140 if (action == ComposeActivity.COMPOSE) { 2141 prefix = ""; 2142 } else if (action == ComposeActivity.FORWARD) { 2143 prefix = res.getString(R.string.forward_subject_label); 2144 } else { 2145 prefix = res.getString(R.string.reply_subject_label); 2146 } 2147 2148 // Don't duplicate the prefix 2149 if (!TextUtils.isEmpty(subject) 2150 && subject.toLowerCase().startsWith(prefix.toLowerCase())) { 2151 correctedSubject = subject; 2152 } else { 2153 final String subjectOrNoSubject = TextUtils.isEmpty(subject) ? 2154 res.getString(R.string.no_subject) : 2155 subject; 2156 2157 correctedSubject = 2158 res.getString(R.string.formatted_subject, prefix, subjectOrNoSubject); 2159 } 2160 2161 return correctedSubject; 2162 } 2163 2164 private void setSubject(Message refMessage, int action) { 2165 mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action)); 2166 } 2167 2168 private void initRecipients() { 2169 setupRecipients(mTo); 2170 setupRecipients(mCc); 2171 setupRecipients(mBcc); 2172 } 2173 2174 private void setupRecipients(RecipientEditTextView view) { 2175 // todo - remove this experiment 2176 if (LogUtils.isLoggable("NewChips", LogUtils.DEBUG) || mUseNewChips) { 2177 final DropdownChipLayouter layouter = getDropdownChipLayouter(); 2178 if (layouter != null) { 2179 view.setDropdownChipLayouter(layouter); 2180 } 2181 view.setAdapter(getRecipientAdapter()); 2182 } else { 2183 view.setAdapter(new RecipientAdapter(this, mAccount)); 2184 } 2185 view.setRecipientEntryItemClickedListener(this); 2186 if (mValidator == null) { 2187 final String accountName = mAccount.getEmailAddress(); 2188 int offset = accountName.indexOf("@") + 1; 2189 String account = accountName; 2190 if (offset > 0) { 2191 account = account.substring(offset); 2192 } 2193 mValidator = new Rfc822Validator(account); 2194 } 2195 view.setValidator(mValidator); 2196 } 2197 2198 /** 2199 * Derived classes should override if they wish to provide their own autocomplete behavior. 2200 */ 2201 public BaseRecipientAdapter getRecipientAdapter() { 2202 return new RecipientAdapter(this, mAccount); 2203 } 2204 2205 /** 2206 * Derived classes should override this to provide their own dropdown behavior. 2207 * If the result is null, the default {@link com.android.ex.chips.DropdownChipLayouter} 2208 * is used. 2209 */ 2210 public DropdownChipLayouter getDropdownChipLayouter() { 2211 return null; 2212 } 2213 2214 @Override 2215 public void onClick(View v) { 2216 final int id = v.getId(); 2217 if (id == R.id.add_cc_bcc) { 2218 // Verify that cc/ bcc aren't showing. 2219 // Animate in cc/bcc. 2220 showCcBccViews(); 2221 } else if (id == R.id.add_attachment) { 2222 doAttach(Utils.isRunningKitkatOrLater() ? MIME_TYPE_ALL : MIME_TYPE_PHOTO); 2223 } 2224 } 2225 2226 @Override 2227 public boolean onCreateOptionsMenu(Menu menu) { 2228 final boolean superCreated = super.onCreateOptionsMenu(menu); 2229 // Don't render any menu items when there are no accounts. 2230 if (mAccounts == null || mAccounts.length == 0) { 2231 return superCreated; 2232 } 2233 MenuInflater inflater = getMenuInflater(); 2234 inflater.inflate(R.menu.compose_menu, menu); 2235 2236 /* 2237 * Start save in the correct enabled state. 2238 * 1) If a user launches compose from within gmail, save is disabled 2239 * until they add something, at which point, save is enabled, auto save 2240 * on exit; if the user empties everything, save is disabled, exiting does not 2241 * auto-save 2242 * 2) if a user replies/ reply all/ forwards from within gmail, save is 2243 * disabled until they change something, at which point, save is 2244 * enabled, auto save on exit; if the user empties everything, save is 2245 * disabled, exiting does not auto-save. 2246 * 3) If a user launches compose from another application and something 2247 * gets populated (attachments, recipients, body, subject, etc), save is 2248 * enabled, auto save on exit; if the user empties everything, save is 2249 * disabled, exiting does not auto-save 2250 */ 2251 mSave = menu.findItem(R.id.save); 2252 String action = getIntent() != null ? getIntent().getAction() : null; 2253 enableSave(mInnerSavedState != null ? 2254 mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED) 2255 : (Intent.ACTION_SEND.equals(action) 2256 || Intent.ACTION_SEND_MULTIPLE.equals(action) 2257 || Intent.ACTION_SENDTO.equals(action) 2258 || shouldSave())); 2259 2260 MenuItem helpItem = menu.findItem(R.id.help_info_menu_item); 2261 MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item); 2262 if (helpItem != null) { 2263 helpItem.setVisible(mAccount != null 2264 && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT)); 2265 } 2266 if (sendFeedbackItem != null) { 2267 sendFeedbackItem.setVisible(mAccount != null 2268 && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK)); 2269 } 2270 2271 // Show attach picture on pre-K devices. 2272 menu.findItem(R.id.add_photo_attachment).setVisible(!Utils.isRunningKitkatOrLater()); 2273 2274 return true; 2275 } 2276 2277 @Override 2278 public boolean onPrepareOptionsMenu(Menu menu) { 2279 MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc); 2280 if (ccBcc != null && mCc != null) { 2281 // Its possible there is a menu item OR a button. 2282 boolean ccFieldVisible = mCc.isShown(); 2283 boolean bccFieldVisible = mBcc.isShown(); 2284 if (!ccFieldVisible || !bccFieldVisible) { 2285 ccBcc.setVisible(true); 2286 ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label 2287 : R.string.add_bcc_label)); 2288 } else { 2289 ccBcc.setVisible(false); 2290 } 2291 } 2292 return true; 2293 } 2294 2295 @Override 2296 public boolean onOptionsItemSelected(MenuItem item) { 2297 final int id = item.getItemId(); 2298 2299 Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id, 2300 "compose", 0); 2301 2302 boolean handled = true; 2303 if (id == R.id.add_file_attachment) { 2304 doAttach(MIME_TYPE_ALL); 2305 } else if (id == R.id.add_photo_attachment) { 2306 doAttach(MIME_TYPE_PHOTO); 2307 } else if (id == R.id.add_cc_bcc) { 2308 showCcBccViews(); 2309 } else if (id == R.id.save) { 2310 doSave(true); 2311 } else if (id == R.id.send) { 2312 doSend(); 2313 } else if (id == R.id.discard) { 2314 doDiscard(); 2315 } else if (id == R.id.settings) { 2316 Utils.showSettings(this, mAccount); 2317 } else if (id == android.R.id.home) { 2318 onAppUpPressed(); 2319 } else if (id == R.id.help_info_menu_item) { 2320 Utils.showHelp(this, mAccount, getString(R.string.compose_help_context)); 2321 } else if (id == R.id.feedback_menu_item) { 2322 Utils.sendFeedback(this, mAccount, false); 2323 } else { 2324 handled = false; 2325 } 2326 return handled || super.onOptionsItemSelected(item); 2327 } 2328 2329 @Override 2330 public void onBackPressed() { 2331 // If we are showing the wait fragment, just exit. 2332 if (getWaitFragment() != null) { 2333 finish(); 2334 } else { 2335 super.onBackPressed(); 2336 } 2337 } 2338 2339 /** 2340 * Carries out the "up" action in the action bar. 2341 */ 2342 private void onAppUpPressed() { 2343 if (mLaunchedFromEmail) { 2344 // If this was started from Gmail, simply treat app up as the system back button, so 2345 // that the last view is restored. 2346 onBackPressed(); 2347 return; 2348 } 2349 2350 // Fire the main activity to ensure it launches the "top" screen of mail. 2351 // Since the main Activity is singleTask, it should revive that task if it was already 2352 // started. 2353 final Intent mailIntent = Utils.createViewInboxIntent(mAccount); 2354 mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 2355 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 2356 startActivity(mailIntent); 2357 finish(); 2358 } 2359 2360 private void doSend() { 2361 sendOrSaveWithSanityChecks(false, true, false, false); 2362 logSendOrSave(false /* save */); 2363 mPerformedSendOrDiscard = true; 2364 } 2365 2366 private void doSave(boolean showToast) { 2367 sendOrSaveWithSanityChecks(true, showToast, false, false); 2368 } 2369 2370 @Override 2371 public void onRecipientEntryItemClicked(int charactersTyped, int position) { 2372 // Send analytics of characters typed and position in dropdown selected. 2373 final String category = mUseNewChips ? "suggest_click_new" : "suggest_click_old"; 2374 Analytics.getInstance().sendEvent( 2375 category, Integer.toString(charactersTyped), Integer.toString(position), 0); 2376 } 2377 2378 @VisibleForTesting 2379 public interface SendOrSaveCallback { 2380 void initializeSendOrSave(SendOrSaveTask sendOrSaveTask); 2381 void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message); 2382 Message getMessage(); 2383 void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success); 2384 void incrementRecipientsTimesContacted(List<String> recipients); 2385 } 2386 2387 @VisibleForTesting 2388 public static class SendOrSaveTask implements Runnable { 2389 private final Context mContext; 2390 @VisibleForTesting 2391 public final SendOrSaveCallback mSendOrSaveCallback; 2392 @VisibleForTesting 2393 public final SendOrSaveMessage mSendOrSaveMessage; 2394 private ReplyFromAccount mExistingDraftAccount; 2395 2396 public SendOrSaveTask(Context context, SendOrSaveMessage message, 2397 SendOrSaveCallback callback, ReplyFromAccount draftAccount) { 2398 mContext = context; 2399 mSendOrSaveCallback = callback; 2400 mSendOrSaveMessage = message; 2401 mExistingDraftAccount = draftAccount; 2402 } 2403 2404 @Override 2405 public void run() { 2406 final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage; 2407 2408 final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount; 2409 Message message = mSendOrSaveCallback.getMessage(); 2410 long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID; 2411 // If a previous draft has been saved, in an account that is different 2412 // than what the user wants to send from, remove the old draft, and treat this 2413 // as a new message 2414 if (mExistingDraftAccount != null 2415 && !selectedAccount.account.uri.equals(mExistingDraftAccount.account.uri)) { 2416 if (messageId != UIProvider.INVALID_MESSAGE_ID) { 2417 ContentResolver resolver = mContext.getContentResolver(); 2418 ContentValues values = new ContentValues(); 2419 values.put(BaseColumns._ID, messageId); 2420 if (mExistingDraftAccount.account.expungeMessageUri != null) { 2421 new ContentProviderTask.UpdateTask() 2422 .run(resolver, mExistingDraftAccount.account.expungeMessageUri, 2423 values, null, null); 2424 } else { 2425 // TODO(mindyp) delete the conversation. 2426 } 2427 // reset messageId to 0, so a new message will be created 2428 messageId = UIProvider.INVALID_MESSAGE_ID; 2429 } 2430 } 2431 2432 final long messageIdToSave = messageId; 2433 sendOrSaveMessage(messageIdToSave, sendOrSaveMessage, selectedAccount); 2434 2435 if (!sendOrSaveMessage.mSave) { 2436 incrementRecipientsTimesContacted( 2437 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO)); 2438 incrementRecipientsTimesContacted( 2439 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC)); 2440 incrementRecipientsTimesContacted( 2441 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC)); 2442 } 2443 mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true); 2444 } 2445 2446 private void incrementRecipientsTimesContacted(final String addressString) { 2447 if (TextUtils.isEmpty(addressString)) { 2448 return; 2449 } 2450 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString); 2451 final ArrayList<String> recipients = new ArrayList<String>(tokens.length); 2452 for (final Rfc822Token token : tokens) { 2453 recipients.add(token.getAddress()); 2454 } 2455 mSendOrSaveCallback.incrementRecipientsTimesContacted(recipients); 2456 } 2457 2458 /** 2459 * Send or Save a message. 2460 */ 2461 private void sendOrSaveMessage(final long messageIdToSave, 2462 final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) { 2463 final ContentResolver resolver = mContext.getContentResolver(); 2464 final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID; 2465 2466 final String accountMethod = sendOrSaveMessage.mSave ? 2467 UIProvider.AccountCallMethods.SAVE_MESSAGE : 2468 UIProvider.AccountCallMethods.SEND_MESSAGE; 2469 2470 try { 2471 if (updateExistingMessage) { 2472 sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave); 2473 2474 callAccountSendSaveMethod(resolver, 2475 selectedAccount.account, accountMethod, sendOrSaveMessage); 2476 } else { 2477 Uri messageUri = null; 2478 final Bundle result = callAccountSendSaveMethod(resolver, 2479 selectedAccount.account, accountMethod, sendOrSaveMessage); 2480 if (result != null) { 2481 // If a non-null value was returned, then the provider handled the call 2482 // method 2483 messageUri = result.getParcelable(UIProvider.MessageColumns.URI); 2484 } 2485 if (sendOrSaveMessage.mSave && messageUri != null) { 2486 final Cursor messageCursor = resolver.query(messageUri, 2487 UIProvider.MESSAGE_PROJECTION, null, null, null); 2488 if (messageCursor != null) { 2489 try { 2490 if (messageCursor.moveToFirst()) { 2491 // Broadcast notification that a new message has 2492 // been allocated 2493 mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, 2494 new Message(messageCursor)); 2495 } 2496 } finally { 2497 messageCursor.close(); 2498 } 2499 } 2500 } 2501 } 2502 } finally { 2503 // Close any opened file descriptors 2504 closeOpenedAttachmentFds(sendOrSaveMessage); 2505 } 2506 } 2507 2508 private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) { 2509 final Bundle openedFds = sendOrSaveMessage.attachmentFds(); 2510 if (openedFds != null) { 2511 final Set<String> keys = openedFds.keySet(); 2512 for (final String key : keys) { 2513 final ParcelFileDescriptor fd = openedFds.getParcelable(key); 2514 if (fd != null) { 2515 try { 2516 fd.close(); 2517 } catch (IOException e) { 2518 // Do nothing 2519 } 2520 } 2521 } 2522 } 2523 } 2524 2525 /** 2526 * Use the {@link ContentResolver#call} method to send or save the message. 2527 * 2528 * If this was successful, this method will return an non-null Bundle instance 2529 */ 2530 private static Bundle callAccountSendSaveMethod(final ContentResolver resolver, 2531 final Account account, final String method, 2532 final SendOrSaveMessage sendOrSaveMessage) { 2533 // Copy all of the values from the content values to the bundle 2534 final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size()); 2535 final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet(); 2536 2537 for (Entry<String, Object> entry : valueSet) { 2538 final Object entryValue = entry.getValue(); 2539 final String key = entry.getKey(); 2540 if (entryValue instanceof String) { 2541 methodExtras.putString(key, (String)entryValue); 2542 } else if (entryValue instanceof Boolean) { 2543 methodExtras.putBoolean(key, (Boolean)entryValue); 2544 } else if (entryValue instanceof Integer) { 2545 methodExtras.putInt(key, (Integer)entryValue); 2546 } else if (entryValue instanceof Long) { 2547 methodExtras.putLong(key, (Long)entryValue); 2548 } else { 2549 LogUtils.wtf(LOG_TAG, "Unexpected object type: %s", 2550 entryValue.getClass().getName()); 2551 } 2552 } 2553 2554 // If the SendOrSaveMessage has some opened fds, add them to the bundle 2555 final Bundle fdMap = sendOrSaveMessage.attachmentFds(); 2556 if (fdMap != null) { 2557 methodExtras.putParcelable( 2558 UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap); 2559 } 2560 2561 return resolver.call(account.uri, method, account.uri.toString(), methodExtras); 2562 } 2563 } 2564 2565 /** 2566 * Reports recipients that have been contacted in order to improve auto-complete 2567 * suggestions. Default behavior updates usage statistics in ContactsProvider. 2568 * @param recipients addresses 2569 */ 2570 protected void incrementRecipientsTimesContacted(List<String> recipients) { 2571 final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(this); 2572 statsUpdater.updateWithAddress(recipients); 2573 } 2574 2575 @VisibleForTesting 2576 public static class SendOrSaveMessage { 2577 final ReplyFromAccount mAccount; 2578 final ContentValues mValues; 2579 final String mRefMessageId; 2580 @VisibleForTesting 2581 public final boolean mSave; 2582 final int mRequestId; 2583 private final Bundle mAttachmentFds; 2584 2585 public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values, 2586 String refMessageId, List<Attachment> attachments, boolean save) { 2587 mAccount = account; 2588 mValues = values; 2589 mRefMessageId = refMessageId; 2590 mSave = save; 2591 mRequestId = mValues.hashCode() ^ hashCode(); 2592 2593 mAttachmentFds = initializeAttachmentFds(context, attachments); 2594 } 2595 2596 int requestId() { 2597 return mRequestId; 2598 } 2599 2600 Bundle attachmentFds() { 2601 return mAttachmentFds; 2602 } 2603 2604 /** 2605 * Opens {@link ParcelFileDescriptor} for each of the attachments. This method must be 2606 * called before the ComposeActivity finishes. 2607 * Note: The caller is responsible for closing these file descriptors. 2608 */ 2609 private static Bundle initializeAttachmentFds(final Context context, 2610 final List<Attachment> attachments) { 2611 if (attachments == null || attachments.size() == 0) { 2612 return null; 2613 } 2614 2615 final Bundle result = new Bundle(attachments.size()); 2616 final ContentResolver resolver = context.getContentResolver(); 2617 2618 for (Attachment attachment : attachments) { 2619 if (attachment == null || Utils.isEmpty(attachment.contentUri)) { 2620 continue; 2621 } 2622 2623 ParcelFileDescriptor fileDescriptor; 2624 try { 2625 fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r"); 2626 } catch (FileNotFoundException e) { 2627 LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment"); 2628 fileDescriptor = null; 2629 } catch (SecurityException e) { 2630 // We have encountered a security exception when attempting to open the file 2631 // specified by the content uri. If the attachment has been cached, this 2632 // isn't a problem, as even through the original permission may have been 2633 // revoked, we have cached the file. This will happen when saving/sending 2634 // a previously saved draft. 2635 // TODO(markwei): Expose whether the attachment has been cached through the 2636 // attachment object. This would allow us to limit when the log is made, as 2637 // if the attachment has been cached, this really isn't an error 2638 LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment"); 2639 // Just set the file descriptor to null, as the underlying provider needs 2640 // to handle the file descriptor not being set. 2641 fileDescriptor = null; 2642 } 2643 2644 if (fileDescriptor != null) { 2645 result.putParcelable(attachment.contentUri.toString(), fileDescriptor); 2646 } 2647 } 2648 2649 return result; 2650 } 2651 } 2652 2653 /** 2654 * Get the to recipients. 2655 */ 2656 public String[] getToAddresses() { 2657 return getAddressesFromList(mTo); 2658 } 2659 2660 /** 2661 * Get the cc recipients. 2662 */ 2663 public String[] getCcAddresses() { 2664 return getAddressesFromList(mCc); 2665 } 2666 2667 /** 2668 * Get the bcc recipients. 2669 */ 2670 public String[] getBccAddresses() { 2671 return getAddressesFromList(mBcc); 2672 } 2673 2674 public String[] getAddressesFromList(RecipientEditTextView list) { 2675 if (list == null) { 2676 return new String[0]; 2677 } 2678 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText()); 2679 int count = tokens.length; 2680 String[] result = new String[count]; 2681 for (int i = 0; i < count; i++) { 2682 result[i] = tokens[i].toString(); 2683 } 2684 return result; 2685 } 2686 2687 /** 2688 * Check for invalid email addresses. 2689 * @param to String array of email addresses to check. 2690 * @param wrongEmailsOut Emails addresses that were invalid. 2691 */ 2692 public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) { 2693 if (mValidator == null) { 2694 return; 2695 } 2696 for (final String email : to) { 2697 if (!mValidator.isValid(email)) { 2698 wrongEmailsOut.add(email); 2699 } 2700 } 2701 } 2702 2703 public static class RecipientErrorDialogFragment extends DialogFragment { 2704 // Public no-args constructor needed for fragment re-instantiation 2705 public RecipientErrorDialogFragment() {} 2706 2707 public static RecipientErrorDialogFragment newInstance(final String message) { 2708 final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment(); 2709 final Bundle args = new Bundle(1); 2710 args.putString("message", message); 2711 frag.setArguments(args); 2712 return frag; 2713 } 2714 2715 @Override 2716 public Dialog onCreateDialog(Bundle savedInstanceState) { 2717 final String message = getArguments().getString("message"); 2718 return new AlertDialog.Builder(getActivity()) 2719 .setMessage(message) 2720 .setPositiveButton( 2721 R.string.ok, new Dialog.OnClickListener() { 2722 @Override 2723 public void onClick(DialogInterface dialog, int which) { 2724 ((ComposeActivity)getActivity()).finishRecipientErrorDialog(); 2725 } 2726 }).create(); 2727 } 2728 } 2729 2730 private void finishRecipientErrorDialog() { 2731 // after the user dismisses the recipient error 2732 // dialog we want to make sure to refocus the 2733 // recipient to field so they can fix the issue 2734 // easily 2735 if (mTo != null) { 2736 mTo.requestFocus(); 2737 } 2738 } 2739 2740 /** 2741 * Show an error because the user has entered an invalid recipient. 2742 */ 2743 private void showRecipientErrorDialog(final String message) { 2744 final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message); 2745 frag.show(getFragmentManager(), "recipient error"); 2746 } 2747 2748 /** 2749 * Update the state of the UI based on whether or not the current draft 2750 * needs to be saved and the message is not empty. 2751 */ 2752 public void updateSaveUi() { 2753 if (mSave != null) { 2754 mSave.setEnabled((shouldSave() && !isBlank())); 2755 } 2756 } 2757 2758 /** 2759 * Returns true if we need to save the current draft. 2760 */ 2761 private boolean shouldSave() { 2762 synchronized (mDraftLock) { 2763 // The message should only be saved if: 2764 // It hasn't been sent AND 2765 // Some text has been added to the message OR 2766 // an attachment has been added or removed 2767 // AND there is actually something in the draft to save. 2768 return (mTextChanged || mAttachmentsChanged || mReplyFromChanged) 2769 && !isBlank(); 2770 } 2771 } 2772 2773 /** 2774 * Check if all fields are blank. 2775 * @return boolean 2776 */ 2777 public boolean isBlank() { 2778 // Need to check for null since isBlank() can be called from onPause() 2779 // before findViews() is called 2780 if (mSubject == null || mBodyView == null || mTo == null || mCc == null || 2781 mAttachmentsView == null) { 2782 LogUtils.w(LOG_TAG, "null views in isBlank check"); 2783 return true; 2784 } 2785 return mSubject.getText().length() == 0 2786 && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature, 2787 mBodyView.getText().toString()) == 0) 2788 && mTo.length() == 0 2789 && mCc.length() == 0 && mBcc.length() == 0 2790 && mAttachmentsView.getAttachments().size() == 0; 2791 } 2792 2793 @VisibleForTesting 2794 protected int getSignatureStartPosition(String signature, String bodyText) { 2795 int startPos = -1; 2796 2797 if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) { 2798 return startPos; 2799 } 2800 2801 int bodyLength = bodyText.length(); 2802 int signatureLength = signature.length(); 2803 String printableVersion = convertToPrintableSignature(signature); 2804 int printableLength = printableVersion.length(); 2805 2806 if (bodyLength >= printableLength 2807 && bodyText.substring(bodyLength - printableLength) 2808 .equals(printableVersion)) { 2809 startPos = bodyLength - printableLength; 2810 } else if (bodyLength >= signatureLength 2811 && bodyText.substring(bodyLength - signatureLength) 2812 .equals(signature)) { 2813 startPos = bodyLength - signatureLength; 2814 } 2815 return startPos; 2816 } 2817 2818 /** 2819 * Allows any changes made by the user to be ignored. Called when the user 2820 * decides to discard a draft. 2821 */ 2822 private void discardChanges() { 2823 mTextChanged = false; 2824 mAttachmentsChanged = false; 2825 mReplyFromChanged = false; 2826 } 2827 2828 /** 2829 * @param save True to save, false to send 2830 * @param showToast True to show a toast once the message is sent/saved 2831 */ 2832 protected void sendOrSaveWithSanityChecks(final boolean save, final boolean showToast, 2833 final boolean orientationChanged, final boolean autoSend) { 2834 if (mAccounts == null || mAccount == null) { 2835 Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show(); 2836 if (autoSend) { 2837 finish(); 2838 } 2839 return; 2840 } 2841 2842 final String[] to, cc, bcc; 2843 if (orientationChanged) { 2844 to = cc = bcc = new String[0]; 2845 } else { 2846 to = getToAddresses(); 2847 cc = getCcAddresses(); 2848 bcc = getBccAddresses(); 2849 } 2850 2851 final ArrayList<String> recipients = buildEmailAddressList(to); 2852 recipients.addAll(buildEmailAddressList(cc)); 2853 recipients.addAll(buildEmailAddressList(bcc)); 2854 2855 // Don't let the user send to nobody (but it's okay to save a message 2856 // with no recipients) 2857 if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) { 2858 showRecipientErrorDialog(getString(R.string.recipient_needed)); 2859 return; 2860 } 2861 2862 List<String> wrongEmails = new ArrayList<String>(); 2863 if (!save) { 2864 checkInvalidEmails(to, wrongEmails); 2865 checkInvalidEmails(cc, wrongEmails); 2866 checkInvalidEmails(bcc, wrongEmails); 2867 } 2868 2869 // Don't let the user send an email with invalid recipients 2870 if (wrongEmails.size() > 0) { 2871 String errorText = String.format(getString(R.string.invalid_recipient), 2872 wrongEmails.get(0)); 2873 showRecipientErrorDialog(errorText); 2874 return; 2875 } 2876 2877 if (!save) { 2878 if (autoSend) { 2879 // Skip all further checks during autosend. This flow is used by Android Wear 2880 // and Google Now. 2881 sendOrSave(save, showToast); 2882 return; 2883 } 2884 2885 // Show a warning before sending only if there are no attachments, body, or subject. 2886 if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) { 2887 boolean warnAboutEmptySubject = isSubjectEmpty(); 2888 boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0; 2889 2890 // A warning about an empty body may not be warranted when 2891 // forwarding mails, since a common use case is to forward 2892 // quoted text and not append any more text. 2893 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty()); 2894 2895 // When we bring up a dialog warning the user about a send, 2896 // assume that they accept sending the message. If they do not, 2897 // the dialog listener is required to enable sending again. 2898 if (warnAboutEmptySubject) { 2899 showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, 2900 showToast, recipients); 2901 return; 2902 } 2903 2904 if (warnAboutEmptyBody) { 2905 showSendConfirmDialog(R.string.confirm_send_message_with_no_body, 2906 showToast, recipients); 2907 return; 2908 } 2909 } 2910 // Ask for confirmation to send. 2911 if (showSendConfirmation()) { 2912 showSendConfirmDialog(R.string.confirm_send_message, showToast, recipients); 2913 return; 2914 } 2915 } 2916 2917 performAdditionalSendOrSaveSanityChecks(save, showToast, recipients); 2918 } 2919 2920 /** 2921 * Returns a boolean indicating whether warnings should be shown for empty 2922 * subject and body fields 2923 * 2924 * @return True if a warning should be shown for empty text fields 2925 */ 2926 protected boolean showEmptyTextWarnings() { 2927 return mAttachmentsView.getAttachments().size() == 0; 2928 } 2929 2930 /** 2931 * Returns a boolean indicating whether the user should confirm each send 2932 * 2933 * @return True if a warning should be on each send 2934 */ 2935 protected boolean showSendConfirmation() { 2936 return mCachedSettings != null && mCachedSettings.confirmSend; 2937 } 2938 2939 public static class SendConfirmDialogFragment extends DialogFragment 2940 implements DialogInterface.OnClickListener { 2941 2942 private static final String MESSAGE_ID = "messageId"; 2943 private static final String SHOW_TOAST = "showToast"; 2944 private static final String RECIPIENTS = "recipients"; 2945 2946 private boolean mShowToast; 2947 2948 private ArrayList<String> mRecipients; 2949 2950 // Public no-args constructor needed for fragment re-instantiation 2951 public SendConfirmDialogFragment() {} 2952 2953 public static SendConfirmDialogFragment newInstance(final int messageId, 2954 final boolean showToast, final ArrayList<String> recipients) { 2955 final SendConfirmDialogFragment frag = new SendConfirmDialogFragment(); 2956 final Bundle args = new Bundle(3); 2957 args.putInt(MESSAGE_ID, messageId); 2958 args.putBoolean(SHOW_TOAST, showToast); 2959 args.putStringArrayList(RECIPIENTS, recipients); 2960 frag.setArguments(args); 2961 return frag; 2962 } 2963 2964 @Override 2965 public Dialog onCreateDialog(Bundle savedInstanceState) { 2966 final int messageId = getArguments().getInt(MESSAGE_ID); 2967 mShowToast = getArguments().getBoolean(SHOW_TOAST); 2968 mRecipients = getArguments().getStringArrayList(RECIPIENTS); 2969 2970 final int confirmTextId = (messageId == R.string.confirm_send_message) ? 2971 R.string.ok : R.string.send; 2972 2973 return new AlertDialog.Builder(getActivity()) 2974 .setMessage(messageId) 2975 .setPositiveButton(confirmTextId, this) 2976 .setNegativeButton(R.string.cancel, null) 2977 .create(); 2978 } 2979 2980 @Override 2981 public void onClick(DialogInterface dialog, int which) { 2982 if (which == DialogInterface.BUTTON_POSITIVE) { 2983 ((ComposeActivity) getActivity()).finishSendConfirmDialog(mShowToast, mRecipients); 2984 } 2985 } 2986 } 2987 2988 private void finishSendConfirmDialog( 2989 final boolean showToast, final ArrayList<String> recipients) { 2990 performAdditionalSendOrSaveSanityChecks(false /* save */, showToast, recipients); 2991 } 2992 2993 // The list of recipients are used by the additional sendOrSave checks. 2994 // However, the send confirm dialog may be shown before performing 2995 // the additional checks. As a result, we need to plumb the recipient 2996 // list through the send confirm dialog so that 2997 // performAdditionalSendOrSaveChecks can be performed properly. 2998 private void showSendConfirmDialog(final int messageId, 2999 final boolean showToast, final ArrayList<String> recipients) { 3000 final DialogFragment frag = SendConfirmDialogFragment.newInstance( 3001 messageId, showToast, recipients); 3002 frag.show(getFragmentManager(), "send confirm"); 3003 } 3004 3005 /** 3006 * Returns whether the ComposeArea believes there is any text in the body of 3007 * the composition. TODO: When ComposeArea controls the Body as well, add 3008 * that here. 3009 */ 3010 public boolean isBodyEmpty() { 3011 return !mQuotedTextView.isTextIncluded(); 3012 } 3013 3014 /** 3015 * Test to see if the subject is empty. 3016 * 3017 * @return boolean. 3018 */ 3019 // TODO: this will likely go away when composeArea.focus() is implemented 3020 // after all the widget control is moved over. 3021 public boolean isSubjectEmpty() { 3022 return TextUtils.getTrimmedLength(mSubject.getText()) == 0; 3023 } 3024 3025 @VisibleForTesting 3026 public String getSubject() { 3027 return mSubject.getText().toString(); 3028 } 3029 3030 private int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount, 3031 Message message, final Message refMessage, Spanned body, final CharSequence quotedText, 3032 SendOrSaveCallback callback, Handler handler, boolean save, int composeMode, 3033 ReplyFromAccount draftAccount, final ContentValues extraValues) { 3034 final ContentValues values = new ContentValues(); 3035 3036 final String refMessageId = refMessage != null ? refMessage.uri.toString() : ""; 3037 3038 MessageModification.putToAddresses(values, message.getToAddresses()); 3039 MessageModification.putCcAddresses(values, message.getCcAddresses()); 3040 MessageModification.putBccAddresses(values, message.getBccAddresses()); 3041 MessageModification.putCustomFromAddress(values, message.getFrom()); 3042 3043 MessageModification.putSubject(values, message.subject); 3044 3045 // Make sure to remove only the composing spans from the Spannable before saving. 3046 final String htmlBody = spannedBodyToHtml(body); 3047 final String textBody = Utils.convertHtmlToPlainText(htmlBody); 3048 // fullbody will contain the actual body plus the quoted text. 3049 final String fullBody; 3050 final String quotedString; 3051 final boolean hasQuotedText = !TextUtils.isEmpty(quotedText); 3052 if (hasQuotedText) { 3053 // The quoted text is HTML at this point. 3054 quotedString = quotedText.toString(); 3055 fullBody = htmlBody + quotedString; 3056 MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD); 3057 MessageModification.putAppendRefMessageContent(values, true /* include quoted */); 3058 } else { 3059 fullBody = htmlBody; 3060 quotedString = null; 3061 } 3062 if (refMessage != null) { 3063 // The code below might need to be revisited. The quoted text position is different 3064 // between text/html and text/plain parts and they should be stored seperately and 3065 // the right version should be used in the UI. text/html should have preference 3066 // if both exist. Issues like this made me file b/14256940 to make sure that we 3067 // properly handle the existing of both text/html and text/plain parts and to verify 3068 // that we are not making some assumptions that break if there is no text/html part. 3069 int quotedTextPos = -1; 3070 if (!TextUtils.isEmpty(refMessage.bodyHtml)) { 3071 MessageModification.putBodyHtml(values, fullBody.toString()); 3072 if (hasQuotedText) { 3073 quotedTextPos = htmlBody.length() + 3074 QuotedTextView.getQuotedTextOffset(quotedString); 3075 } 3076 } 3077 if (!TextUtils.isEmpty(refMessage.bodyText)) { 3078 MessageModification.putBody(values, 3079 Utils.convertHtmlToPlainText(fullBody.toString())); 3080 if (hasQuotedText && (quotedTextPos == -1)) { 3081 quotedTextPos = textBody.length(); 3082 } 3083 } 3084 if (quotedTextPos != -1) { 3085 // The quoted text pos is the text/html version first and the text/plan version 3086 // if there is no text/html part. The reason for this is because preference 3087 // is given to text/html in the compose window if it exists. In the future, we 3088 // should calculate the index for both since the user could choose to compose 3089 // explicitly in text/plain. 3090 MessageModification.putQuoteStartPos(values, quotedTextPos); 3091 } 3092 } else { 3093 MessageModification.putBodyHtml(values, fullBody.toString()); 3094 MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString())); 3095 } 3096 int draftType = getDraftType(composeMode); 3097 MessageModification.putDraftType(values, draftType); 3098 MessageModification.putAttachments(values, message.getAttachments()); 3099 if (!TextUtils.isEmpty(refMessageId)) { 3100 MessageModification.putRefMessageId(values, refMessageId); 3101 } 3102 if (extraValues != null) { 3103 values.putAll(extraValues); 3104 } 3105 SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount, 3106 values, refMessageId, message.getAttachments(), save); 3107 SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback, 3108 draftAccount); 3109 3110 callback.initializeSendOrSave(sendOrSaveTask); 3111 // Do the send/save action on the specified handler to avoid possible 3112 // ANRs 3113 handler.post(sendOrSaveTask); 3114 3115 return sendOrSaveMessage.requestId(); 3116 } 3117 3118 /** 3119 * Removes any composing spans from the specified string. This will create a new 3120 * SpannableString instance, as to not modify the behavior of the EditText view. 3121 */ 3122 private static SpannableString removeComposingSpans(Spanned body) { 3123 final SpannableString messageBody = new SpannableString(body); 3124 BaseInputConnection.removeComposingSpans(messageBody); 3125 return messageBody; 3126 } 3127 3128 private static int getDraftType(int mode) { 3129 int draftType = -1; 3130 switch (mode) { 3131 case ComposeActivity.COMPOSE: 3132 draftType = DraftType.COMPOSE; 3133 break; 3134 case ComposeActivity.REPLY: 3135 draftType = DraftType.REPLY; 3136 break; 3137 case ComposeActivity.REPLY_ALL: 3138 draftType = DraftType.REPLY_ALL; 3139 break; 3140 case ComposeActivity.FORWARD: 3141 draftType = DraftType.FORWARD; 3142 break; 3143 } 3144 return draftType; 3145 } 3146 3147 /** 3148 * Derived classes should override this step to perform additional checks before 3149 * send or save. The default implementation simply calls {@link #sendOrSave(boolean, boolean)}. 3150 */ 3151 protected void performAdditionalSendOrSaveSanityChecks( 3152 final boolean save, final boolean showToast, ArrayList<String> recipients) { 3153 sendOrSave(save, showToast); 3154 } 3155 3156 protected void sendOrSave(final boolean save, final boolean showToast) { 3157 // Check if user is a monkey. Monkeys can compose and hit send 3158 // button but are not allowed to send anything off the device. 3159 if (ActivityManager.isUserAMonkey()) { 3160 return; 3161 } 3162 3163 final Spanned body = mBodyView.getEditableText(); 3164 3165 SendOrSaveCallback callback = new SendOrSaveCallback() { 3166 // FIXME: unused 3167 private int mRestoredRequestId; 3168 3169 @Override 3170 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) { 3171 synchronized (mActiveTasks) { 3172 int numTasks = mActiveTasks.size(); 3173 if (numTasks == 0) { 3174 // Start service so we won't be killed if this app is 3175 // put in the background. 3176 startService(new Intent(ComposeActivity.this, EmptyService.class)); 3177 } 3178 3179 mActiveTasks.add(sendOrSaveTask); 3180 } 3181 if (sTestSendOrSaveCallback != null) { 3182 sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask); 3183 } 3184 } 3185 3186 @Override 3187 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, 3188 Message message) { 3189 synchronized (mDraftLock) { 3190 mDraftAccount = sendOrSaveMessage.mAccount; 3191 mDraftId = message.id; 3192 mDraft = message; 3193 if (sRequestMessageIdMap != null) { 3194 sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId); 3195 } 3196 // Cache request message map, in case the process is killed 3197 saveRequestMap(); 3198 } 3199 if (sTestSendOrSaveCallback != null) { 3200 sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message); 3201 } 3202 } 3203 3204 @Override 3205 public Message getMessage() { 3206 synchronized (mDraftLock) { 3207 return mDraft; 3208 } 3209 } 3210 3211 @Override 3212 public void sendOrSaveFinished(SendOrSaveTask task, boolean success) { 3213 // Update the last sent from account. 3214 if (mAccount != null) { 3215 MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString()); 3216 } 3217 if (success) { 3218 // Successfully sent or saved so reset change markers 3219 discardChanges(); 3220 } else { 3221 // A failure happened with saving/sending the draft 3222 // TODO(pwestbro): add a better string that should be used 3223 // when failing to send or save 3224 Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT) 3225 .show(); 3226 } 3227 3228 int numTasks; 3229 synchronized (mActiveTasks) { 3230 // Remove the task from the list of active tasks 3231 mActiveTasks.remove(task); 3232 numTasks = mActiveTasks.size(); 3233 } 3234 3235 if (numTasks == 0) { 3236 // Stop service so we can be killed. 3237 stopService(new Intent(ComposeActivity.this, EmptyService.class)); 3238 } 3239 if (sTestSendOrSaveCallback != null) { 3240 sTestSendOrSaveCallback.sendOrSaveFinished(task, success); 3241 } 3242 } 3243 3244 @Override 3245 public void incrementRecipientsTimesContacted(final List<String> recipients) { 3246 ComposeActivity.this.incrementRecipientsTimesContacted(recipients); 3247 } 3248 }; 3249 3250 setAccount(mReplyFromAccount.account); 3251 3252 Message msg = createMessage(mReplyFromAccount, mRefMessage, getMode()); 3253 mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body, 3254 mQuotedTextView.getQuotedTextIfIncluded(), callback, 3255 SEND_SAVE_TASK_HANDLER, save, mComposeMode, mDraftAccount, mExtraValues); 3256 3257 // Don't display the toast if the user is just changing the orientation, 3258 // but we still need to save the draft to the cursor because this is how we restore 3259 // the attachments when the configuration change completes. 3260 if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 3261 Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, 3262 Toast.LENGTH_LONG).show(); 3263 } 3264 3265 // Need to update variables here because the send or save completes 3266 // asynchronously even though the toast shows right away. 3267 discardChanges(); 3268 updateSaveUi(); 3269 3270 // If we are sending, finish the activity 3271 if (!save) { 3272 finish(); 3273 } 3274 } 3275 3276 /** 3277 * Save the state of the request messageid map. This allows for the Gmail 3278 * process to be killed, but and still allow for ComposeActivity instances 3279 * to be recreated correctly. 3280 */ 3281 private void saveRequestMap() { 3282 // TODO: store the request map in user preferences. 3283 } 3284 3285 @SuppressLint("NewApi") 3286 private void doAttach(String type) { 3287 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 3288 i.addCategory(Intent.CATEGORY_OPENABLE); 3289 i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 3290 i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); 3291 i.setType(type); 3292 mAddingAttachment = true; 3293 startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)), 3294 RESULT_PICK_ATTACHMENT); 3295 } 3296 3297 private void showCcBccViews() { 3298 mCcBccView.show(true, true, true); 3299 if (mCcBccButton != null) { 3300 mCcBccButton.setVisibility(View.INVISIBLE); 3301 } 3302 } 3303 3304 private static String getActionString(int action) { 3305 final String msgType; 3306 switch (action) { 3307 case COMPOSE: 3308 msgType = "new_message"; 3309 break; 3310 case REPLY: 3311 msgType = "reply"; 3312 break; 3313 case REPLY_ALL: 3314 msgType = "reply_all"; 3315 break; 3316 case FORWARD: 3317 msgType = "forward"; 3318 break; 3319 default: 3320 msgType = "unknown"; 3321 break; 3322 } 3323 return msgType; 3324 } 3325 3326 private void logSendOrSave(boolean save) { 3327 if (!Analytics.isLoggable() || mAttachmentsView == null) { 3328 return; 3329 } 3330 3331 final String category = (save) ? "message_save" : "message_send"; 3332 final int attachmentCount = getAttachments().size(); 3333 final String msgType = getActionString(mComposeMode); 3334 final String label; 3335 final long value; 3336 if (mComposeMode == COMPOSE) { 3337 label = Integer.toString(attachmentCount); 3338 value = attachmentCount; 3339 } else { 3340 label = null; 3341 value = 0; 3342 } 3343 Analytics.getInstance().sendEvent(category, msgType, label, value); 3344 } 3345 3346 @Override 3347 public boolean onNavigationItemSelected(int position, long itemId) { 3348 int initialComposeMode = mComposeMode; 3349 if (position == ComposeActivity.REPLY) { 3350 mComposeMode = ComposeActivity.REPLY; 3351 } else if (position == ComposeActivity.REPLY_ALL) { 3352 mComposeMode = ComposeActivity.REPLY_ALL; 3353 } else if (position == ComposeActivity.FORWARD) { 3354 mComposeMode = ComposeActivity.FORWARD; 3355 } 3356 clearChangeListeners(); 3357 if (initialComposeMode != mComposeMode) { 3358 resetMessageForModeChange(); 3359 if (mRefMessage != null) { 3360 setFieldsFromRefMessage(mComposeMode); 3361 } 3362 boolean showCc = false; 3363 boolean showBcc = false; 3364 if (mDraft != null) { 3365 // Following desktop behavior, if the user has added a BCC 3366 // field to a draft, we show it regardless of compose mode. 3367 showBcc = !TextUtils.isEmpty(mDraft.getBcc()); 3368 // Use the draft to determine what to populate. 3369 // If the Bcc field is showing, show the Cc field whether it is populated or not. 3370 showCc = showBcc 3371 || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL); 3372 } 3373 if (mRefMessage != null) { 3374 showCc = !TextUtils.isEmpty(mCc.getText()); 3375 showBcc = !TextUtils.isEmpty(mBcc.getText()); 3376 } 3377 mCcBccView.show(false, showCc, showBcc); 3378 } 3379 updateHideOrShowCcBcc(); 3380 initChangeListeners(); 3381 return true; 3382 } 3383 3384 @VisibleForTesting 3385 protected void resetMessageForModeChange() { 3386 // When switching between reply, reply all, forward, 3387 // follow the behavior of webview. 3388 // The contents of the following fields are cleared 3389 // so that they can be populated directly from the 3390 // ref message: 3391 // 1) Any recipient fields 3392 // 2) The subject 3393 mTo.setText(""); 3394 mCc.setText(""); 3395 mBcc.setText(""); 3396 // Any edits to the subject are replaced with the original subject. 3397 mSubject.setText(""); 3398 3399 // Any changes to the contents of the following fields are kept: 3400 // 1) Body 3401 // 2) Attachments 3402 // If the user made changes to attachments, keep their changes. 3403 if (!mAttachmentsChanged) { 3404 mAttachmentsView.deleteAllAttachments(); 3405 } 3406 } 3407 3408 private class ComposeModeAdapter extends ArrayAdapter<String> { 3409 3410 private LayoutInflater mInflater; 3411 3412 public ComposeModeAdapter(Context context) { 3413 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 3414 .getStringArray(R.array.compose_modes)); 3415 } 3416 3417 private LayoutInflater getInflater() { 3418 if (mInflater == null) { 3419 mInflater = LayoutInflater.from(getContext()); 3420 } 3421 return mInflater; 3422 } 3423 3424 @Override 3425 public View getView(int position, View convertView, ViewGroup parent) { 3426 if (convertView == null) { 3427 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 3428 } 3429 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 3430 return super.getView(position, convertView, parent); 3431 } 3432 } 3433 3434 @Override 3435 public void onRespondInline(String text) { 3436 appendToBody(text, false); 3437 mQuotedTextView.setUpperDividerVisible(false); 3438 mRespondedInline = true; 3439 if (!mBodyView.hasFocus()) { 3440 mBodyView.requestFocus(); 3441 } 3442 } 3443 3444 /** 3445 * Append text to the body of the message. If there is no existing body 3446 * text, just sets the body to text. 3447 * 3448 * @param text Text to append 3449 * @param withSignature True to append a signature. 3450 */ 3451 public void appendToBody(CharSequence text, boolean withSignature) { 3452 Editable bodyText = mBodyView.getEditableText(); 3453 if (bodyText != null && bodyText.length() > 0) { 3454 bodyText.append(text); 3455 } else { 3456 setBody(text, withSignature); 3457 } 3458 } 3459 3460 /** 3461 * Set the body of the message. 3462 * 3463 * @param text text to set 3464 * @param withSignature True to append a signature. 3465 */ 3466 public void setBody(CharSequence text, boolean withSignature) { 3467 mBodyView.setText(text); 3468 if (withSignature) { 3469 appendSignature(); 3470 } 3471 } 3472 3473 private void appendSignature() { 3474 final String newSignature = mCachedSettings != null ? mCachedSettings.signature : null; 3475 final int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString()); 3476 if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) { 3477 mSignature = newSignature; 3478 if (!TextUtils.isEmpty(mSignature)) { 3479 // Appending a signature does not count as changing text. 3480 mBodyView.removeTextChangedListener(this); 3481 mBodyView.append(convertToPrintableSignature(mSignature)); 3482 mBodyView.addTextChangedListener(this); 3483 } 3484 resetBodySelection(); 3485 } 3486 } 3487 3488 private String convertToPrintableSignature(String signature) { 3489 String signatureResource = getResources().getString(R.string.signature); 3490 if (signature == null) { 3491 signature = ""; 3492 } 3493 return String.format(signatureResource, signature); 3494 } 3495 3496 @Override 3497 public void onAccountChanged() { 3498 mReplyFromAccount = mFromSpinner.getCurrentAccount(); 3499 if (!mAccount.equals(mReplyFromAccount.account)) { 3500 // Clear a signature, if there was one. 3501 mBodyView.removeTextChangedListener(this); 3502 String oldSignature = mSignature; 3503 String bodyText = getBody().getText().toString(); 3504 if (!TextUtils.isEmpty(oldSignature)) { 3505 int pos = getSignatureStartPosition(oldSignature, bodyText); 3506 if (pos > -1) { 3507 mBodyView.setText(bodyText.substring(0, pos)); 3508 } 3509 } 3510 setAccount(mReplyFromAccount.account); 3511 mBodyView.addTextChangedListener(this); 3512 // TODO: handle discarding attachments when switching accounts. 3513 // Only enable save for this draft if there is any other content 3514 // in the message. 3515 if (!isBlank()) { 3516 enableSave(true); 3517 } 3518 mReplyFromChanged = true; 3519 initRecipients(); 3520 } 3521 } 3522 3523 public void enableSave(boolean enabled) { 3524 if (mSave != null) { 3525 mSave.setEnabled(enabled); 3526 } 3527 } 3528 3529 public static class DiscardConfirmDialogFragment extends DialogFragment { 3530 // Public no-args constructor needed for fragment re-instantiation 3531 public DiscardConfirmDialogFragment() {} 3532 3533 @Override 3534 public Dialog onCreateDialog(Bundle savedInstanceState) { 3535 return new AlertDialog.Builder(getActivity()) 3536 .setMessage(R.string.confirm_discard_text) 3537 .setPositiveButton(R.string.discard, 3538 new DialogInterface.OnClickListener() { 3539 @Override 3540 public void onClick(DialogInterface dialog, int which) { 3541 ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation(); 3542 } 3543 }) 3544 .setNegativeButton(R.string.cancel, null) 3545 .create(); 3546 } 3547 } 3548 3549 private void doDiscard() { 3550 final DialogFragment frag = new DiscardConfirmDialogFragment(); 3551 frag.show(getFragmentManager(), "discard confirm"); 3552 } 3553 /** 3554 * Effectively discard the current message. 3555 * 3556 * This method is either invoked from the menu or from the dialog 3557 * once the user has confirmed that they want to discard the message. 3558 */ 3559 private void doDiscardWithoutConfirmation() { 3560 synchronized (mDraftLock) { 3561 if (mDraftId != UIProvider.INVALID_MESSAGE_ID) { 3562 ContentValues values = new ContentValues(); 3563 values.put(BaseColumns._ID, mDraftId); 3564 if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) { 3565 getContentResolver().update(mAccount.expungeMessageUri, values, null, null); 3566 } else { 3567 getContentResolver().delete(mDraft.uri, null, null); 3568 } 3569 // This is not strictly necessary (since we should not try to 3570 // save the draft after calling this) but it ensures that if we 3571 // do save again for some reason we make a new draft rather than 3572 // trying to resave an expunged draft. 3573 mDraftId = UIProvider.INVALID_MESSAGE_ID; 3574 } 3575 } 3576 3577 // Display a toast to let the user know 3578 Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show(); 3579 3580 // This prevents the draft from being saved in onPause(). 3581 discardChanges(); 3582 mPerformedSendOrDiscard = true; 3583 finish(); 3584 } 3585 3586 private void saveIfNeeded() { 3587 if (mAccount == null) { 3588 // We have not chosen an account yet so there's no way that we can save. This is ok, 3589 // though, since we are saving our state before AccountsActivity is activated. Thus, the 3590 // user has not interacted with us yet and there is no real state to save. 3591 return; 3592 } 3593 3594 if (shouldSave()) { 3595 doSave(!mAddingAttachment /* show toast */); 3596 } 3597 } 3598 3599 @Override 3600 public void onAttachmentDeleted() { 3601 mAttachmentsChanged = true; 3602 // If we are showing any attachments, make sure we have an upper 3603 // divider. 3604 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3605 updateSaveUi(); 3606 } 3607 3608 @Override 3609 public void onAttachmentAdded() { 3610 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3611 mAttachmentsView.focusLastAttachment(); 3612 } 3613 3614 /** 3615 * This is called any time one of our text fields changes. 3616 */ 3617 @Override 3618 public void afterTextChanged(Editable s) { 3619 mTextChanged = true; 3620 updateSaveUi(); 3621 } 3622 3623 @Override 3624 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3625 // Do nothing. 3626 } 3627 3628 @Override 3629 public void onTextChanged(CharSequence s, int start, int before, int count) { 3630 // Do nothing. 3631 } 3632 3633 3634 // There is a big difference between the text associated with an address changing 3635 // to add the display name or to format properly and a recipient being added or deleted. 3636 // Make sure we only notify of changes when a recipient has been added or deleted. 3637 private class RecipientTextWatcher implements TextWatcher { 3638 private HashMap<String, Integer> mContent = new HashMap<String, Integer>(); 3639 3640 private RecipientEditTextView mView; 3641 3642 private TextWatcher mListener; 3643 3644 public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) { 3645 mView = view; 3646 mListener = listener; 3647 } 3648 3649 @Override 3650 public void afterTextChanged(Editable s) { 3651 if (hasChanged()) { 3652 mListener.afterTextChanged(s); 3653 } 3654 } 3655 3656 private boolean hasChanged() { 3657 final ArrayList<String> currRecips = buildEmailAddressList(getAddressesFromList(mView)); 3658 int totalCount = currRecips.size(); 3659 int totalPrevCount = 0; 3660 for (Entry<String, Integer> entry : mContent.entrySet()) { 3661 totalPrevCount += entry.getValue(); 3662 } 3663 if (totalCount != totalPrevCount) { 3664 return true; 3665 } 3666 3667 for (String recip : currRecips) { 3668 if (!mContent.containsKey(recip)) { 3669 return true; 3670 } else { 3671 int count = mContent.get(recip) - 1; 3672 if (count < 0) { 3673 return true; 3674 } else { 3675 mContent.put(recip, count); 3676 } 3677 } 3678 } 3679 return false; 3680 } 3681 3682 @Override 3683 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3684 final ArrayList<String> recips = buildEmailAddressList(getAddressesFromList(mView)); 3685 for (String recip : recips) { 3686 if (!mContent.containsKey(recip)) { 3687 mContent.put(recip, 1); 3688 } else { 3689 mContent.put(recip, (mContent.get(recip)) + 1); 3690 } 3691 } 3692 } 3693 3694 @Override 3695 public void onTextChanged(CharSequence s, int start, int before, int count) { 3696 // Do nothing. 3697 } 3698 } 3699 3700 /** 3701 * Returns a list of email addresses from the recipients. List only contains 3702 * email addresses strips additional info like the recipient's name. 3703 */ 3704 private static ArrayList<String> buildEmailAddressList(String[] recips) { 3705 // Tokenize them all and put them in the list. 3706 final ArrayList<String> recipAddresses = Lists.newArrayListWithCapacity(recips.length); 3707 for (int i = 0; i < recips.length; i++) { 3708 recipAddresses.add(Rfc822Tokenizer.tokenize(recips[i])[0].getAddress()); 3709 } 3710 return recipAddresses; 3711 } 3712 3713 public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) { 3714 if (sTestSendOrSaveCallback != null && testCallback != null) { 3715 throw new IllegalStateException("Attempting to register more than one test callback"); 3716 } 3717 sTestSendOrSaveCallback = testCallback; 3718 } 3719 3720 @VisibleForTesting 3721 protected ArrayList<Attachment> getAttachments() { 3722 return mAttachmentsView.getAttachments(); 3723 } 3724 3725 @Override 3726 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 3727 switch (id) { 3728 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3729 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3730 null, null); 3731 case REFERENCE_MESSAGE_LOADER: 3732 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3733 null, null); 3734 case LOADER_ACCOUNT_CURSOR: 3735 return new CursorLoader(this, MailAppProvider.getAccountsUri(), 3736 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 3737 } 3738 return null; 3739 } 3740 3741 @Override 3742 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 3743 int id = loader.getId(); 3744 switch (id) { 3745 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3746 if (data != null && data.moveToFirst()) { 3747 mRefMessage = new Message(data); 3748 Intent intent = getIntent(); 3749 initFromRefMessage(mComposeMode); 3750 finishSetup(mComposeMode, intent, null); 3751 if (mComposeMode != FORWARD) { 3752 String to = intent.getStringExtra(EXTRA_TO); 3753 if (!TextUtils.isEmpty(to)) { 3754 mRefMessage.setTo(null); 3755 mRefMessage.setFrom(null); 3756 clearChangeListeners(); 3757 mTo.append(to); 3758 initChangeListeners(); 3759 } 3760 } 3761 } else { 3762 finish(); 3763 } 3764 break; 3765 case REFERENCE_MESSAGE_LOADER: 3766 // Only populate mRefMessage and leave other fields untouched. 3767 if (data != null && data.moveToFirst()) { 3768 mRefMessage = new Message(data); 3769 } 3770 finishSetup(mComposeMode, getIntent(), mInnerSavedState); 3771 break; 3772 case LOADER_ACCOUNT_CURSOR: 3773 if (data != null && data.moveToFirst()) { 3774 // there are accounts now! 3775 Account account; 3776 final ArrayList<Account> accounts = new ArrayList<Account>(); 3777 final ArrayList<Account> initializedAccounts = new ArrayList<Account>(); 3778 do { 3779 account = new Account(data); 3780 if (account.isAccountReady()) { 3781 initializedAccounts.add(account); 3782 } 3783 accounts.add(account); 3784 } while (data.moveToNext()); 3785 if (initializedAccounts.size() > 0) { 3786 findViewById(R.id.wait).setVisibility(View.GONE); 3787 getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR); 3788 findViewById(R.id.compose).setVisibility(View.VISIBLE); 3789 mAccounts = initializedAccounts.toArray( 3790 new Account[initializedAccounts.size()]); 3791 3792 finishCreate(); 3793 invalidateOptionsMenu(); 3794 } else { 3795 // Show "waiting" 3796 account = accounts.size() > 0 ? accounts.get(0) : null; 3797 showWaitFragment(account); 3798 } 3799 } 3800 break; 3801 } 3802 } 3803 3804 private void showWaitFragment(Account account) { 3805 WaitFragment fragment = getWaitFragment(); 3806 if (fragment != null) { 3807 fragment.updateAccount(account); 3808 } else { 3809 findViewById(R.id.wait).setVisibility(View.VISIBLE); 3810 replaceFragment(WaitFragment.newInstance(account, false /* expectingMessages */), 3811 FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT); 3812 } 3813 } 3814 3815 private WaitFragment getWaitFragment() { 3816 return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT); 3817 } 3818 3819 private int replaceFragment(Fragment fragment, int transition, String tag) { 3820 FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction(); 3821 fragmentTransaction.setTransition(transition); 3822 fragmentTransaction.replace(R.id.wait, fragment, tag); 3823 final int transactionId = fragmentTransaction.commitAllowingStateLoss(); 3824 return transactionId; 3825 } 3826 3827 @Override 3828 public void onLoaderReset(Loader<Cursor> arg0) { 3829 // Do nothing. 3830 } 3831} 3832