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