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