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