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