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