ComposeActivity.java revision e003b4cc4a23e6d394998bebe26430a361c5ac29
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 && !isBlank()) { 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 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1534 size = mAttachmentsView.addAttachment(mAccount, a); 1535 1536 Analytics.getInstance().sendEvent("send_intent_attachment", 1537 Utils.normalizeMimeType(a.getContentType()), null, size); 1538 1539 } catch (AttachmentFailureException e) { 1540 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1541 showAttachmentTooBigToast(e.getErrorRes()); 1542 } 1543 totalSize += size; 1544 } 1545 } 1546 if (extras.containsKey(Intent.EXTRA_STREAM)) { 1547 if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { 1548 ArrayList<Parcelable> uris = extras 1549 .getParcelableArrayList(Intent.EXTRA_STREAM); 1550 ArrayList<Attachment> attachments = new ArrayList<Attachment>(); 1551 for (Parcelable uri : uris) { 1552 try { 1553 final Attachment a = mAttachmentsView.generateLocalAttachment( 1554 (Uri) uri); 1555 attachments.add(a); 1556 1557 Analytics.getInstance().sendEvent("send_intent_attachment", 1558 Utils.normalizeMimeType(a.getContentType()), null, a.size); 1559 1560 } catch (AttachmentFailureException e) { 1561 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1562 String maxSize = AttachmentUtils.convertToHumanReadableSize( 1563 getApplicationContext(), 1564 mAccount.settings.getMaxAttachmentSize()); 1565 showErrorToast(getString 1566 (R.string.generic_attachment_problem, maxSize)); 1567 } 1568 } 1569 totalSize += addAttachments(attachments); 1570 } else { 1571 final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); 1572 long size = 0; 1573 try { 1574 final Attachment a = mAttachmentsView.generateLocalAttachment(uri); 1575 size = mAttachmentsView.addAttachment(mAccount, a); 1576 1577 Analytics.getInstance().sendEvent("send_intent_attachment", 1578 Utils.normalizeMimeType(a.getContentType()), null, size); 1579 1580 } catch (AttachmentFailureException e) { 1581 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1582 showAttachmentTooBigToast(e.getErrorRes()); 1583 } 1584 totalSize += size; 1585 } 1586 } 1587 1588 if (totalSize > 0) { 1589 mAttachmentsChanged = true; 1590 updateSaveUi(); 1591 1592 Analytics.getInstance().sendEvent("send_intent_with_attachments", 1593 Integer.toString(getAttachments().size()), null, totalSize); 1594 } 1595 } 1596 } 1597 1598 private void initQuotedText(CharSequence quotedText, boolean shouldQuoteText) { 1599 mQuotedTextView.setQuotedTextFromHtml(quotedText, shouldQuoteText); 1600 mShowQuotedText = true; 1601 } 1602 1603 private void initQuotedTextFromRefMessage(Message refMessage, int action) { 1604 if (mRefMessage != null && (action == REPLY || action == REPLY_ALL || action == FORWARD)) { 1605 mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD); 1606 } 1607 } 1608 1609 private void updateHideOrShowCcBcc() { 1610 // Its possible there is a menu item OR a button. 1611 boolean ccVisible = mCcBccView.isCcVisible(); 1612 boolean bccVisible = mCcBccView.isBccVisible(); 1613 if (mCcBccButton != null) { 1614 if (!ccVisible || !bccVisible) { 1615 mCcBccButton.setVisibility(View.VISIBLE); 1616 mCcBccButton.setText(getString(!ccVisible ? R.string.add_cc_label 1617 : R.string.add_bcc_label)); 1618 } else { 1619 mCcBccButton.setVisibility(View.INVISIBLE); 1620 } 1621 } 1622 } 1623 1624 private void showCcBcc(Bundle state) { 1625 if (state != null && state.containsKey(EXTRA_SHOW_CC)) { 1626 boolean showCc = state.getBoolean(EXTRA_SHOW_CC); 1627 boolean showBcc = state.getBoolean(EXTRA_SHOW_BCC); 1628 if (showCc || showBcc) { 1629 mCcBccView.show(false, showCc, showBcc); 1630 } 1631 } 1632 } 1633 1634 /** 1635 * Add attachment and update the compose area appropriately. 1636 * @param data 1637 */ 1638 public void addAttachmentAndUpdateView(Intent data) { 1639 addAttachmentAndUpdateView(data != null ? data.getData() : (Uri) null); 1640 } 1641 1642 public void addAttachmentAndUpdateView(Uri contentUri) { 1643 if (contentUri == null) { 1644 return; 1645 } 1646 try { 1647 addAttachmentAndUpdateView(mAttachmentsView.generateLocalAttachment(contentUri)); 1648 } catch (AttachmentFailureException e) { 1649 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1650 showErrorToast(getResources().getString( 1651 e.getErrorRes(), 1652 AttachmentUtils.convertToHumanReadableSize( 1653 getApplicationContext(), mAccount.settings.getMaxAttachmentSize()))); 1654 } 1655 } 1656 1657 public void addAttachmentAndUpdateView(Attachment attachment) { 1658 try { 1659 long size = mAttachmentsView.addAttachment(mAccount, attachment); 1660 if (size > 0) { 1661 mAttachmentsChanged = true; 1662 updateSaveUi(); 1663 } 1664 } catch (AttachmentFailureException e) { 1665 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 1666 showAttachmentTooBigToast(e.getErrorRes()); 1667 } 1668 } 1669 1670 void initRecipientsFromRefMessage(Message refMessage, int action) { 1671 // Don't populate the address if this is a forward. 1672 if (action == ComposeActivity.FORWARD) { 1673 return; 1674 } 1675 initReplyRecipients(refMessage, action); 1676 } 1677 1678 // TODO: This should be private. This method shouldn't be used by ComposeActivityTests, as 1679 // it doesn't setup the state of the activity correctly 1680 @VisibleForTesting 1681 void initReplyRecipients(final Message refMessage, final int action) { 1682 String[] sentToAddresses = refMessage.getToAddressesUnescaped(); 1683 final Collection<String> toAddresses; 1684 final String[] replyToAddresses = refMessage.getReplyToAddressesUnescaped(); 1685 String replyToAddress = replyToAddresses.length > 0 ? replyToAddresses[0] : null; 1686 final String[] fromAddresses = refMessage.getFromAddressesUnescaped(); 1687 final String fromAddress = fromAddresses.length > 0 ? fromAddresses[0] : null; 1688 1689 // If there is no reply to address, the reply to address is the sender. 1690 if (TextUtils.isEmpty(replyToAddress)) { 1691 replyToAddress = fromAddress; 1692 } 1693 1694 // If this is a reply, the Cc list is empty. If this is a reply-all, the 1695 // Cc list is the union of the To and Cc recipients of the original 1696 // message, excluding the current user's email address and any addresses 1697 // already on the To list. 1698 if (action == ComposeActivity.REPLY) { 1699 toAddresses = initToRecipients(fromAddress, replyToAddress, sentToAddresses); 1700 addToAddresses(toAddresses); 1701 } else if (action == ComposeActivity.REPLY_ALL) { 1702 final Set<String> ccAddresses = Sets.newHashSet(); 1703 toAddresses = initToRecipients(fromAddress, replyToAddress, sentToAddresses); 1704 addToAddresses(toAddresses); 1705 addRecipients(ccAddresses, sentToAddresses); 1706 addRecipients(ccAddresses, refMessage.getCcAddressesUnescaped()); 1707 addCcAddresses(ccAddresses, toAddresses); 1708 } 1709 } 1710 1711 private void addToAddresses(Collection<String> addresses) { 1712 addAddressesToList(addresses, mTo); 1713 } 1714 1715 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 1716 addCcAddressesToList(tokenizeAddressList(addresses), 1717 toAddresses != null ? tokenizeAddressList(toAddresses) : null, mCc); 1718 } 1719 1720 private void addBccAddresses(Collection<String> addresses) { 1721 addAddressesToList(addresses, mBcc); 1722 } 1723 1724 @VisibleForTesting 1725 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 1726 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 1727 String address; 1728 1729 if (compareToList == null) { 1730 for (Rfc822Token[] tokens : addresses) { 1731 for (int i = 0; i < tokens.length; i++) { 1732 address = tokens[i].toString(); 1733 list.append(address + END_TOKEN); 1734 } 1735 } 1736 } else { 1737 HashSet<String> compareTo = convertToHashSet(compareToList); 1738 for (Rfc822Token[] tokens : addresses) { 1739 for (int i = 0; i < tokens.length; i++) { 1740 address = tokens[i].toString(); 1741 // Check if this is a duplicate: 1742 if (!compareTo.contains(tokens[i].getAddress())) { 1743 // Get the address here 1744 list.append(address + END_TOKEN); 1745 } 1746 } 1747 } 1748 } 1749 } 1750 1751 private static HashSet<String> convertToHashSet(final List<Rfc822Token[]> list) { 1752 final HashSet<String> hash = new HashSet<String>(); 1753 for (final Rfc822Token[] tokens : list) { 1754 for (int i = 0; i < tokens.length; i++) { 1755 hash.add(tokens[i].getAddress()); 1756 } 1757 } 1758 return hash; 1759 } 1760 1761 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 1762 @VisibleForTesting 1763 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 1764 1765 for (String address: addresses) { 1766 tokenized.add(Rfc822Tokenizer.tokenize(address)); 1767 } 1768 return tokenized; 1769 } 1770 1771 @VisibleForTesting 1772 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 1773 for (String address : addresses) { 1774 addAddressToList(address, list); 1775 } 1776 } 1777 1778 private static void addAddressToList(final String address, final RecipientEditTextView list) { 1779 if (address == null || list == null) 1780 return; 1781 1782 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 1783 1784 for (int i = 0; i < tokens.length; i++) { 1785 list.append(tokens[i] + END_TOKEN); 1786 } 1787 } 1788 1789 @VisibleForTesting 1790 protected Collection<String> initToRecipients(final String fullSenderAddress, 1791 final String replyToAddress, final String[] inToAddresses) { 1792 // The To recipient is the reply-to address specified in the original 1793 // message, unless it is: 1794 // the current user OR a custom from of the current user, in which case 1795 // it's the To recipient list of the original message. 1796 // OR missing, in which case use the sender of the original message 1797 Set<String> toAddresses = Sets.newHashSet(); 1798 if (!TextUtils.isEmpty(replyToAddress) && !recipientMatchesThisAccount(replyToAddress)) { 1799 toAddresses.add(replyToAddress); 1800 } else { 1801 // In this case, the user is replying to a message in which their 1802 // current account or one of their custom from addresses is the only 1803 // recipient and they sent the original message. 1804 if (inToAddresses.length == 1 && recipientMatchesThisAccount(fullSenderAddress) 1805 && recipientMatchesThisAccount(inToAddresses[0])) { 1806 toAddresses.add(inToAddresses[0]); 1807 return toAddresses; 1808 } 1809 // This happens if the user replies to a message they originally 1810 // wrote. In this case, "reply" really means "re-send," so we 1811 // target the original recipients. This works as expected even 1812 // if the user sent the original message to themselves. 1813 for (String address : inToAddresses) { 1814 if (!recipientMatchesThisAccount(address)) { 1815 toAddresses.add(address); 1816 } 1817 } 1818 } 1819 return toAddresses; 1820 } 1821 1822 private void addRecipients(final Set<String> recipients, final String[] addresses) { 1823 for (final String email : addresses) { 1824 // Do not add this account, or any of its custom from addresses, to 1825 // the list of recipients. 1826 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 1827 if (!recipientMatchesThisAccount(recipientAddress)) { 1828 recipients.add(email.replace("\"\"", "")); 1829 } 1830 } 1831 } 1832 1833 /** 1834 * A recipient matches this account if it has the same address as the 1835 * currently selected account OR one of the custom from addresses associated 1836 * with the currently selected account. 1837 * @param recipientAddress address we are comparing with the currently selected account 1838 * @return 1839 */ 1840 protected boolean recipientMatchesThisAccount(String recipientAddress) { 1841 return ReplyFromAccount.matchesAccountOrCustomFrom(mAccount, recipientAddress, 1842 mAccount.getReplyFroms()); 1843 } 1844 1845 /** 1846 * Returns a formatted subject string with the appropriate prefix for the action type. 1847 * E.g., "FWD: " is prepended if action is {@link ComposeActivity#FORWARD}. 1848 */ 1849 public static String buildFormattedSubject(Resources res, String subject, int action) { 1850 String prefix; 1851 String correctedSubject = null; 1852 if (action == ComposeActivity.COMPOSE) { 1853 prefix = ""; 1854 } else if (action == ComposeActivity.FORWARD) { 1855 prefix = res.getString(R.string.forward_subject_label); 1856 } else { 1857 prefix = res.getString(R.string.reply_subject_label); 1858 } 1859 1860 // Don't duplicate the prefix 1861 if (!TextUtils.isEmpty(subject) 1862 && subject.toLowerCase().startsWith(prefix.toLowerCase())) { 1863 correctedSubject = subject; 1864 } else { 1865 correctedSubject = String.format( 1866 res.getString(R.string.formatted_subject), prefix, subject); 1867 } 1868 1869 return correctedSubject; 1870 } 1871 1872 private void setSubject(Message refMessage, int action) { 1873 mSubject.setText(buildFormattedSubject(getResources(), refMessage.subject, action)); 1874 } 1875 1876 private void initRecipients() { 1877 setupRecipients(mTo); 1878 setupRecipients(mCc); 1879 setupRecipients(mBcc); 1880 } 1881 1882 private void setupRecipients(RecipientEditTextView view) { 1883 view.setAdapter(new RecipientAdapter(this, mAccount)); 1884 if (mValidator == null) { 1885 final String accountName = mAccount.name; 1886 int offset = accountName.indexOf("@") + 1; 1887 String account = accountName; 1888 if (offset > -1) { 1889 account = account.substring(accountName.indexOf("@") + 1); 1890 } 1891 mValidator = new Rfc822Validator(account); 1892 } 1893 view.setValidator(mValidator); 1894 } 1895 1896 @Override 1897 public void onClick(View v) { 1898 final int id = v.getId(); 1899 if (id == R.id.add_cc_bcc) { 1900 // Verify that cc/ bcc aren't showing. 1901 // Animate in cc/bcc. 1902 showCcBccViews(); 1903 } else if (id == R.id.add_photo_attachment) { 1904 doAttach(MIME_TYPE_PHOTO); 1905 } else if (id == R.id.add_video_attachment) { 1906 doAttach(MIME_TYPE_VIDEO); 1907 } 1908 } 1909 1910 @Override 1911 public boolean onCreateOptionsMenu(Menu menu) { 1912 super.onCreateOptionsMenu(menu); 1913 // Don't render any menu items when there are no accounts. 1914 if (mAccounts == null || mAccounts.length == 0) { 1915 return true; 1916 } 1917 MenuInflater inflater = getMenuInflater(); 1918 inflater.inflate(R.menu.compose_menu, menu); 1919 1920 /* 1921 * Start save in the correct enabled state. 1922 * 1) If a user launches compose from within gmail, save is disabled 1923 * until they add something, at which point, save is enabled, auto save 1924 * on exit; if the user empties everything, save is disabled, exiting does not 1925 * auto-save 1926 * 2) if a user replies/ reply all/ forwards from within gmail, save is 1927 * disabled until they change something, at which point, save is 1928 * enabled, auto save on exit; if the user empties everything, save is 1929 * disabled, exiting does not auto-save. 1930 * 3) If a user launches compose from another application and something 1931 * gets populated (attachments, recipients, body, subject, etc), save is 1932 * enabled, auto save on exit; if the user empties everything, save is 1933 * disabled, exiting does not auto-save 1934 */ 1935 mSave = menu.findItem(R.id.save); 1936 String action = getIntent() != null ? getIntent().getAction() : null; 1937 enableSave(mInnerSavedState != null ? 1938 mInnerSavedState.getBoolean(EXTRA_SAVE_ENABLED) 1939 : (Intent.ACTION_SEND.equals(action) 1940 || Intent.ACTION_SEND_MULTIPLE.equals(action) 1941 || Intent.ACTION_SENDTO.equals(action) 1942 || shouldSave())); 1943 1944 mSend = menu.findItem(R.id.send); 1945 MenuItem helpItem = menu.findItem(R.id.help_info_menu_item); 1946 MenuItem sendFeedbackItem = menu.findItem(R.id.feedback_menu_item); 1947 if (helpItem != null) { 1948 helpItem.setVisible(mAccount != null 1949 && mAccount.supportsCapability(AccountCapabilities.HELP_CONTENT)); 1950 } 1951 if (sendFeedbackItem != null) { 1952 sendFeedbackItem.setVisible(mAccount != null 1953 && mAccount.supportsCapability(AccountCapabilities.SEND_FEEDBACK)); 1954 } 1955 return true; 1956 } 1957 1958 @Override 1959 public boolean onPrepareOptionsMenu(Menu menu) { 1960 MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc); 1961 if (ccBcc != null && mCc != null) { 1962 // Its possible there is a menu item OR a button. 1963 boolean ccFieldVisible = mCc.isShown(); 1964 boolean bccFieldVisible = mBcc.isShown(); 1965 if (!ccFieldVisible || !bccFieldVisible) { 1966 ccBcc.setVisible(true); 1967 ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label 1968 : R.string.add_bcc_label)); 1969 } else { 1970 ccBcc.setVisible(false); 1971 } 1972 } 1973 return true; 1974 } 1975 1976 @Override 1977 public boolean onOptionsItemSelected(MenuItem item) { 1978 final int id = item.getItemId(); 1979 1980 Analytics.getInstance().sendMenuItemEvent(Analytics.EVENT_CATEGORY_MENU_ITEM, id, null, 0); 1981 1982 boolean handled = true; 1983 if (id == R.id.add_photo_attachment) { 1984 doAttach(MIME_TYPE_PHOTO); 1985 } else if (id == R.id.add_video_attachment) { 1986 doAttach(MIME_TYPE_VIDEO); 1987 } else if (id == R.id.add_cc_bcc) { 1988 showCcBccViews(); 1989 } else if (id == R.id.save) { 1990 doSave(true); 1991 } else if (id == R.id.send) { 1992 doSend(); 1993 } else if (id == R.id.discard) { 1994 doDiscard(); 1995 } else if (id == R.id.settings) { 1996 Utils.showSettings(this, mAccount); 1997 } else if (id == android.R.id.home) { 1998 onAppUpPressed(); 1999 } else if (id == R.id.help_info_menu_item) { 2000 Utils.showHelp(this, mAccount, getString(R.string.compose_help_context)); 2001 } else if (id == R.id.feedback_menu_item) { 2002 Utils.sendFeedback(this, mAccount, false); 2003 } else { 2004 handled = false; 2005 } 2006 return !handled ? super.onOptionsItemSelected(item) : handled; 2007 } 2008 2009 @Override 2010 public void onBackPressed() { 2011 // If we are showing the wait fragment, just exit. 2012 if (getWaitFragment() != null) { 2013 finish(); 2014 } else { 2015 super.onBackPressed(); 2016 } 2017 } 2018 2019 /** 2020 * Carries out the "up" action in the action bar. 2021 */ 2022 private void onAppUpPressed() { 2023 if (mLaunchedFromEmail) { 2024 // If this was started from Gmail, simply treat app up as the system back button, so 2025 // that the last view is restored. 2026 onBackPressed(); 2027 return; 2028 } 2029 2030 // Fire the main activity to ensure it launches the "top" screen of mail. 2031 // Since the main Activity is singleTask, it should revive that task if it was already 2032 // started. 2033 final Intent mailIntent = Utils.createViewInboxIntent(mAccount); 2034 mailIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | 2035 Intent.FLAG_ACTIVITY_TASK_ON_HOME); 2036 startActivity(mailIntent); 2037 finish(); 2038 } 2039 2040 private void doSend() { 2041 sendOrSaveWithSanityChecks(false, true, false, false); 2042 logSendOrSave(false /* save */); 2043 mPerformedSendOrDiscard = true; 2044 } 2045 2046 private void doSave(boolean showToast) { 2047 sendOrSaveWithSanityChecks(true, showToast, false, false); 2048 } 2049 2050 @VisibleForTesting 2051 public interface SendOrSaveCallback { 2052 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask); 2053 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, Message message); 2054 public Message getMessage(); 2055 public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success); 2056 } 2057 2058 @VisibleForTesting 2059 public static class SendOrSaveTask implements Runnable { 2060 private final Context mContext; 2061 @VisibleForTesting 2062 public final SendOrSaveCallback mSendOrSaveCallback; 2063 @VisibleForTesting 2064 public final SendOrSaveMessage mSendOrSaveMessage; 2065 private ReplyFromAccount mExistingDraftAccount; 2066 2067 public SendOrSaveTask(Context context, SendOrSaveMessage message, 2068 SendOrSaveCallback callback, ReplyFromAccount draftAccount) { 2069 mContext = context; 2070 mSendOrSaveCallback = callback; 2071 mSendOrSaveMessage = message; 2072 mExistingDraftAccount = draftAccount; 2073 } 2074 2075 @Override 2076 public void run() { 2077 final SendOrSaveMessage sendOrSaveMessage = mSendOrSaveMessage; 2078 2079 final ReplyFromAccount selectedAccount = sendOrSaveMessage.mAccount; 2080 Message message = mSendOrSaveCallback.getMessage(); 2081 long messageId = message != null ? message.id : UIProvider.INVALID_MESSAGE_ID; 2082 // If a previous draft has been saved, in an account that is different 2083 // than what the user wants to send from, remove the old draft, and treat this 2084 // as a new message 2085 if (mExistingDraftAccount != null 2086 && !selectedAccount.account.uri.equals(mExistingDraftAccount.account.uri)) { 2087 if (messageId != UIProvider.INVALID_MESSAGE_ID) { 2088 ContentResolver resolver = mContext.getContentResolver(); 2089 ContentValues values = new ContentValues(); 2090 values.put(BaseColumns._ID, messageId); 2091 if (mExistingDraftAccount.account.expungeMessageUri != null) { 2092 new ContentProviderTask.UpdateTask() 2093 .run(resolver, mExistingDraftAccount.account.expungeMessageUri, 2094 values, null, null); 2095 } else { 2096 // TODO(mindyp) delete the conversation. 2097 } 2098 // reset messageId to 0, so a new message will be created 2099 messageId = UIProvider.INVALID_MESSAGE_ID; 2100 } 2101 } 2102 2103 final long messageIdToSave = messageId; 2104 sendOrSaveMessage(messageIdToSave, sendOrSaveMessage, selectedAccount); 2105 2106 if (!sendOrSaveMessage.mSave) { 2107 incrementRecipientsTimesContacted(mContext, 2108 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.TO)); 2109 incrementRecipientsTimesContacted(mContext, 2110 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.CC)); 2111 incrementRecipientsTimesContacted(mContext, 2112 (String) sendOrSaveMessage.mValues.get(UIProvider.MessageColumns.BCC)); 2113 } 2114 mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true); 2115 } 2116 2117 private static void incrementRecipientsTimesContacted(final Context context, 2118 final String addressString) { 2119 if (TextUtils.isEmpty(addressString)) { 2120 return; 2121 } 2122 final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(addressString); 2123 final ArrayList<String> recipients = new ArrayList<String>(tokens.length); 2124 for (int i = 0; i < tokens.length;i++) { 2125 recipients.add(tokens[i].getAddress()); 2126 } 2127 final DataUsageStatUpdater statsUpdater = new DataUsageStatUpdater(context); 2128 statsUpdater.updateWithAddress(recipients); 2129 } 2130 2131 /** 2132 * Send or Save a message. 2133 */ 2134 private void sendOrSaveMessage(final long messageIdToSave, 2135 final SendOrSaveMessage sendOrSaveMessage, final ReplyFromAccount selectedAccount) { 2136 final ContentResolver resolver = mContext.getContentResolver(); 2137 final boolean updateExistingMessage = messageIdToSave != UIProvider.INVALID_MESSAGE_ID; 2138 2139 final String accountMethod = sendOrSaveMessage.mSave ? 2140 UIProvider.AccountCallMethods.SAVE_MESSAGE : 2141 UIProvider.AccountCallMethods.SEND_MESSAGE; 2142 2143 try { 2144 if (updateExistingMessage) { 2145 sendOrSaveMessage.mValues.put(BaseColumns._ID, messageIdToSave); 2146 2147 callAccountSendSaveMethod(resolver, 2148 selectedAccount.account, accountMethod, sendOrSaveMessage); 2149 } else { 2150 Uri messageUri = null; 2151 final Bundle result = callAccountSendSaveMethod(resolver, 2152 selectedAccount.account, accountMethod, sendOrSaveMessage); 2153 if (result != null) { 2154 // If a non-null value was returned, then the provider handled the call 2155 // method 2156 messageUri = result.getParcelable(UIProvider.MessageColumns.URI); 2157 } 2158 if (sendOrSaveMessage.mSave && messageUri != null) { 2159 final Cursor messageCursor = resolver.query(messageUri, 2160 UIProvider.MESSAGE_PROJECTION, null, null, null); 2161 if (messageCursor != null) { 2162 try { 2163 if (messageCursor.moveToFirst()) { 2164 // Broadcast notification that a new message has 2165 // been allocated 2166 mSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, 2167 new Message(messageCursor)); 2168 } 2169 } finally { 2170 messageCursor.close(); 2171 } 2172 } 2173 } 2174 } 2175 } finally { 2176 // Close any opened file descriptors 2177 closeOpenedAttachmentFds(sendOrSaveMessage); 2178 } 2179 } 2180 2181 private static void closeOpenedAttachmentFds(final SendOrSaveMessage sendOrSaveMessage) { 2182 final Bundle openedFds = sendOrSaveMessage.attachmentFds(); 2183 if (openedFds != null) { 2184 final Set<String> keys = openedFds.keySet(); 2185 for (final String key : keys) { 2186 final ParcelFileDescriptor fd = openedFds.getParcelable(key); 2187 if (fd != null) { 2188 try { 2189 fd.close(); 2190 } catch (IOException e) { 2191 // Do nothing 2192 } 2193 } 2194 } 2195 } 2196 } 2197 2198 /** 2199 * Use the {@link ContentResolver#call} method to send or save the message. 2200 * 2201 * If this was successful, this method will return an non-null Bundle instance 2202 */ 2203 private static Bundle callAccountSendSaveMethod(final ContentResolver resolver, 2204 final Account account, final String method, 2205 final SendOrSaveMessage sendOrSaveMessage) { 2206 // Copy all of the values from the content values to the bundle 2207 final Bundle methodExtras = new Bundle(sendOrSaveMessage.mValues.size()); 2208 final Set<Entry<String, Object>> valueSet = sendOrSaveMessage.mValues.valueSet(); 2209 2210 for (Entry<String, Object> entry : valueSet) { 2211 final Object entryValue = entry.getValue(); 2212 final String key = entry.getKey(); 2213 if (entryValue instanceof String) { 2214 methodExtras.putString(key, (String)entryValue); 2215 } else if (entryValue instanceof Boolean) { 2216 methodExtras.putBoolean(key, (Boolean)entryValue); 2217 } else if (entryValue instanceof Integer) { 2218 methodExtras.putInt(key, (Integer)entryValue); 2219 } else if (entryValue instanceof Long) { 2220 methodExtras.putLong(key, (Long)entryValue); 2221 } else { 2222 LogUtils.wtf(LOG_TAG, "Unexpected object type: %s", 2223 entryValue.getClass().getName()); 2224 } 2225 } 2226 2227 // If the SendOrSaveMessage has some opened fds, add them to the bundle 2228 final Bundle fdMap = sendOrSaveMessage.attachmentFds(); 2229 if (fdMap != null) { 2230 methodExtras.putParcelable( 2231 UIProvider.SendOrSaveMethodParamKeys.OPENED_FD_MAP, fdMap); 2232 } 2233 2234 return resolver.call(account.uri, method, account.uri.toString(), methodExtras); 2235 } 2236 } 2237 2238 @VisibleForTesting 2239 public static class SendOrSaveMessage { 2240 final ReplyFromAccount mAccount; 2241 final ContentValues mValues; 2242 final String mRefMessageId; 2243 @VisibleForTesting 2244 public final boolean mSave; 2245 final int mRequestId; 2246 private final Bundle mAttachmentFds; 2247 2248 public SendOrSaveMessage(Context context, ReplyFromAccount account, ContentValues values, 2249 String refMessageId, List<Attachment> attachments, boolean save) { 2250 mAccount = account; 2251 mValues = values; 2252 mRefMessageId = refMessageId; 2253 mSave = save; 2254 mRequestId = mValues.hashCode() ^ hashCode(); 2255 2256 mAttachmentFds = initializeAttachmentFds(context, attachments); 2257 } 2258 2259 int requestId() { 2260 return mRequestId; 2261 } 2262 2263 Bundle attachmentFds() { 2264 return mAttachmentFds; 2265 } 2266 2267 /** 2268 * Opens {@link ParcelFileDescriptor} for each of the attachments. This method must be 2269 * called before the ComposeActivity finishes. 2270 * Note: The caller is responsible for closing these file descriptors. 2271 */ 2272 private static Bundle initializeAttachmentFds(final Context context, 2273 final List<Attachment> attachments) { 2274 if (attachments == null || attachments.size() == 0) { 2275 return null; 2276 } 2277 2278 final Bundle result = new Bundle(attachments.size()); 2279 final ContentResolver resolver = context.getContentResolver(); 2280 2281 for (Attachment attachment : attachments) { 2282 if (attachment == null || Utils.isEmpty(attachment.contentUri)) { 2283 continue; 2284 } 2285 2286 ParcelFileDescriptor fileDescriptor; 2287 try { 2288 fileDescriptor = resolver.openFileDescriptor(attachment.contentUri, "r"); 2289 } catch (FileNotFoundException e) { 2290 LogUtils.e(LOG_TAG, e, "Exception attempting to open attachment"); 2291 fileDescriptor = null; 2292 } catch (SecurityException e) { 2293 // We have encountered a security exception when attempting to open the file 2294 // specified by the content uri. If the attachment has been cached, this 2295 // isn't a problem, as even through the original permission may have been 2296 // revoked, we have cached the file. This will happen when saving/sending 2297 // a previously saved draft. 2298 // TODO(markwei): Expose whether the attachment has been cached through the 2299 // attachment object. This would allow us to limit when the log is made, as 2300 // if the attachment has been cached, this really isn't an error 2301 LogUtils.e(LOG_TAG, e, "Security Exception attempting to open attachment"); 2302 // Just set the file descriptor to null, as the underlying provider needs 2303 // to handle the file descriptor not being set. 2304 fileDescriptor = null; 2305 } 2306 2307 if (fileDescriptor != null) { 2308 result.putParcelable(attachment.contentUri.toString(), fileDescriptor); 2309 } 2310 } 2311 2312 return result; 2313 } 2314 } 2315 2316 /** 2317 * Get the to recipients. 2318 */ 2319 public String[] getToAddresses() { 2320 return getAddressesFromList(mTo); 2321 } 2322 2323 /** 2324 * Get the cc recipients. 2325 */ 2326 public String[] getCcAddresses() { 2327 return getAddressesFromList(mCc); 2328 } 2329 2330 /** 2331 * Get the bcc recipients. 2332 */ 2333 public String[] getBccAddresses() { 2334 return getAddressesFromList(mBcc); 2335 } 2336 2337 public String[] getAddressesFromList(RecipientEditTextView list) { 2338 if (list == null) { 2339 return new String[0]; 2340 } 2341 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText()); 2342 int count = tokens.length; 2343 String[] result = new String[count]; 2344 for (int i = 0; i < count; i++) { 2345 result[i] = tokens[i].toString(); 2346 } 2347 return result; 2348 } 2349 2350 /** 2351 * Check for invalid email addresses. 2352 * @param to String array of email addresses to check. 2353 * @param wrongEmailsOut Emails addresses that were invalid. 2354 */ 2355 public void checkInvalidEmails(final String[] to, final List<String> wrongEmailsOut) { 2356 if (mValidator == null) { 2357 return; 2358 } 2359 for (final String email : to) { 2360 if (!mValidator.isValid(email)) { 2361 wrongEmailsOut.add(email); 2362 } 2363 } 2364 } 2365 2366 public static class RecipientErrorDialogFragment extends DialogFragment { 2367 // Public no-args constructor needed for fragment re-instantiation 2368 public RecipientErrorDialogFragment() {} 2369 2370 public static RecipientErrorDialogFragment newInstance(final String message) { 2371 final RecipientErrorDialogFragment frag = new RecipientErrorDialogFragment(); 2372 final Bundle args = new Bundle(1); 2373 args.putString("message", message); 2374 frag.setArguments(args); 2375 return frag; 2376 } 2377 2378 @Override 2379 public Dialog onCreateDialog(Bundle savedInstanceState) { 2380 final String message = getArguments().getString("message"); 2381 return new AlertDialog.Builder(getActivity()).setMessage(message).setTitle( 2382 R.string.recipient_error_dialog_title) 2383 .setIconAttribute(android.R.attr.alertDialogIcon) 2384 .setPositiveButton( 2385 R.string.ok, new Dialog.OnClickListener() { 2386 @Override 2387 public void onClick(DialogInterface dialog, int which) { 2388 ((ComposeActivity)getActivity()).finishRecipientErrorDialog(); 2389 } 2390 }).create(); 2391 } 2392 } 2393 2394 private void finishRecipientErrorDialog() { 2395 // after the user dismisses the recipient error 2396 // dialog we want to make sure to refocus the 2397 // recipient to field so they can fix the issue 2398 // easily 2399 if (mTo != null) { 2400 mTo.requestFocus(); 2401 } 2402 } 2403 2404 /** 2405 * Show an error because the user has entered an invalid recipient. 2406 * @param message 2407 */ 2408 private void showRecipientErrorDialog(final String message) { 2409 final DialogFragment frag = RecipientErrorDialogFragment.newInstance(message); 2410 frag.show(getFragmentManager(), "recipient error"); 2411 } 2412 2413 /** 2414 * Update the state of the UI based on whether or not the current draft 2415 * needs to be saved and the message is not empty. 2416 */ 2417 public void updateSaveUi() { 2418 if (mSave != null) { 2419 mSave.setEnabled((shouldSave() && !isBlank())); 2420 } 2421 } 2422 2423 /** 2424 * Returns true if we need to save the current draft. 2425 */ 2426 private boolean shouldSave() { 2427 synchronized (mDraftLock) { 2428 // The message should only be saved if: 2429 // It hasn't been sent AND 2430 // Some text has been added to the message OR 2431 // an attachment has been added or removed 2432 // AND there is actually something in the draft to save. 2433 return (mTextChanged || mAttachmentsChanged || mReplyFromChanged) 2434 && !isBlank(); 2435 } 2436 } 2437 2438 /** 2439 * Check if all fields are blank. 2440 * @return boolean 2441 */ 2442 public boolean isBlank() { 2443 return mSubject.getText().length() == 0 2444 && (mBodyView.getText().length() == 0 || getSignatureStartPosition(mSignature, 2445 mBodyView.getText().toString()) == 0) 2446 && mTo.length() == 0 2447 && mCc.length() == 0 && mBcc.length() == 0 2448 && mAttachmentsView.getAttachments().size() == 0; 2449 } 2450 2451 @VisibleForTesting 2452 protected int getSignatureStartPosition(String signature, String bodyText) { 2453 int startPos = -1; 2454 2455 if (TextUtils.isEmpty(signature) || TextUtils.isEmpty(bodyText)) { 2456 return startPos; 2457 } 2458 2459 int bodyLength = bodyText.length(); 2460 int signatureLength = signature.length(); 2461 String printableVersion = convertToPrintableSignature(signature); 2462 int printableLength = printableVersion.length(); 2463 2464 if (bodyLength >= printableLength 2465 && bodyText.substring(bodyLength - printableLength) 2466 .equals(printableVersion)) { 2467 startPos = bodyLength - printableLength; 2468 } else if (bodyLength >= signatureLength 2469 && bodyText.substring(bodyLength - signatureLength) 2470 .equals(signature)) { 2471 startPos = bodyLength - signatureLength; 2472 } 2473 return startPos; 2474 } 2475 2476 /** 2477 * Allows any changes made by the user to be ignored. Called when the user 2478 * decides to discard a draft. 2479 */ 2480 private void discardChanges() { 2481 mTextChanged = false; 2482 mAttachmentsChanged = false; 2483 mReplyFromChanged = false; 2484 } 2485 2486 /** 2487 * @param save 2488 * @param showToast 2489 * @return Whether the send or save succeeded. 2490 */ 2491 protected boolean sendOrSaveWithSanityChecks(final boolean save, final boolean showToast, 2492 final boolean orientationChanged, final boolean autoSend) { 2493 if (mAccounts == null || mAccount == null) { 2494 Toast.makeText(this, R.string.send_failed, Toast.LENGTH_SHORT).show(); 2495 if (autoSend) { 2496 finish(); 2497 } 2498 return false; 2499 } 2500 2501 final String[] to, cc, bcc; 2502 if (orientationChanged) { 2503 to = cc = bcc = new String[0]; 2504 } else { 2505 to = getToAddresses(); 2506 cc = getCcAddresses(); 2507 bcc = getBccAddresses(); 2508 } 2509 2510 // Don't let the user send to nobody (but it's okay to save a message 2511 // with no recipients) 2512 if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) { 2513 showRecipientErrorDialog(getString(R.string.recipient_needed)); 2514 return false; 2515 } 2516 2517 List<String> wrongEmails = new ArrayList<String>(); 2518 if (!save) { 2519 checkInvalidEmails(to, wrongEmails); 2520 checkInvalidEmails(cc, wrongEmails); 2521 checkInvalidEmails(bcc, wrongEmails); 2522 } 2523 2524 // Don't let the user send an email with invalid recipients 2525 if (wrongEmails.size() > 0) { 2526 String errorText = String.format(getString(R.string.invalid_recipient), 2527 wrongEmails.get(0)); 2528 showRecipientErrorDialog(errorText); 2529 return false; 2530 } 2531 2532 // Show a warning before sending only if there are no attachments. 2533 if (!save) { 2534 if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) { 2535 boolean warnAboutEmptySubject = isSubjectEmpty(); 2536 boolean emptyBody = TextUtils.getTrimmedLength(mBodyView.getEditableText()) == 0; 2537 2538 // A warning about an empty body may not be warranted when 2539 // forwarding mails, since a common use case is to forward 2540 // quoted text and not append any more text. 2541 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty()); 2542 2543 // When we bring up a dialog warning the user about a send, 2544 // assume that they accept sending the message. If they do not, 2545 // the dialog listener is required to enable sending again. 2546 if (warnAboutEmptySubject) { 2547 showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, save, 2548 showToast); 2549 return true; 2550 } 2551 2552 if (warnAboutEmptyBody) { 2553 showSendConfirmDialog(R.string.confirm_send_message_with_no_body, save, 2554 showToast); 2555 return true; 2556 } 2557 } 2558 // Ask for confirmation to send (if always required) 2559 if (showSendConfirmation()) { 2560 showSendConfirmDialog(R.string.confirm_send_message, save, showToast); 2561 return true; 2562 } 2563 } 2564 2565 sendOrSave(save, showToast); 2566 return true; 2567 } 2568 2569 /** 2570 * Returns a boolean indicating whether warnings should be shown for empty 2571 * subject and body fields 2572 * 2573 * @return True if a warning should be shown for empty text fields 2574 */ 2575 protected boolean showEmptyTextWarnings() { 2576 return mAttachmentsView.getAttachments().size() == 0; 2577 } 2578 2579 /** 2580 * Returns a boolean indicating whether the user should confirm each send 2581 * 2582 * @return True if a warning should be on each send 2583 */ 2584 protected boolean showSendConfirmation() { 2585 return mCachedSettings != null ? mCachedSettings.confirmSend : false; 2586 } 2587 2588 public static class SendConfirmDialogFragment extends DialogFragment { 2589 // Public no-args constructor needed for fragment re-instantiation 2590 public SendConfirmDialogFragment() {} 2591 2592 public static SendConfirmDialogFragment newInstance(final int messageId, 2593 final boolean save, final boolean showToast) { 2594 final SendConfirmDialogFragment frag = new SendConfirmDialogFragment(); 2595 final Bundle args = new Bundle(3); 2596 args.putInt("messageId", messageId); 2597 args.putBoolean("save", save); 2598 args.putBoolean("showToast", showToast); 2599 frag.setArguments(args); 2600 return frag; 2601 } 2602 2603 @Override 2604 public Dialog onCreateDialog(Bundle savedInstanceState) { 2605 final int messageId = getArguments().getInt("messageId"); 2606 final boolean save = getArguments().getBoolean("save"); 2607 final boolean showToast = getArguments().getBoolean("showToast"); 2608 2609 return new AlertDialog.Builder(getActivity()) 2610 .setMessage(messageId) 2611 .setTitle(R.string.confirm_send_title) 2612 .setIconAttribute(android.R.attr.alertDialogIcon) 2613 .setPositiveButton(R.string.send, 2614 new DialogInterface.OnClickListener() { 2615 @Override 2616 public void onClick(DialogInterface dialog, int whichButton) { 2617 ((ComposeActivity)getActivity()).finishSendConfirmDialog(save, 2618 showToast); 2619 } 2620 }) 2621 .create(); 2622 } 2623 } 2624 2625 private void finishSendConfirmDialog(final boolean save, final boolean showToast) { 2626 sendOrSave(save, showToast); 2627 } 2628 2629 private void showSendConfirmDialog(final int messageId, final boolean save, 2630 final boolean showToast) { 2631 final DialogFragment frag = SendConfirmDialogFragment.newInstance(messageId, save, 2632 showToast); 2633 frag.show(getFragmentManager(), "send confirm"); 2634 } 2635 2636 /** 2637 * Returns whether the ComposeArea believes there is any text in the body of 2638 * the composition. TODO: When ComposeArea controls the Body as well, add 2639 * that here. 2640 */ 2641 public boolean isBodyEmpty() { 2642 return !mQuotedTextView.isTextIncluded(); 2643 } 2644 2645 /** 2646 * Test to see if the subject is empty. 2647 * 2648 * @return boolean. 2649 */ 2650 // TODO: this will likely go away when composeArea.focus() is implemented 2651 // after all the widget control is moved over. 2652 public boolean isSubjectEmpty() { 2653 return TextUtils.getTrimmedLength(mSubject.getText()) == 0; 2654 } 2655 2656 /* package */ 2657 static int sendOrSaveInternal(Context context, ReplyFromAccount replyFromAccount, 2658 Message message, final Message refMessage, Spanned body, final CharSequence quotedText, 2659 SendOrSaveCallback callback, Handler handler, boolean save, int composeMode, 2660 ReplyFromAccount draftAccount, final ContentValues extraValues) { 2661 final ContentValues values = new ContentValues(); 2662 2663 final String refMessageId = refMessage != null ? refMessage.uri.toString() : ""; 2664 2665 MessageModification.putToAddresses(values, message.getToAddresses()); 2666 MessageModification.putCcAddresses(values, message.getCcAddresses()); 2667 MessageModification.putBccAddresses(values, message.getBccAddresses()); 2668 2669 MessageModification.putCustomFromAddress(values, message.getFrom()); 2670 2671 MessageModification.putSubject(values, message.subject); 2672 // Make sure to remove only the composing spans from the Spannable before saving. 2673 final String htmlBody = Html.toHtml(removeComposingSpans(body)); 2674 2675 boolean includeQuotedText = !TextUtils.isEmpty(quotedText); 2676 StringBuilder fullBody = new StringBuilder(htmlBody); 2677 if (includeQuotedText) { 2678 // HTML gets converted to text for now 2679 final String text = quotedText.toString(); 2680 if (QuotedTextView.containsQuotedText(text)) { 2681 int pos = QuotedTextView.getQuotedTextOffset(text); 2682 final int quoteStartPos = fullBody.length() + pos; 2683 fullBody.append(text); 2684 MessageModification.putQuoteStartPos(values, quoteStartPos); 2685 MessageModification.putForward(values, composeMode == ComposeActivity.FORWARD); 2686 MessageModification.putAppendRefMessageContent(values, includeQuotedText); 2687 } else { 2688 LogUtils.w(LOG_TAG, "Couldn't find quoted text"); 2689 // This shouldn't happen, but just use what we have, 2690 // and don't do server-side expansion 2691 fullBody.append(text); 2692 } 2693 } 2694 int draftType = getDraftType(composeMode); 2695 MessageModification.putDraftType(values, draftType); 2696 if (refMessage != null) { 2697 if (!TextUtils.isEmpty(refMessage.bodyHtml)) { 2698 MessageModification.putBodyHtml(values, fullBody.toString()); 2699 } 2700 if (!TextUtils.isEmpty(refMessage.bodyText)) { 2701 MessageModification.putBody(values, 2702 Utils.convertHtmlToPlainText(fullBody.toString()).toString()); 2703 } 2704 } else { 2705 MessageModification.putBodyHtml(values, fullBody.toString()); 2706 MessageModification.putBody(values, Utils.convertHtmlToPlainText(fullBody.toString()) 2707 .toString()); 2708 } 2709 MessageModification.putAttachments(values, message.getAttachments()); 2710 if (!TextUtils.isEmpty(refMessageId)) { 2711 MessageModification.putRefMessageId(values, refMessageId); 2712 } 2713 if (extraValues != null) { 2714 values.putAll(extraValues); 2715 } 2716 SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(context, replyFromAccount, 2717 values, refMessageId, message.getAttachments(), save); 2718 SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback, 2719 draftAccount); 2720 2721 callback.initializeSendOrSave(sendOrSaveTask); 2722 // Do the send/save action on the specified handler to avoid possible 2723 // ANRs 2724 handler.post(sendOrSaveTask); 2725 2726 return sendOrSaveMessage.requestId(); 2727 } 2728 2729 /** 2730 * Removes any composing spans from the specified string. This will create a new 2731 * SpannableString instance, as to not modify the behavior of the EditText view. 2732 */ 2733 private static SpannableString removeComposingSpans(Spanned body) { 2734 final SpannableString messageBody = new SpannableString(body); 2735 BaseInputConnection.removeComposingSpans(messageBody); 2736 return messageBody; 2737 } 2738 2739 private static int getDraftType(int mode) { 2740 int draftType = -1; 2741 switch (mode) { 2742 case ComposeActivity.COMPOSE: 2743 draftType = DraftType.COMPOSE; 2744 break; 2745 case ComposeActivity.REPLY: 2746 draftType = DraftType.REPLY; 2747 break; 2748 case ComposeActivity.REPLY_ALL: 2749 draftType = DraftType.REPLY_ALL; 2750 break; 2751 case ComposeActivity.FORWARD: 2752 draftType = DraftType.FORWARD; 2753 break; 2754 } 2755 return draftType; 2756 } 2757 2758 private void sendOrSave(final boolean save, final boolean showToast) { 2759 // Check if user is a monkey. Monkeys can compose and hit send 2760 // button but are not allowed to send anything off the device. 2761 if (ActivityManager.isUserAMonkey()) { 2762 return; 2763 } 2764 2765 final Spanned body = mBodyView.getEditableText(); 2766 2767 SendOrSaveCallback callback = new SendOrSaveCallback() { 2768 // FIXME: unused 2769 private int mRestoredRequestId; 2770 2771 @Override 2772 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) { 2773 synchronized (mActiveTasks) { 2774 int numTasks = mActiveTasks.size(); 2775 if (numTasks == 0) { 2776 // Start service so we won't be killed if this app is 2777 // put in the background. 2778 startService(new Intent(ComposeActivity.this, EmptyService.class)); 2779 } 2780 2781 mActiveTasks.add(sendOrSaveTask); 2782 } 2783 if (sTestSendOrSaveCallback != null) { 2784 sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask); 2785 } 2786 } 2787 2788 @Override 2789 public void notifyMessageIdAllocated(SendOrSaveMessage sendOrSaveMessage, 2790 Message message) { 2791 synchronized (mDraftLock) { 2792 mDraftAccount = sendOrSaveMessage.mAccount; 2793 mDraftId = message.id; 2794 mDraft = message; 2795 if (sRequestMessageIdMap != null) { 2796 sRequestMessageIdMap.put(sendOrSaveMessage.requestId(), mDraftId); 2797 } 2798 // Cache request message map, in case the process is killed 2799 saveRequestMap(); 2800 } 2801 if (sTestSendOrSaveCallback != null) { 2802 sTestSendOrSaveCallback.notifyMessageIdAllocated(sendOrSaveMessage, message); 2803 } 2804 } 2805 2806 @Override 2807 public Message getMessage() { 2808 synchronized (mDraftLock) { 2809 return mDraft; 2810 } 2811 } 2812 2813 @Override 2814 public void sendOrSaveFinished(SendOrSaveTask task, boolean success) { 2815 // Update the last sent from account. 2816 if (mAccount != null) { 2817 MailAppProvider.getInstance().setLastSentFromAccount(mAccount.uri.toString()); 2818 } 2819 if (success) { 2820 // Successfully sent or saved so reset change markers 2821 discardChanges(); 2822 } else { 2823 // A failure happened with saving/sending the draft 2824 // TODO(pwestbro): add a better string that should be used 2825 // when failing to send or save 2826 Toast.makeText(ComposeActivity.this, R.string.send_failed, Toast.LENGTH_SHORT) 2827 .show(); 2828 } 2829 2830 int numTasks; 2831 synchronized (mActiveTasks) { 2832 // Remove the task from the list of active tasks 2833 mActiveTasks.remove(task); 2834 numTasks = mActiveTasks.size(); 2835 } 2836 2837 if (numTasks == 0) { 2838 // Stop service so we can be killed. 2839 stopService(new Intent(ComposeActivity.this, EmptyService.class)); 2840 } 2841 if (sTestSendOrSaveCallback != null) { 2842 sTestSendOrSaveCallback.sendOrSaveFinished(task, success); 2843 } 2844 } 2845 }; 2846 2847 setAccount(mReplyFromAccount.account); 2848 2849 if (mSendSaveTaskHandler == null) { 2850 HandlerThread handlerThread = new HandlerThread("Send Message Task Thread"); 2851 handlerThread.start(); 2852 2853 mSendSaveTaskHandler = new Handler(handlerThread.getLooper()); 2854 } 2855 2856 Message msg = createMessage(mReplyFromAccount, getMode()); 2857 mRequestId = sendOrSaveInternal(this, mReplyFromAccount, msg, mRefMessage, body, 2858 mQuotedTextView.getQuotedTextIfIncluded(), callback, 2859 mSendSaveTaskHandler, save, mComposeMode, mDraftAccount, mExtraValues); 2860 2861 // Don't display the toast if the user is just changing the orientation, 2862 // but we still need to save the draft to the cursor because this is how we restore 2863 // the attachments when the configuration change completes. 2864 if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 2865 Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, 2866 Toast.LENGTH_LONG).show(); 2867 } 2868 2869 // Need to update variables here because the send or save completes 2870 // asynchronously even though the toast shows right away. 2871 discardChanges(); 2872 updateSaveUi(); 2873 2874 // If we are sending, finish the activity 2875 if (!save) { 2876 finish(); 2877 } 2878 } 2879 2880 /** 2881 * Save the state of the request messageid map. This allows for the Gmail 2882 * process to be killed, but and still allow for ComposeActivity instances 2883 * to be recreated correctly. 2884 */ 2885 private void saveRequestMap() { 2886 // TODO: store the request map in user preferences. 2887 } 2888 2889 private void doAttach(String type) { 2890 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 2891 i.addCategory(Intent.CATEGORY_OPENABLE); 2892 i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 2893 i.setType(type); 2894 mAddingAttachment = true; 2895 startActivityForResult(Intent.createChooser(i, getText(R.string.select_attachment_type)), 2896 RESULT_PICK_ATTACHMENT); 2897 } 2898 2899 private void showCcBccViews() { 2900 mCcBccView.show(true, true, true); 2901 if (mCcBccButton != null) { 2902 mCcBccButton.setVisibility(View.INVISIBLE); 2903 } 2904 } 2905 2906 private void logSendOrSave(boolean save) { 2907 final String category = (save) ? "message_save" : "message_send"; 2908 final int attachmentCount = getAttachments().size(); 2909 final String msgType; 2910 switch (mComposeMode) { 2911 case COMPOSE: 2912 msgType = "new_message"; 2913 break; 2914 case REPLY: 2915 msgType = "reply"; 2916 break; 2917 case REPLY_ALL: 2918 msgType = "reply_all"; 2919 break; 2920 case FORWARD: 2921 msgType = "forward"; 2922 break; 2923 default: 2924 msgType = "unknown"; 2925 break; 2926 } 2927 final String label; 2928 final long value; 2929 if (mComposeMode == COMPOSE) { 2930 label = Integer.toString(attachmentCount); 2931 value = attachmentCount; 2932 } else { 2933 label = null; 2934 value = 0; 2935 } 2936 Analytics.getInstance().sendEvent(category, msgType, label, value); 2937 } 2938 2939 @Override 2940 public boolean onNavigationItemSelected(int position, long itemId) { 2941 int initialComposeMode = mComposeMode; 2942 if (position == ComposeActivity.REPLY) { 2943 mComposeMode = ComposeActivity.REPLY; 2944 } else if (position == ComposeActivity.REPLY_ALL) { 2945 mComposeMode = ComposeActivity.REPLY_ALL; 2946 } else if (position == ComposeActivity.FORWARD) { 2947 mComposeMode = ComposeActivity.FORWARD; 2948 } 2949 clearChangeListeners(); 2950 if (initialComposeMode != mComposeMode) { 2951 resetMessageForModeChange(); 2952 if (mRefMessage != null) { 2953 setFieldsFromRefMessage(mComposeMode); 2954 } 2955 boolean showCc = false; 2956 boolean showBcc = false; 2957 if (mDraft != null) { 2958 // Following desktop behavior, if the user has added a BCC 2959 // field to a draft, we show it regardless of compose mode. 2960 showBcc = !TextUtils.isEmpty(mDraft.getBcc()); 2961 // Use the draft to determine what to populate. 2962 // If the Bcc field is showing, show the Cc field whether it is populated or not. 2963 showCc = showBcc 2964 || (!TextUtils.isEmpty(mDraft.getCc()) && mComposeMode == REPLY_ALL); 2965 } 2966 if (mRefMessage != null) { 2967 showCc = !TextUtils.isEmpty(mCc.getText()); 2968 showBcc = !TextUtils.isEmpty(mBcc.getText()); 2969 } 2970 mCcBccView.show(false, showCc, showBcc); 2971 } 2972 updateHideOrShowCcBcc(); 2973 initChangeListeners(); 2974 return true; 2975 } 2976 2977 @VisibleForTesting 2978 protected void resetMessageForModeChange() { 2979 // When switching between reply, reply all, forward, 2980 // follow the behavior of webview. 2981 // The contents of the following fields are cleared 2982 // so that they can be populated directly from the 2983 // ref message: 2984 // 1) Any recipient fields 2985 // 2) The subject 2986 mTo.setText(""); 2987 mCc.setText(""); 2988 mBcc.setText(""); 2989 // Any edits to the subject are replaced with the original subject. 2990 mSubject.setText(""); 2991 2992 // Any changes to the contents of the following fields are kept: 2993 // 1) Body 2994 // 2) Attachments 2995 // If the user made changes to attachments, keep their changes. 2996 if (!mAttachmentsChanged) { 2997 mAttachmentsView.deleteAllAttachments(); 2998 } 2999 } 3000 3001 private class ComposeModeAdapter extends ArrayAdapter<String> { 3002 3003 private LayoutInflater mInflater; 3004 3005 public ComposeModeAdapter(Context context) { 3006 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 3007 .getStringArray(R.array.compose_modes)); 3008 } 3009 3010 private LayoutInflater getInflater() { 3011 if (mInflater == null) { 3012 mInflater = LayoutInflater.from(getContext()); 3013 } 3014 return mInflater; 3015 } 3016 3017 @Override 3018 public View getView(int position, View convertView, ViewGroup parent) { 3019 if (convertView == null) { 3020 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 3021 } 3022 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 3023 return super.getView(position, convertView, parent); 3024 } 3025 } 3026 3027 @Override 3028 public void onRespondInline(String text) { 3029 appendToBody(text, false); 3030 mQuotedTextView.setUpperDividerVisible(false); 3031 mRespondedInline = true; 3032 if (!mBodyView.hasFocus()) { 3033 mBodyView.requestFocus(); 3034 } 3035 } 3036 3037 /** 3038 * Append text to the body of the message. If there is no existing body 3039 * text, just sets the body to text. 3040 * 3041 * @param text 3042 * @param withSignature True to append a signature. 3043 */ 3044 public void appendToBody(CharSequence text, boolean withSignature) { 3045 Editable bodyText = mBodyView.getEditableText(); 3046 if (bodyText != null && bodyText.length() > 0) { 3047 bodyText.append(text); 3048 } else { 3049 setBody(text, withSignature); 3050 } 3051 } 3052 3053 /** 3054 * Set the body of the message. 3055 * 3056 * @param text 3057 * @param withSignature True to append a signature. 3058 */ 3059 public void setBody(CharSequence text, boolean withSignature) { 3060 mBodyView.setText(text); 3061 if (withSignature) { 3062 appendSignature(); 3063 } 3064 } 3065 3066 private void appendSignature() { 3067 String newSignature = mCachedSettings != null ? mCachedSettings.signature : null; 3068 boolean hasFocus = mBodyView.hasFocus(); 3069 int signaturePos = getSignatureStartPosition(mSignature, mBodyView.getText().toString()); 3070 if (!TextUtils.equals(newSignature, mSignature) || signaturePos < 0) { 3071 mSignature = newSignature; 3072 if (!TextUtils.isEmpty(mSignature)) { 3073 // Appending a signature does not count as changing text. 3074 mBodyView.removeTextChangedListener(this); 3075 mBodyView.append(convertToPrintableSignature(mSignature)); 3076 mBodyView.addTextChangedListener(this); 3077 } 3078 if (hasFocus) { 3079 focusBody(); 3080 } 3081 } 3082 } 3083 3084 private String convertToPrintableSignature(String signature) { 3085 String signatureResource = getResources().getString(R.string.signature); 3086 if (signature == null) { 3087 signature = ""; 3088 } 3089 return String.format(signatureResource, signature); 3090 } 3091 3092 @Override 3093 public void onAccountChanged() { 3094 mReplyFromAccount = mFromSpinner.getCurrentAccount(); 3095 if (!mAccount.equals(mReplyFromAccount.account)) { 3096 // Clear a signature, if there was one. 3097 mBodyView.removeTextChangedListener(this); 3098 String oldSignature = mSignature; 3099 String bodyText = getBody().getText().toString(); 3100 if (!TextUtils.isEmpty(oldSignature)) { 3101 int pos = getSignatureStartPosition(oldSignature, bodyText); 3102 if (pos > -1) { 3103 mBodyView.setText(bodyText.substring(0, pos)); 3104 } 3105 } 3106 setAccount(mReplyFromAccount.account); 3107 mBodyView.addTextChangedListener(this); 3108 // TODO: handle discarding attachments when switching accounts. 3109 // Only enable save for this draft if there is any other content 3110 // in the message. 3111 if (!isBlank()) { 3112 enableSave(true); 3113 } 3114 mReplyFromChanged = true; 3115 initRecipients(); 3116 } 3117 } 3118 3119 public void enableSave(boolean enabled) { 3120 if (mSave != null) { 3121 mSave.setEnabled(enabled); 3122 } 3123 } 3124 3125 public static class DiscardConfirmDialogFragment extends DialogFragment { 3126 // Public no-args constructor needed for fragment re-instantiation 3127 public DiscardConfirmDialogFragment() {} 3128 3129 @Override 3130 public Dialog onCreateDialog(Bundle savedInstanceState) { 3131 return new AlertDialog.Builder(getActivity()) 3132 .setMessage(R.string.confirm_discard_text) 3133 .setPositiveButton(R.string.discard, 3134 new DialogInterface.OnClickListener() { 3135 @Override 3136 public void onClick(DialogInterface dialog, int which) { 3137 ((ComposeActivity)getActivity()).doDiscardWithoutConfirmation(); 3138 } 3139 }) 3140 .setNegativeButton(R.string.cancel, null) 3141 .create(); 3142 } 3143 } 3144 3145 private void doDiscard() { 3146 final DialogFragment frag = new DiscardConfirmDialogFragment(); 3147 frag.show(getFragmentManager(), "discard confirm"); 3148 } 3149 /** 3150 * Effectively discard the current message. 3151 * 3152 * This method is either invoked from the menu or from the dialog 3153 * once the user has confirmed that they want to discard the message. 3154 */ 3155 private void doDiscardWithoutConfirmation() { 3156 synchronized (mDraftLock) { 3157 if (mDraftId != UIProvider.INVALID_MESSAGE_ID) { 3158 ContentValues values = new ContentValues(); 3159 values.put(BaseColumns._ID, mDraftId); 3160 if (!mAccount.expungeMessageUri.equals(Uri.EMPTY)) { 3161 getContentResolver().update(mAccount.expungeMessageUri, values, null, null); 3162 } else { 3163 getContentResolver().delete(mDraft.uri, null, null); 3164 } 3165 // This is not strictly necessary (since we should not try to 3166 // save the draft after calling this) but it ensures that if we 3167 // do save again for some reason we make a new draft rather than 3168 // trying to resave an expunged draft. 3169 mDraftId = UIProvider.INVALID_MESSAGE_ID; 3170 } 3171 } 3172 3173 // Display a toast to let the user know 3174 Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show(); 3175 3176 // This prevents the draft from being saved in onPause(). 3177 discardChanges(); 3178 mPerformedSendOrDiscard = true; 3179 finish(); 3180 } 3181 3182 private void saveIfNeeded() { 3183 if (mAccount == null) { 3184 // We have not chosen an account yet so there's no way that we can save. This is ok, 3185 // though, since we are saving our state before AccountsActivity is activated. Thus, the 3186 // user has not interacted with us yet and there is no real state to save. 3187 return; 3188 } 3189 3190 if (shouldSave()) { 3191 doSave(!mAddingAttachment /* show toast */); 3192 } 3193 } 3194 3195 @Override 3196 public void onAttachmentDeleted() { 3197 mAttachmentsChanged = true; 3198 // If we are showing any attachments, make sure we have an upper 3199 // divider. 3200 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3201 updateSaveUi(); 3202 } 3203 3204 @Override 3205 public void onAttachmentAdded() { 3206 mQuotedTextView.setUpperDividerVisible(mAttachmentsView.getAttachments().size() > 0); 3207 mAttachmentsView.focusLastAttachment(); 3208 } 3209 3210 /** 3211 * This is called any time one of our text fields changes. 3212 */ 3213 @Override 3214 public void afterTextChanged(Editable s) { 3215 mTextChanged = true; 3216 updateSaveUi(); 3217 } 3218 3219 @Override 3220 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3221 // Do nothing. 3222 } 3223 3224 @Override 3225 public void onTextChanged(CharSequence s, int start, int before, int count) { 3226 // Do nothing. 3227 } 3228 3229 3230 // There is a big difference between the text associated with an address changing 3231 // to add the display name or to format properly and a recipient being added or deleted. 3232 // Make sure we only notify of changes when a recipient has been added or deleted. 3233 private class RecipientTextWatcher implements TextWatcher { 3234 private HashMap<String, Integer> mContent = new HashMap<String, Integer>(); 3235 3236 private RecipientEditTextView mView; 3237 3238 private TextWatcher mListener; 3239 3240 public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) { 3241 mView = view; 3242 mListener = listener; 3243 } 3244 3245 @Override 3246 public void afterTextChanged(Editable s) { 3247 if (hasChanged()) { 3248 mListener.afterTextChanged(s); 3249 } 3250 } 3251 3252 private boolean hasChanged() { 3253 String[] currRecips = tokenizeRecips(getAddressesFromList(mView)); 3254 int totalCount = currRecips.length; 3255 int totalPrevCount = 0; 3256 for (Entry<String, Integer> entry : mContent.entrySet()) { 3257 totalPrevCount += entry.getValue(); 3258 } 3259 if (totalCount != totalPrevCount) { 3260 return true; 3261 } 3262 3263 for (String recip : currRecips) { 3264 if (!mContent.containsKey(recip)) { 3265 return true; 3266 } else { 3267 int count = mContent.get(recip) - 1; 3268 if (count < 0) { 3269 return true; 3270 } else { 3271 mContent.put(recip, count); 3272 } 3273 } 3274 } 3275 return false; 3276 } 3277 3278 private String[] tokenizeRecips(String[] recips) { 3279 // Tokenize them all and put them in the list. 3280 String[] recipAddresses = new String[recips.length]; 3281 for (int i = 0; i < recips.length; i++) { 3282 recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress(); 3283 } 3284 return recipAddresses; 3285 } 3286 3287 @Override 3288 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 3289 String[] recips = tokenizeRecips(getAddressesFromList(mView)); 3290 for (String recip : recips) { 3291 if (!mContent.containsKey(recip)) { 3292 mContent.put(recip, 1); 3293 } else { 3294 mContent.put(recip, (mContent.get(recip)) + 1); 3295 } 3296 } 3297 } 3298 3299 @Override 3300 public void onTextChanged(CharSequence s, int start, int before, int count) { 3301 // Do nothing. 3302 } 3303 } 3304 3305 public static void registerTestSendOrSaveCallback(SendOrSaveCallback testCallback) { 3306 if (sTestSendOrSaveCallback != null && testCallback != null) { 3307 throw new IllegalStateException("Attempting to register more than one test callback"); 3308 } 3309 sTestSendOrSaveCallback = testCallback; 3310 } 3311 3312 @VisibleForTesting 3313 protected ArrayList<Attachment> getAttachments() { 3314 return mAttachmentsView.getAttachments(); 3315 } 3316 3317 @Override 3318 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 3319 switch (id) { 3320 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3321 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3322 null, null); 3323 case REFERENCE_MESSAGE_LOADER: 3324 return new CursorLoader(this, mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 3325 null, null); 3326 case LOADER_ACCOUNT_CURSOR: 3327 return new CursorLoader(this, MailAppProvider.getAccountsUri(), 3328 UIProvider.ACCOUNTS_PROJECTION, null, null, null); 3329 } 3330 return null; 3331 } 3332 3333 @Override 3334 public void onLoadFinished(Loader<Cursor> loader, Cursor data) { 3335 int id = loader.getId(); 3336 switch (id) { 3337 case INIT_DRAFT_USING_REFERENCE_MESSAGE: 3338 if (data != null && data.moveToFirst()) { 3339 mRefMessage = new Message(data); 3340 Intent intent = getIntent(); 3341 initFromRefMessage(mComposeMode); 3342 finishSetup(mComposeMode, intent, null); 3343 if (mComposeMode != FORWARD) { 3344 String to = intent.getStringExtra(EXTRA_TO); 3345 if (!TextUtils.isEmpty(to)) { 3346 mRefMessage.setTo(null); 3347 mRefMessage.setFrom(null); 3348 clearChangeListeners(); 3349 mTo.append(to); 3350 initChangeListeners(); 3351 } 3352 } 3353 } else { 3354 finish(); 3355 } 3356 break; 3357 case REFERENCE_MESSAGE_LOADER: 3358 // Only populate mRefMessage and leave other fields untouched. 3359 if (data != null && data.moveToFirst()) { 3360 mRefMessage = new Message(data); 3361 } 3362 finishSetup(mComposeMode, getIntent(), mInnerSavedState); 3363 break; 3364 case LOADER_ACCOUNT_CURSOR: 3365 if (data != null && data.moveToFirst()) { 3366 // there are accounts now! 3367 Account account; 3368 final ArrayList<Account> accounts = new ArrayList<Account>(); 3369 final ArrayList<Account> initializedAccounts = new ArrayList<Account>(); 3370 do { 3371 account = new Account(data); 3372 if (account.isAccountReady()) { 3373 initializedAccounts.add(account); 3374 } 3375 accounts.add(account); 3376 } while (data.moveToNext()); 3377 if (initializedAccounts.size() > 0) { 3378 findViewById(R.id.wait).setVisibility(View.GONE); 3379 getLoaderManager().destroyLoader(LOADER_ACCOUNT_CURSOR); 3380 findViewById(R.id.compose).setVisibility(View.VISIBLE); 3381 mAccounts = initializedAccounts.toArray( 3382 new Account[initializedAccounts.size()]); 3383 3384 finishCreate(); 3385 invalidateOptionsMenu(); 3386 } else { 3387 // Show "waiting" 3388 account = accounts.size() > 0 ? accounts.get(0) : null; 3389 showWaitFragment(account); 3390 } 3391 } 3392 break; 3393 } 3394 } 3395 3396 private void showWaitFragment(Account account) { 3397 WaitFragment fragment = getWaitFragment(); 3398 if (fragment != null) { 3399 fragment.updateAccount(account); 3400 } else { 3401 findViewById(R.id.wait).setVisibility(View.VISIBLE); 3402 replaceFragment(WaitFragment.newInstance(account, true), 3403 FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_WAIT); 3404 } 3405 } 3406 3407 private WaitFragment getWaitFragment() { 3408 return (WaitFragment) getFragmentManager().findFragmentByTag(TAG_WAIT); 3409 } 3410 3411 private int replaceFragment(Fragment fragment, int transition, String tag) { 3412 FragmentTransaction fragmentTransaction = getFragmentManager().beginTransaction(); 3413 fragmentTransaction.setTransition(transition); 3414 fragmentTransaction.replace(R.id.wait, fragment, tag); 3415 final int transactionId = fragmentTransaction.commitAllowingStateLoss(); 3416 return transactionId; 3417 } 3418 3419 @Override 3420 public void onLoaderReset(Loader<Cursor> arg0) { 3421 // Do nothing. 3422 } 3423 3424 @Override 3425 public Context getActivityContext() { 3426 return this; 3427 } 3428} 3429