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