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