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