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