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