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