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