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