ComposeActivity.java revision 818143e2163648acc0c91f123b84777d65cd70cf
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.ActivityManager; 21import android.app.AlertDialog; 22import android.app.Dialog; 23import android.app.ActionBar.OnNavigationListener; 24import android.app.Activity; 25import android.content.ContentResolver; 26import android.content.ContentValues; 27import android.content.Context; 28import android.content.DialogInterface; 29import android.content.Intent; 30import android.content.pm.ActivityInfo; 31import android.database.Cursor; 32import android.net.Uri; 33import android.os.Bundle; 34import android.os.Handler; 35import android.os.HandlerThread; 36import android.provider.BaseColumns; 37import android.provider.Settings; 38import android.text.Editable; 39import android.text.Html; 40import android.text.Spanned; 41import android.text.TextUtils; 42import android.text.TextWatcher; 43import android.text.util.Rfc822Token; 44import android.text.util.Rfc822Tokenizer; 45import android.view.LayoutInflater; 46import android.view.Menu; 47import android.view.MenuInflater; 48import android.view.MenuItem; 49import android.view.View; 50import android.view.ViewGroup; 51import android.view.View.OnClickListener; 52import android.view.inputmethod.BaseInputConnection; 53import android.widget.ArrayAdapter; 54import android.widget.Button; 55import android.widget.TextView; 56import android.widget.Toast; 57 58import com.android.common.Rfc822Validator; 59import com.android.mail.compose.AttachmentsView.AttachmentDeletedListener; 60import com.android.mail.compose.AttachmentsView.AttachmentFailureException; 61import com.android.mail.compose.FromAddressSpinner.OnAccountChangedListener; 62import com.android.mail.compose.QuotedTextView.RespondInlineListener; 63import com.android.mail.providers.Account; 64import com.android.mail.providers.Address; 65import com.android.mail.providers.Attachment; 66import com.android.mail.providers.MessageModification; 67import com.android.mail.providers.UIProvider; 68import com.android.mail.providers.UIProvider.MessageColumns; 69import com.android.mail.R; 70import com.android.mail.utils.LogUtils; 71import com.android.mail.utils.Utils; 72import com.android.ex.chips.RecipientEditTextView; 73import com.google.common.annotations.VisibleForTesting; 74import com.google.common.collect.Lists; 75import com.google.common.collect.Sets; 76 77import java.util.ArrayList; 78import java.util.Arrays; 79import java.util.Collection; 80import java.util.HashMap; 81import java.util.HashSet; 82import java.util.List; 83import java.util.Set; 84import java.util.Map.Entry; 85import java.util.concurrent.ConcurrentHashMap; 86 87public class ComposeActivity extends Activity implements OnClickListener, OnNavigationListener, 88 RespondInlineListener, DialogInterface.OnClickListener, TextWatcher, 89 AttachmentDeletedListener, OnAccountChangedListener { 90 // Identifiers for which type of composition this is 91 static final int COMPOSE = -1; // also used for editing a draft 92 static final int REPLY = 0; 93 static final int REPLY_ALL = 1; 94 static final int FORWARD = 2; 95 96 // Integer extra holding one of the above compose action 97 private static final String EXTRA_ACTION = "action"; 98 99 private static SendOrSaveCallback sTestSendOrSaveCallback = null; 100 // Map containing information about requests to create new messages, and the id of the 101 // messages that were the result of those requests. 102 // 103 // This map is used when the activity that initiated the save a of a new message, is killed 104 // before the save has completed (and when we know the id of the newly created message). When 105 // a save is completed, the service that is running in the background, will update the map 106 // 107 // When a new ComposeActivity instance is created, it will attempt to use the information in 108 // the previously instantiated map. If ComposeActivity.onCreate() is called, with a bundle 109 // (restoring data from a previous instance), and the map hasn't been created, we will attempt 110 // to populate the map with data stored in shared preferences. 111 private static ConcurrentHashMap<Integer, Long> sRequestMessageIdMap = null; 112 // Key used to store the above map 113 private static final String CACHED_MESSAGE_REQUEST_IDS_KEY = "cache-message-request-ids"; 114 /** 115 * Notifies the {@code Activity} that the caller is an Email 116 * {@code Activity}, so that the back behavior may be modified accordingly. 117 * 118 * @see #onAppUpPressed 119 */ 120 private static final String EXTRA_FROM_EMAIL_TASK = "fromemail"; 121 122 // If this is a reply/forward then this extra will hold the original message uri 123 private static final String EXTRA_IN_REFERENCE_TO_MESSAGE_URI = "in-reference-to-uri"; 124 private static final String END_TOKEN = ", "; 125 private static final String LOG_TAG = new LogUtils().getLogTag(); 126 // Request numbers for activities we start 127 private static final int RESULT_PICK_ATTACHMENT = 1; 128 private static final int RESULT_CREATE_ACCOUNT = 2; 129 130 /** 131 * A single thread for running tasks in the background. 132 */ 133 private Handler mSendSaveTaskHandler = null; 134 private RecipientEditTextView mTo; 135 private RecipientEditTextView mCc; 136 private RecipientEditTextView mBcc; 137 private Button mCcBccButton; 138 private CcBccView mCcBccView; 139 private AttachmentsView mAttachmentsView; 140 private Account mAccount; 141 private Rfc822Validator mValidator; 142 private Uri mRefMessageUri; 143 private TextView mSubject; 144 145 private ComposeModeAdapter mComposeModeAdapter; 146 private int mComposeMode = -1; 147 private boolean mForward; 148 private String mRecipient; 149 private QuotedTextView mQuotedTextView; 150 private TextView mBodyView; 151 private View mFromStatic; 152 private View mFromSpinnerWrapper; 153 private FromAddressSpinner mFromSpinner; 154 private boolean mAddingAttachment; 155 private boolean mAttachmentsChanged; 156 private boolean mTextChanged; 157 private boolean mReplyFromChanged; 158 private MenuItem mSave; 159 private MenuItem mSend; 160 private Object mDraftIdLock = new Object(); 161 private long mRefMessageId; 162 private AlertDialog mRecipientErrorDialog; 163 private AlertDialog mSendConfirmDialog; 164 /** 165 * Can be called from a non-UI thread. 166 */ 167 public static void editDraft(Context launcher, Account account, long localMessageId) { 168 } 169 170 /** 171 * Can be called from a non-UI thread. 172 */ 173 public static void compose(Context launcher, Account account) { 174 launch(launcher, account, null, COMPOSE); 175 } 176 177 /** 178 * Can be called from a non-UI thread. 179 */ 180 public static void reply(Context launcher, Account account, String uri) { 181 launch(launcher, account, uri, REPLY); 182 } 183 184 /** 185 * Can be called from a non-UI thread. 186 */ 187 public static void replyAll(Context launcher, Account account, String uri) { 188 launch(launcher, account, uri, REPLY_ALL); 189 } 190 191 /** 192 * Can be called from a non-UI thread. 193 */ 194 public static void forward(Context launcher, Account account, String uri) { 195 launch(launcher, account, uri, FORWARD); 196 } 197 198 private static void launch(Context launcher, Account account, String uri, int action) { 199 Intent intent = new Intent(launcher, ComposeActivity.class); 200 intent.putExtra(EXTRA_FROM_EMAIL_TASK, true); 201 intent.putExtra(EXTRA_ACTION, action); 202 intent.putExtra(Utils.EXTRA_ACCOUNT, account); 203 intent.putExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI, uri); 204 launcher.startActivity(intent); 205 } 206 207 @Override 208 public void onCreate(Bundle savedInstanceState) { 209 super.onCreate(savedInstanceState); 210 setContentView(R.layout.compose); 211 findViews(); 212 Intent intent = getIntent(); 213 setAccount((Account)intent.getParcelableExtra(Utils.EXTRA_ACCOUNT)); 214 if (mAccount == null) { 215 return; 216 } 217 int action = intent.getIntExtra(EXTRA_ACTION, COMPOSE); 218 if (action == REPLY || action == REPLY_ALL || action == FORWARD) { 219 mRefMessageUri = Uri.parse(intent.getStringExtra(EXTRA_IN_REFERENCE_TO_MESSAGE_URI)); 220 initFromRefMessage(action, mAccount.name); 221 } else { 222 mQuotedTextView.setVisibility(View.GONE); 223 } 224 initRecipients(); 225 initActionBar(action); 226 initFromSpinner(); 227 initChangeListeners(); 228 } 229 230 @Override 231 protected void onResume() { 232 super.onResume(); 233 // Update the from spinner as other accounts 234 // may now be available. 235 if (mFromSpinner != null && mAccount != null) { 236 mFromSpinner.asyncInitFromSpinner(); 237 } 238 } 239 240 @Override 241 protected void onPause() { 242 super.onPause(); 243 244 if (mSendConfirmDialog != null) { 245 mSendConfirmDialog.dismiss(); 246 } 247 if (mRecipientErrorDialog != null) { 248 mRecipientErrorDialog.dismiss(); 249 } 250 251 saveIfNeeded(); 252 } 253 254 @Override 255 protected final void onActivityResult(int request, int result, Intent data) { 256 mAddingAttachment = false; 257 258 if (result == RESULT_OK && request == RESULT_PICK_ATTACHMENT) { 259 addAttachmentAndUpdateView(data); 260 } 261 } 262 263 @Override 264 public final void onSaveInstanceState(Bundle state) { 265 super.onSaveInstanceState(state); 266 267 // onSaveInstanceState is only called if the user might come back to this activity so it is 268 // not an ideal location to save the draft. However, if we have never saved the draft before 269 // we have to save it here in order to have an id to save in the bundle. 270 saveIfNeededOnOrientationChanged(); 271 } 272 273 @VisibleForTesting 274 void setAccount(Account account) { 275 mAccount = account; 276 } 277 278 private void initFromSpinner() { 279 mFromSpinner.setCurrentAccount(mAccount); 280 mFromSpinner.asyncInitFromSpinner(); 281 boolean showSpinner = mFromSpinner.getCount() > 1; 282 // If there is only 1 account, just show that account. 283 // Otherwise, give the user the ability to choose which account to send 284 // mail from / save drafts to. 285 mFromStatic.setVisibility( 286 showSpinner ? View.GONE : View.VISIBLE); 287 mFromSpinnerWrapper.setVisibility( 288 showSpinner ? View.VISIBLE : View.GONE); 289 } 290 291 private void findViews() { 292 mCcBccButton = (Button) findViewById(R.id.add_cc_bcc); 293 if (mCcBccButton != null) { 294 mCcBccButton.setOnClickListener(this); 295 } 296 mCcBccView = (CcBccView) findViewById(R.id.cc_bcc_wrapper); 297 mAttachmentsView = (AttachmentsView)findViewById(R.id.attachments); 298 mTo = (RecipientEditTextView) findViewById(R.id.to); 299 mCc = (RecipientEditTextView) findViewById(R.id.cc); 300 mBcc = (RecipientEditTextView) findViewById(R.id.bcc); 301 // TODO: add special chips text change watchers before adding 302 // this as a text changed watcher to the to, cc, bcc fields. 303 mSubject = (TextView) findViewById(R.id.subject); 304 mQuotedTextView = (QuotedTextView) findViewById(R.id.quoted_text_view); 305 mQuotedTextView.setRespondInlineListener(this); 306 mBodyView = (TextView) findViewById(R.id.body); 307 mFromStatic = findViewById(R.id.static_from_content); 308 mFromSpinnerWrapper = findViewById(R.id.spinner_from_content); 309 mFromSpinner = (FromAddressSpinner) findViewById(R.id.from_picker); 310 } 311 312 // Now that the message has been initialized from any existing draft or 313 // ref message data, set up listeners for any changes that occur to the 314 // message. 315 private void initChangeListeners() { 316 mSubject.addTextChangedListener(this); 317 mBodyView.addTextChangedListener(this); 318 mTo.addTextChangedListener(new RecipientTextWatcher(mTo, this)); 319 mCc.addTextChangedListener(new RecipientTextWatcher(mCc, this)); 320 mBcc.addTextChangedListener(new RecipientTextWatcher(mBcc, this)); 321 mFromSpinner.setOnAccountChangedListener(this); 322 mAttachmentsView.setAttachmentChangesListener(this); 323 } 324 325 private void initActionBar(int action) { 326 mComposeMode = action; 327 ActionBar actionBar = getActionBar(); 328 if (action == ComposeActivity.COMPOSE) { 329 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); 330 actionBar.setTitle(R.string.compose); 331 } else { 332 actionBar.setTitle(null); 333 if (mComposeModeAdapter == null) { 334 mComposeModeAdapter = new ComposeModeAdapter(this); 335 } 336 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); 337 actionBar.setListNavigationCallbacks(mComposeModeAdapter, this); 338 switch (action) { 339 case ComposeActivity.REPLY: 340 actionBar.setSelectedNavigationItem(0); 341 break; 342 case ComposeActivity.REPLY_ALL: 343 actionBar.setSelectedNavigationItem(1); 344 break; 345 case ComposeActivity.FORWARD: 346 actionBar.setSelectedNavigationItem(2); 347 break; 348 } 349 } 350 } 351 352 private void initFromRefMessage(int action, String recipientAddress) { 353 ContentResolver resolver = getContentResolver(); 354 Cursor refMessage = resolver.query(mRefMessageUri, UIProvider.MESSAGE_PROJECTION, null, 355 null, null); 356 if (refMessage != null) { 357 try { 358 refMessage.moveToFirst(); 359 mRefMessageId = refMessage.getLong(UIProvider.MESSAGE_ID_COLUMN); 360 setSubject(refMessage, action); 361 // Setup recipients 362 if (action == FORWARD) { 363 mForward = true; 364 } 365 initRecipientsFromRefMessageCursor(recipientAddress, refMessage, action); 366 initBodyFromRefMessage(refMessage, action); 367 if (action == ComposeActivity.FORWARD || mAttachmentsChanged) { 368 initAttachments(refMessage); 369 } 370 updateHideOrShowCcBcc(); 371 } finally { 372 refMessage.close(); 373 } 374 } 375 } 376 377 private void initAttachments(Cursor refMessage) { 378 mAttachmentsView.addAttachments(mAccount, refMessage); 379 } 380 381 private void initBodyFromRefMessage(Cursor refMessage, int action) { 382 if (action == REPLY || action == REPLY_ALL || action == FORWARD) { 383 mQuotedTextView.setQuotedText(action, refMessage, action != FORWARD); 384 } 385 } 386 387 private void updateHideOrShowCcBcc() { 388 // Its possible there is a menu item OR a button. 389 boolean ccVisible = !TextUtils.isEmpty(mCc.getText()); 390 boolean bccVisible = !TextUtils.isEmpty(mBcc.getText()); 391 if (ccVisible || bccVisible) { 392 mCcBccView.show(false, ccVisible, bccVisible); 393 } 394 if (mCcBccButton != null) { 395 if (!mCc.isShown() || !mBcc.isShown()) { 396 mCcBccButton.setVisibility(View.VISIBLE); 397 mCcBccButton.setText(getString(!mCc.isShown() ? R.string.add_cc_label 398 : R.string.add_bcc_label)); 399 } else { 400 mCcBccButton.setVisibility(View.GONE); 401 } 402 } 403 } 404 405 /** 406 * Add attachment and update the compose area appropriately. 407 * @param data 408 */ 409 public void addAttachmentAndUpdateView(Intent data) { 410 Uri uri = data != null ? data.getData() : null; 411 try { 412 long size = mAttachmentsView.addAttachment(mAccount, uri, false /* doSave */); 413 if (size > 0) { 414 mAttachmentsChanged = true; 415 updateSaveUi(); 416 } 417 } catch (AttachmentFailureException e) { 418 // A toast has already been shown to the user, no need to do 419 // anything. 420 LogUtils.e(LOG_TAG, e, "Error adding attachment"); 421 } 422 } 423 424 void initRecipientsFromRefMessageCursor(String recipientAddress, Cursor refMessage, 425 int action) { 426 // Don't populate the address if this is a forward. 427 if (action == ComposeActivity.FORWARD) { 428 return; 429 } 430 initReplyRecipients(mAccount.name, refMessage, action); 431 } 432 433 @VisibleForTesting 434 void initReplyRecipients(String account, Cursor refMessage, int action) { 435 // This is the email address of the current user, i.e. the one composing 436 // the reply. 437 final String accountEmail = Address.getEmailAddress(account).getAddress(); 438 String fromAddress = refMessage.getString(UIProvider.MESSAGE_FROM_COLUMN); 439 String[] sentToAddresses = Utils.splitCommaSeparatedString(refMessage 440 .getString(UIProvider.MESSAGE_TO_COLUMN)); 441 String[] replytoAddresses = Utils.splitCommaSeparatedString(refMessage 442 .getString(UIProvider.MESSAGE_REPLY_TO_COLUMN)); 443 final Collection<String> toAddresses; 444 445 // If this is a reply, the Cc list is empty. If this is a reply-all, the 446 // Cc list is the union of the To and Cc recipients of the original 447 // message, excluding the current user's email address and any addresses 448 // already on the To list. 449 if (action == ComposeActivity.REPLY) { 450 toAddresses = initToRecipients(account, accountEmail, fromAddress, 451 replytoAddresses, new String[0]); 452 addToAddresses(toAddresses); 453 } else if (action == ComposeActivity.REPLY_ALL) { 454 final Set<String> ccAddresses = Sets.newHashSet(); 455 toAddresses = initToRecipients(account, accountEmail, fromAddress, 456 replytoAddresses, new String[0]); 457 addToAddresses(toAddresses); 458 addRecipients(accountEmail, ccAddresses, sentToAddresses); 459 addRecipients(accountEmail, ccAddresses, Utils.splitCommaSeparatedString(refMessage 460 .getString(UIProvider.MESSAGE_CC_COLUMN))); 461 addCcAddresses(ccAddresses, toAddresses); 462 } 463 } 464 465 private void addToAddresses(Collection<String> addresses) { 466 addAddressesToList(addresses, mTo); 467 } 468 469 private void addCcAddresses(Collection<String> addresses, Collection<String> toAddresses) { 470 addCcAddressesToList(tokenizeAddressList(addresses), tokenizeAddressList(toAddresses), 471 mCc); 472 } 473 474 @VisibleForTesting 475 protected void addCcAddressesToList(List<Rfc822Token[]> addresses, 476 List<Rfc822Token[]> compareToList, RecipientEditTextView list) { 477 String address; 478 479 HashSet<String> compareTo = convertToHashSet(compareToList); 480 for (Rfc822Token[] tokens : addresses) { 481 for (int i = 0; i < tokens.length; i++) { 482 address = tokens[i].toString(); 483 // Check if this is a duplicate: 484 if (!compareTo.contains(tokens[i].getAddress())) { 485 // Get the address here 486 list.append(address + END_TOKEN); 487 } 488 } 489 } 490 } 491 492 private HashSet<String> convertToHashSet(List<Rfc822Token[]> list) { 493 HashSet<String> hash = new HashSet<String>(); 494 for (Rfc822Token[] tokens : list) { 495 for (int i = 0; i < tokens.length; i++) { 496 hash.add(tokens[i].getAddress()); 497 } 498 } 499 return hash; 500 } 501 502 protected List<Rfc822Token[]> tokenizeAddressList(Collection<String> addresses) { 503 @VisibleForTesting 504 List<Rfc822Token[]> tokenized = new ArrayList<Rfc822Token[]>(); 505 506 for (String address: addresses) { 507 tokenized.add(Rfc822Tokenizer.tokenize(address)); 508 } 509 return tokenized; 510 } 511 512 @VisibleForTesting 513 void addAddressesToList(Collection<String> addresses, RecipientEditTextView list) { 514 for (String address : addresses) { 515 addAddressToList(address, list); 516 } 517 } 518 519 private void addAddressToList(String address, RecipientEditTextView list) { 520 if (address == null || list == null) 521 return; 522 523 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(address); 524 525 for (int i = 0; i < tokens.length; i++) { 526 list.append(tokens[i] + END_TOKEN); 527 } 528 } 529 530 @VisibleForTesting 531 protected Collection<String> initToRecipients(String account, String accountEmail, 532 String senderAddress, String[] replyToAddresses, String[] inToAddresses) { 533 // The To recipient is the reply-to address specified in the original 534 // message, unless it is: 535 // the current user OR a custom from of the current user, in which case 536 // it's the To recipient list of the original message. 537 // OR missing, in which case use the sender of the original message 538 Set<String> toAddresses = Sets.newHashSet(); 539 Address sender = Address.getEmailAddress(senderAddress); 540 if (sender != null && sender.getAddress().equalsIgnoreCase(account)) { 541 // The sender address is this account, so reply acts like reply all. 542 toAddresses.addAll(Arrays.asList(inToAddresses)); 543 } else if (replyToAddresses != null && replyToAddresses.length != 0) { 544 toAddresses.addAll(Arrays.asList(replyToAddresses)); 545 } else { 546 // Check to see if the sender address is one of the user's custom 547 // from addresses. 548 if (senderAddress != null && sender != null 549 && !accountEmail.equalsIgnoreCase(sender.getAddress())) { 550 // Replying to the sender of the original message is the most 551 // common case. 552 toAddresses.add(senderAddress); 553 } else { 554 // This happens if the user replies to a message they originally 555 // wrote. In this case, "reply" really means "re-send," so we 556 // target the original recipients. This works as expected even 557 // if the user sent the original message to themselves. 558 toAddresses.addAll(Arrays.asList(inToAddresses)); 559 } 560 } 561 return toAddresses; 562 } 563 564 private static void addRecipients(String account, Set<String> recipients, String[] addresses) { 565 for (String email : addresses) { 566 // Do not add this account, or any of the custom froms, to the list 567 // of recipients. 568 final String recipientAddress = Address.getEmailAddress(email).getAddress(); 569 if (!account.equalsIgnoreCase(recipientAddress)) { 570 recipients.add(email.replace("\"\"", "")); 571 } 572 } 573 } 574 575 private void setSubject(Cursor refMessage, int action) { 576 String subject = refMessage.getString(UIProvider.MESSAGE_SUBJECT_COLUMN); 577 String prefix; 578 String correctedSubject = null; 579 if (action == ComposeActivity.COMPOSE) { 580 prefix = ""; 581 } else if (action == ComposeActivity.FORWARD) { 582 prefix = getString(R.string.forward_subject_label); 583 } else { 584 prefix = getString(R.string.reply_subject_label); 585 } 586 587 // Don't duplicate the prefix 588 if (subject.toLowerCase().startsWith(prefix.toLowerCase())) { 589 correctedSubject = subject; 590 } else { 591 correctedSubject = String 592 .format(getString(R.string.formatted_subject), prefix, subject); 593 } 594 mSubject.setText(correctedSubject); 595 } 596 597 private void initRecipients() { 598 setupRecipients(mTo); 599 setupRecipients(mCc); 600 setupRecipients(mBcc); 601 } 602 603 private void setupRecipients(RecipientEditTextView view) { 604 String accountName = mAccount.name; 605 view.setAdapter(new RecipientAdapter(this, accountName)); 606 view.setTokenizer(new Rfc822Tokenizer()); 607 if (mValidator == null) { 608 int offset = accountName.indexOf("@") + 1; 609 String account = accountName; 610 if (offset > -1) { 611 account = account.substring(accountName.indexOf("@") + 1); 612 } 613 mValidator = new Rfc822Validator(account); 614 } 615 view.setValidator(mValidator); 616 } 617 618 @Override 619 public void onClick(View v) { 620 int id = v.getId(); 621 switch (id) { 622 case R.id.add_cc_bcc: 623 // Verify that cc/ bcc aren't showing. 624 // Animate in cc/bcc. 625 showCcBccViews(); 626 break; 627 } 628 } 629 630 @Override 631 public boolean onCreateOptionsMenu(Menu menu) { 632 super.onCreateOptionsMenu(menu); 633 MenuInflater inflater = getMenuInflater(); 634 inflater.inflate(R.menu.compose_menu, menu); 635 mSave = menu.findItem(R.id.save); 636 mSend = menu.findItem(R.id.send); 637 return true; 638 } 639 640 @Override 641 public boolean onPrepareOptionsMenu(Menu menu) { 642 MenuItem ccBcc = menu.findItem(R.id.add_cc_bcc); 643 if (ccBcc != null && mCc != null) { 644 // Its possible there is a menu item OR a button. 645 boolean ccFieldVisible = mCc.isShown(); 646 boolean bccFieldVisible = mBcc.isShown(); 647 if (!ccFieldVisible || !bccFieldVisible) { 648 ccBcc.setVisible(true); 649 ccBcc.setTitle(getString(!ccFieldVisible ? R.string.add_cc_label 650 : R.string.add_bcc_label)); 651 } else { 652 ccBcc.setVisible(false); 653 } 654 } 655 if (mSave != null) { 656 mSave.setEnabled(shouldSave()); 657 } 658 return true; 659 } 660 661 @Override 662 public boolean onOptionsItemSelected(MenuItem item) { 663 int id = item.getItemId(); 664 boolean handled = true; 665 switch (id) { 666 case R.id.add_attachment: 667 doAttach(); 668 break; 669 case R.id.add_cc_bcc: 670 showCcBccViews(); 671 break; 672 case R.id.save: 673 doSave(true, false); 674 break; 675 case R.id.send: 676 doSend(); 677 break; 678 default: 679 handled = false; 680 break; 681 } 682 return !handled ? super.onOptionsItemSelected(item) : handled; 683 } 684 685 private void doSend() { 686 sendOrSaveWithSanityChecks(false, true, false); 687 } 688 689 private void doSave(boolean showToast, boolean resetIME) { 690 sendOrSaveWithSanityChecks(true, showToast, false); 691 if (resetIME) { 692 // Clear the IME composing suggestions from the body. 693 BaseInputConnection.removeComposingSpans(mBodyView.getEditableText()); 694 } 695 } 696 697 /*package*/ interface SendOrSaveCallback { 698 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask); 699 public void notifyMessageIdAllocated(SendOrSaveMessage message, long messageId); 700 public long getMessageId(); 701 public void sendOrSaveFinished(SendOrSaveTask sendOrSaveTask, boolean success); 702 } 703 704 /*package*/ static class SendOrSaveTask implements Runnable { 705 private final Context mContext; 706 private final SendOrSaveCallback mSendOrSaveCallback; 707 @VisibleForTesting 708 final SendOrSaveMessage mSendOrSaveMessage; 709 710 public SendOrSaveTask(Context context, SendOrSaveMessage message, 711 SendOrSaveCallback callback) { 712 mContext = context; 713 mSendOrSaveCallback = callback; 714 mSendOrSaveMessage = message; 715 } 716 717 @Override 718 public void run() { 719 final SendOrSaveMessage message = mSendOrSaveMessage; 720 721 final Account selectedAccount = message.mSelectedAccount; 722 long messageId = mSendOrSaveCallback.getMessageId(); 723 // If a previous draft has been saved, in an account that is different 724 // than what the user wants to send from, remove the old draft, and treat this 725 // as a new message 726 if (!selectedAccount.equals(message.mAccount)) { 727 if (messageId != UIProvider.INVALID_MESSAGE_ID) { 728 ContentResolver resolver = mContext.getContentResolver(); 729 ContentValues values = new ContentValues(); 730 values.put(BaseColumns._ID, messageId); 731 if (!TextUtils.isEmpty(selectedAccount.expungeMessageUri)) { 732 resolver.update(Uri.parse(selectedAccount.expungeMessageUri), values, null, 733 null); 734 } 735 // reset messageId to 0, so a new message will be created 736 messageId = UIProvider.INVALID_MESSAGE_ID; 737 } 738 } 739 740 final long messageIdToSave = messageId; 741 int newDraftId = -1; 742 if (messageIdToSave != UIProvider.INVALID_MESSAGE_ID) { 743 mContext.getContentResolver().update( 744 Uri.parse(message.mSave ? selectedAccount.saveDraftUri 745 : selectedAccount.sendMessageUri), message.mValues, null, null); 746 } else { 747 newDraftId = mContext.getContentResolver().update( 748 Uri.parse(message.mSave ? selectedAccount.saveDraftUri 749 : selectedAccount.sendMessageUri), message.mValues, null, null); 750 751 // Broadcast notification that a new message id has been 752 // allocated 753 mSendOrSaveCallback.notifyMessageIdAllocated(message, newDraftId); 754 } 755 756 if (!message.mSave) { 757 UIProvider.incrementRecipientsTimesContacted(mContext, 758 (String) message.mValues.get(UIProvider.MessageColumns.TO)); 759 UIProvider.incrementRecipientsTimesContacted(mContext, 760 (String) message.mValues.get(UIProvider.MessageColumns.CC)); 761 UIProvider.incrementRecipientsTimesContacted(mContext, 762 (String) message.mValues.get(UIProvider.MessageColumns.BCC)); 763 } 764 mSendOrSaveCallback.sendOrSaveFinished(SendOrSaveTask.this, true); 765 } 766 } 767 768 // Array of the outstanding send or save tasks. Access is synchronized 769 // with the object itself 770 /* package for testing */ 771 ArrayList<SendOrSaveTask> mActiveTasks = Lists.newArrayList(); 772 private int mRequestId; 773 private long mDraftId; 774 775 /*package*/ static class SendOrSaveMessage { 776 final Account mAccount; 777 final Account mSelectedAccount; 778 final ContentValues mValues; 779 final long mRefMessageId; 780 final boolean mSave; 781 final int mRequestId; 782 783 public SendOrSaveMessage(Account account, Account selectedAccount, ContentValues values, 784 long refMessageId, boolean save) { 785 mAccount = account; 786 mSelectedAccount = selectedAccount; 787 mValues = values; 788 mRefMessageId = refMessageId; 789 mSave = save; 790 mRequestId = mValues.hashCode() ^ hashCode(); 791 } 792 793 int requestId() { 794 return mRequestId; 795 } 796 } 797 798 /** 799 * Get the to recipients. 800 */ 801 public String[] getToAddresses() { 802 return getAddressesFromList(mTo); 803 } 804 805 /** 806 * Get the cc recipients. 807 */ 808 public String[] getCcAddresses() { 809 return getAddressesFromList(mCc); 810 } 811 812 /** 813 * Get the bcc recipients. 814 */ 815 public String[] getBccAddresses() { 816 return getAddressesFromList(mBcc); 817 } 818 819 public String[] getAddressesFromList(RecipientEditTextView list) { 820 if (list == null) { 821 return new String[0]; 822 } 823 Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(list.getText()); 824 int count = tokens.length; 825 String[] result = new String[count]; 826 for (int i = 0; i < count; i++) { 827 result[i] = tokens[i].toString(); 828 } 829 return result; 830 } 831 832 /** 833 * Check for invalid email addresses. 834 * @param to String array of email addresses to check. 835 * @param wrongEmailsOut Emails addresses that were invalid. 836 */ 837 public void checkInvalidEmails(String[] to, List<String> wrongEmailsOut) { 838 for (String email : to) { 839 if (!mValidator.isValid(email)) { 840 wrongEmailsOut.add(email); 841 } 842 } 843 } 844 845 /** 846 * Show an error because the user has entered an invalid recipient. 847 * @param message 848 */ 849 public void showRecipientErrorDialog(String message) { 850 // Only 1 invalid recipients error dialog should be allowed up at a 851 // time. 852 if (mRecipientErrorDialog != null) { 853 mRecipientErrorDialog.dismiss(); 854 } 855 mRecipientErrorDialog = new AlertDialog.Builder(this).setMessage(message).setTitle( 856 R.string.recipient_error_dialog_title) 857 .setIconAttribute(android.R.attr.alertDialogIcon) 858 .setCancelable(false) 859 .setPositiveButton( 860 R.string.ok, new Dialog.OnClickListener() { 861 public void onClick(DialogInterface dialog, int which) { 862 // after the user dismisses the recipient error 863 // dialog we want to make sure to refocus the 864 // recipient to field so they can fix the issue 865 // easily 866 if (mTo != null) { 867 mTo.requestFocus(); 868 } 869 mRecipientErrorDialog = null; 870 } 871 }).show(); 872 } 873 874 /** 875 * Update the state of the UI based on whether or not the current draft 876 * needs to be saved and the message is not empty. 877 */ 878 public void updateSaveUi() { 879 if (mSave != null) { 880 mSave.setEnabled((shouldSave() && !isBlank())); 881 } 882 } 883 884 /** 885 * Returns true if we need to save the current draft. 886 */ 887 private boolean shouldSave() { 888 synchronized (mDraftIdLock) { 889 // The message should only be saved if: 890 // It hasn't been sent AND 891 // Some text has been added to the message OR 892 // an attachment has been added or removed 893 return (mTextChanged || mAttachmentsChanged || 894 (mReplyFromChanged && !isBlank())); 895 } 896 } 897 898 /** 899 * Check if the ComposeArea believes all fields are blank. 900 * @return boolean 901 */ 902 public boolean isBlank() { 903 return mSubject.getText().length() == 0 904 && mBodyView.getText().length() == 0 905 && mTo.length() == 0 906 && mCc.length() == 0 907 && mBcc.length() == 0 908 && mAttachmentsView.getAttachments().size() == 0; 909 } 910 911 /** 912 * Allows any changes made by the user to be ignored. Called when the user 913 * decides to discard a draft. 914 */ 915 private void discardChanges() { 916 mTextChanged = false; 917 mAttachmentsChanged = false; 918 mReplyFromChanged = false; 919 } 920 921 /** 922 * 923 * @param body 924 * @param save 925 * @param showToast 926 * @return Whether the send or save succeeded. 927 */ 928 protected boolean sendOrSaveWithSanityChecks(final boolean save, 929 final boolean showToast, final boolean orientationChanged) { 930 String[] to, cc, bcc; 931 Editable body = mBodyView.getEditableText(); 932 933 if (orientationChanged) { 934 to = cc = bcc = new String[0]; 935 } else { 936 to = getToAddresses(); 937 cc = getCcAddresses(); 938 bcc = getBccAddresses(); 939 } 940 941 // Don't let the user send to nobody (but it's okay to save a message with no recipients) 942 if (!save && (to.length == 0 && cc.length == 0 && bcc.length == 0)) { 943 showRecipientErrorDialog(getString(R.string.recipient_needed)); 944 return false; 945 } 946 947 List<String> wrongEmails = new ArrayList<String>(); 948 if (!save) { 949 checkInvalidEmails(to, wrongEmails); 950 checkInvalidEmails(cc, wrongEmails); 951 checkInvalidEmails(bcc, wrongEmails); 952 } 953 954 // Don't let the user send an email with invalid recipients 955 if (wrongEmails.size() > 0) { 956 String errorText = 957 String.format(getString(R.string.invalid_recipient), wrongEmails.get(0)); 958 showRecipientErrorDialog(errorText); 959 return false; 960 } 961 962 DialogInterface.OnClickListener listener = new DialogInterface.OnClickListener() { 963 public void onClick(DialogInterface dialog, int which) { 964 sendOrSave(mBodyView.getEditableText(), save, showToast, orientationChanged); 965 } 966 }; 967 968 // Show a warning before sending only if there are no attachments. 969 if (!save) { 970 if (mAttachmentsView.getAttachments().isEmpty() && showEmptyTextWarnings()) { 971 boolean warnAboutEmptySubject = isSubjectEmpty(); 972 boolean emptyBody = TextUtils.getTrimmedLength(body) == 0; 973 974 // A warning about an empty body may not be warranted when 975 // forwarding mails, since a common use case is to forward 976 // quoted text and not append any more text. 977 boolean warnAboutEmptyBody = emptyBody && (!mForward || isBodyEmpty()); 978 979 // When we bring up a dialog warning the user about a send, 980 // assume that they accept sending the message. If they do not, the dialog 981 // listener is required to enable sending again. 982 if (warnAboutEmptySubject) { 983 showSendConfirmDialog(R.string.confirm_send_message_with_no_subject, listener); 984 return true; 985 } 986 987 if (warnAboutEmptyBody) { 988 showSendConfirmDialog(R.string.confirm_send_message_with_no_body, listener); 989 return true; 990 } 991 } 992 // Ask for confirmation to send (if always required) 993 if (showSendConfirmation()) { 994 showSendConfirmDialog(R.string.confirm_send_message, listener); 995 return true; 996 } 997 } 998 999 sendOrSave(body, save, showToast, false); 1000 return true; 1001 } 1002 1003 /** 1004 * Returns a boolean indicating whether warnings should be shown for empty 1005 * subject and body fields 1006 * 1007 * @return True if a warning should be shown for empty text fields 1008 */ 1009 protected boolean showEmptyTextWarnings() { 1010 return mAttachmentsView.getAttachments().size() == 0; 1011 } 1012 1013 /** 1014 * Returns a boolean indicating whether the user should confirm each send 1015 * 1016 * @return True if a warning should be on each send 1017 */ 1018 protected boolean showSendConfirmation() { 1019 // TODO: read user preference for whether or not to show confirm send dialog. 1020 return true; 1021 } 1022 1023 private void showSendConfirmDialog(int messageId, DialogInterface.OnClickListener listener) { 1024 if (mSendConfirmDialog != null) { 1025 mSendConfirmDialog.dismiss(); 1026 mSendConfirmDialog = null; 1027 } 1028 mSendConfirmDialog = new AlertDialog.Builder(this) 1029 .setMessage(messageId) 1030 .setTitle(R.string.confirm_send_title) 1031 .setIconAttribute(android.R.attr.alertDialogIcon) 1032 .setPositiveButton(R.string.send, listener) 1033 .setNegativeButton(R.string.cancel, this) 1034 .setCancelable(false) 1035 .show(); 1036 } 1037 1038 /** 1039 * Returns whether the ComposeArea believes there is any text in the body of 1040 * the composition. TODO: When ComposeArea controls the Body as well, add 1041 * that here. 1042 */ 1043 public boolean isBodyEmpty() { 1044 return !mQuotedTextView.isTextIncluded(); 1045 } 1046 1047 /** 1048 * Test to see if the subject is empty. 1049 * @return boolean. 1050 */ 1051 // TODO: this will likely go away when composeArea.focus() is implemented 1052 // after all the widget control is moved over. 1053 public boolean isSubjectEmpty() { 1054 return TextUtils.getTrimmedLength(mSubject.getText()) == 0; 1055 } 1056 1057 /* package */ 1058 static int sendOrSaveInternal(Context context, final Account account, 1059 final Account selectedAccount, String fromAddress, final Spanned body, final String[] to, 1060 final String[] cc, final String[] bcc, final String subject, 1061 final CharSequence quotedText, final List<Attachment> attachments, 1062 final long refMessageId, SendOrSaveCallback callback, Handler handler, boolean save, 1063 boolean forward) { 1064 ContentValues values = new ContentValues(); 1065 1066 MessageModification.putToAddresses(values, to); 1067 MessageModification.putCcAddresses(values, cc); 1068 MessageModification.putBccAddresses(values, bcc); 1069 1070 MessageModification.putSubject(values, subject); 1071 String htmlBody = Html.toHtml(body); 1072 boolean includeQuotedText = !TextUtils.isEmpty(quotedText); 1073 StringBuilder fullBody = new StringBuilder(htmlBody); 1074 if (includeQuotedText) { 1075 if (forward) { 1076 // forwarded messages get full text in HTML from client 1077 fullBody.append(quotedText); 1078 MessageModification.putForward(values, forward); 1079 } else { 1080 // replies get full quoted text from server - HTMl gets converted to text for now 1081 final String text = quotedText.toString(); 1082 if (QuotedTextView.containsQuotedText(text)) { 1083 int pos = QuotedTextView.getQuotedTextOffset(text); 1084 fullBody.append(text.substring(0, pos)); 1085 int quoteStartPos = fullBody.length(); 1086 MessageModification.putForward(values, forward); 1087 MessageModification.putIncludeQuotedText(values, includeQuotedText); 1088 MessageModification.putQuoteStartPos(values, quoteStartPos); 1089 } else { 1090 LogUtils.w(LOG_TAG, "Couldn't find quoted text"); 1091 // This shouldn't happen, but just use what we have, 1092 // and don't do server-side expansion 1093 fullBody.append(text); 1094 } 1095 } 1096 } 1097 MessageModification.putBody(values, fullBody.toString()); 1098 1099 SendOrSaveMessage sendOrSaveMessage = new SendOrSaveMessage(account, selectedAccount, 1100 values, refMessageId, save); 1101 SendOrSaveTask sendOrSaveTask = new SendOrSaveTask(context, sendOrSaveMessage, callback); 1102 1103 callback.initializeSendOrSave(sendOrSaveTask); 1104 1105 // Do the send/save action on the specified handler to avoid possible ANRs 1106 handler.post(sendOrSaveTask); 1107 1108 return sendOrSaveMessage.requestId(); 1109 } 1110 1111 private void sendOrSave(Spanned body, boolean save, boolean showToast, 1112 boolean orientationChanged) { 1113 // Check if user is a monkey. Monkeys can compose and hit send 1114 // button but are not allowed to send anything off the device. 1115 if (!save && ActivityManager.isUserAMonkey()) { 1116 return; 1117 } 1118 1119 String[] to, cc, bcc; 1120 if (orientationChanged) { 1121 to = cc = bcc = new String[0]; 1122 } else { 1123 to = getToAddresses(); 1124 cc = getCcAddresses(); 1125 bcc = getBccAddresses(); 1126 } 1127 1128 1129 SendOrSaveCallback callback = new SendOrSaveCallback() { 1130 private long mDraftId; 1131 private int mRestoredRequestId; 1132 1133 public void initializeSendOrSave(SendOrSaveTask sendOrSaveTask) { 1134 synchronized(mActiveTasks) { 1135 int numTasks = mActiveTasks.size(); 1136 if (numTasks == 0) { 1137 // Start service so we won't be killed if this app is put in the 1138 // background. 1139 startService(new Intent(ComposeActivity.this, EmptyService.class)); 1140 } 1141 1142 mActiveTasks.add(sendOrSaveTask); 1143 } 1144 if (sTestSendOrSaveCallback != null) { 1145 sTestSendOrSaveCallback.initializeSendOrSave(sendOrSaveTask); 1146 } 1147 } 1148 1149 public void notifyMessageIdAllocated(SendOrSaveMessage message, long messageId) { 1150 synchronized(mDraftIdLock) { 1151 mDraftId = messageId; 1152 sRequestMessageIdMap.put(message.requestId(), messageId); 1153 1154 // Cache request message map, in case the process is killed 1155 saveRequestMap(); 1156 } 1157 if (sTestSendOrSaveCallback != null) { 1158 sTestSendOrSaveCallback.notifyMessageIdAllocated(message, messageId); 1159 } 1160 } 1161 1162 public long getMessageId() { 1163 synchronized(mDraftIdLock) { 1164 if (mDraftId == UIProvider.INVALID_MESSAGE_ID) { 1165 // We don't have the message Id, check to see if we have a restored 1166 // request id, and see if we have a message for that request. 1167 if (mRestoredRequestId != 0) { 1168 Long retrievedMessageId = 1169 sRequestMessageIdMap.get(mRestoredRequestId); 1170 if (retrievedMessageId != null) { 1171 mDraftId = retrievedMessageId.longValue(); 1172 } 1173 } 1174 } 1175 return mDraftId; 1176 } 1177 } 1178 1179 public void sendOrSaveFinished(SendOrSaveTask task, boolean success) { 1180 if (success) { 1181 // Successfully sent or saved so reset change markers 1182 discardChanges(); 1183 } else { 1184 // A failure happened with saving/sending the draft 1185 // TODO(pwestbro): add a better string that should be used when failing to 1186 // send or save 1187 Toast.makeText(ComposeActivity.this, R.string.send_failed, 1188 Toast.LENGTH_SHORT).show(); 1189 } 1190 1191 int numTasks; 1192 synchronized(mActiveTasks) { 1193 // Remove the task from the list of active tasks 1194 mActiveTasks.remove(task); 1195 numTasks = mActiveTasks.size(); 1196 } 1197 1198 if (numTasks == 0) { 1199 // Stop service so we can be killed. 1200 stopService(new Intent(ComposeActivity.this, EmptyService.class)); 1201 } 1202 if (sTestSendOrSaveCallback != null) { 1203 sTestSendOrSaveCallback.sendOrSaveFinished(task, success); 1204 } 1205 } 1206 }; 1207 1208 // Get the selected account if the from spinner has been setup. 1209 Account selectedAccount = mAccount; 1210 String fromAddress = selectedAccount.name; 1211 if (selectedAccount == null || fromAddress == null) { 1212 // We don't have either the selected account or from address, 1213 // use mAccount. 1214 selectedAccount = mAccount; 1215 fromAddress = mAccount.name; 1216 } 1217 1218 if (mSendSaveTaskHandler == null) { 1219 HandlerThread handlerThread = new HandlerThread("Send Message Task Thread"); 1220 handlerThread.start(); 1221 1222 mSendSaveTaskHandler = new Handler(handlerThread.getLooper()); 1223 } 1224 1225 mRequestId = sendOrSaveInternal(this, mAccount, selectedAccount, fromAddress, body, 1226 to, cc, bcc, mSubject.getText().toString(), mQuotedTextView.getQuotedText(), 1227 mAttachmentsView.getAttachments(), mRefMessageId, callback, mSendSaveTaskHandler, 1228 save, mForward); 1229 1230 if (mRecipient != null && mRecipient.equals(mAccount.name)) { 1231 mRecipient = selectedAccount.name; 1232 } 1233 mAccount = selectedAccount; 1234 1235 // Don't display the toast if the user is just changing the orientation, but we still 1236 // need to save the draft to the cursor because this is how we restore the attachments 1237 // when the configuration change completes. 1238 if (showToast && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 1239 Toast.makeText(this, save ? R.string.message_saved : R.string.sending_message, 1240 Toast.LENGTH_LONG).show(); 1241 } 1242 1243 // Need to update variables here 1244 // because the send or save completes asynchronously even though the 1245 // toast shows right away. 1246 discardChanges(); 1247 updateSaveUi(); 1248 1249 // If we are sending, finish the activity 1250 if (!save) { 1251 finish(); 1252 } 1253 } 1254 1255 /** 1256 * Save the state of the request messageid map. This allows for the Gmail process 1257 * to be killed, but and still allow for ComposeActivity instances to be recreated 1258 * correctly. 1259 */ 1260 private void saveRequestMap() { 1261 // TODO: store the request map in user preferences. 1262 } 1263 1264 public void doAttach() { 1265 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 1266 i.addCategory(Intent.CATEGORY_OPENABLE); 1267 if (Settings.System.getInt( 1268 getContentResolver(), UIProvider.getAttachmentTypeSetting(), 0) != 0) { 1269 i.setType("*/*"); 1270 } else { 1271 i.setType("image/*"); 1272 } 1273 mAddingAttachment = true; 1274 startActivityForResult(Intent.createChooser(i, 1275 getText(R.string.select_attachment_type)), RESULT_PICK_ATTACHMENT); 1276 } 1277 1278 private void showCcBccViews() { 1279 mCcBccView.show(true, true, true); 1280 if (mCcBccButton != null) { 1281 mCcBccButton.setVisibility(View.GONE); 1282 } 1283 } 1284 1285 @Override 1286 public boolean onNavigationItemSelected(int position, long itemId) { 1287 int initialComposeMode = mComposeMode; 1288 if (position == ComposeActivity.REPLY) { 1289 mComposeMode = ComposeActivity.REPLY; 1290 } else if (position == ComposeActivity.REPLY_ALL) { 1291 mComposeMode = ComposeActivity.REPLY_ALL; 1292 } else if (position == ComposeActivity.FORWARD) { 1293 mComposeMode = ComposeActivity.FORWARD; 1294 } 1295 if (initialComposeMode != mComposeMode) { 1296 resetMessageForModeChange(); 1297 initFromRefMessage(mComposeMode, mAccount.name); 1298 } 1299 return true; 1300 } 1301 1302 private void resetMessageForModeChange() { 1303 // When switching between reply, reply all, forward, 1304 // follow the behavior of webview. 1305 // The contents of the following fields are cleared 1306 // so that they can be populated directly from the 1307 // ref message: 1308 // 1) Any recipient fields 1309 // 2) The subject 1310 mTo.setText(""); 1311 mCc.setText(""); 1312 mBcc.setText(""); 1313 // Any edits to the subject are replaced with the original subject. 1314 mSubject.setText(""); 1315 1316 // Any changes to the contents of the following fields are kept: 1317 // 1) Body 1318 // 2) Attachments 1319 // If the user made changes to attachments, keep their changes. 1320 if (!mAttachmentsChanged) { 1321 mAttachmentsView.deleteAllAttachments(); 1322 } 1323 } 1324 1325 private class ComposeModeAdapter extends ArrayAdapter<String> { 1326 1327 private LayoutInflater mInflater; 1328 1329 public ComposeModeAdapter(Context context) { 1330 super(context, R.layout.compose_mode_item, R.id.mode, getResources() 1331 .getStringArray(R.array.compose_modes)); 1332 } 1333 1334 private LayoutInflater getInflater() { 1335 if (mInflater == null) { 1336 mInflater = LayoutInflater.from(getContext()); 1337 } 1338 return mInflater; 1339 } 1340 1341 @Override 1342 public View getView(int position, View convertView, ViewGroup parent) { 1343 if (convertView == null) { 1344 convertView = getInflater().inflate(R.layout.compose_mode_display_item, null); 1345 } 1346 ((TextView) convertView.findViewById(R.id.mode)).setText(getItem(position)); 1347 return super.getView(position, convertView, parent); 1348 } 1349 } 1350 1351 @Override 1352 public void onRespondInline(String text) { 1353 appendToBody(text, false); 1354 } 1355 1356 /** 1357 * Append text to the body of the message. If there is no existing body 1358 * text, just sets the body to text. 1359 * 1360 * @param text 1361 * @param withSignature True to append a signature. 1362 */ 1363 public void appendToBody(CharSequence text, boolean withSignature) { 1364 Editable bodyText = mBodyView.getEditableText(); 1365 if (bodyText != null && bodyText.length() > 0) { 1366 bodyText.append(text); 1367 } else { 1368 setBody(text, withSignature); 1369 } 1370 } 1371 1372 /** 1373 * Set the body of the message. 1374 * @param text 1375 * @param withSignature True to append a signature. 1376 */ 1377 public void setBody(CharSequence text, boolean withSignature) { 1378 mBodyView.setText(text); 1379 } 1380 1381 @Override 1382 public void onAccountChanged() { 1383 Account selectedAccountInfo = mFromSpinner.getCurrentAccount(); 1384 mAccount = selectedAccountInfo; 1385 1386 // TODO: handle discarding attachments when switching accounts. 1387 // Only enable save for this draft if there is any other content 1388 // in the message. 1389 if (!isBlank()) { 1390 enableSave(true); 1391 } 1392 mReplyFromChanged = true; 1393 initRecipients(); 1394 } 1395 1396 public void enableSave(boolean enabled) { 1397 if (mSave != null) { 1398 mSave.setEnabled(enabled); 1399 } 1400 } 1401 1402 public void enableSend(boolean enabled) { 1403 if (mSend != null) { 1404 mSend.setEnabled(enabled); 1405 } 1406 } 1407 1408 /** 1409 * Handles button clicks from any error dialogs dealing with sending 1410 * a message. 1411 */ 1412 @Override 1413 public void onClick(DialogInterface dialog, int which) { 1414 switch (which) { 1415 case DialogInterface.BUTTON_POSITIVE: { 1416 doDiscardWithoutConfirmation(true /* show toast */ ); 1417 break; 1418 } 1419 case DialogInterface.BUTTON_NEGATIVE: { 1420 // If the user cancels the send, re-enable the send button. 1421 enableSend(true); 1422 break; 1423 } 1424 } 1425 1426 } 1427 1428 /** 1429 * Effectively discard the current message. 1430 * 1431 * This method is either invoked from the menu or from the dialog 1432 * once the user has confirmed that they want to discard the message. 1433 * @param showToast show "Message discarded" toast if true 1434 */ 1435 private void doDiscardWithoutConfirmation(boolean showToast) { 1436 synchronized (mDraftIdLock) { 1437 if (mDraftId != UIProvider.INVALID_MESSAGE_ID) { 1438 ContentValues values = new ContentValues(); 1439 values.put(MessageColumns.SERVER_ID, mDraftId); 1440 getContentResolver().update(Uri.parse(mAccount.expungeMessageUri), 1441 values, null, null); 1442 // This is not strictly necessary (since we should not try to 1443 // save the draft after calling this) but it ensures that if we 1444 // do save again for some reason we make a new draft rather than 1445 // trying to resave an expunged draft. 1446 mDraftId = UIProvider.INVALID_MESSAGE_ID; 1447 } 1448 } 1449 1450 if (showToast) { 1451 // Display a toast to let the user know 1452 Toast.makeText(this, R.string.message_discarded, Toast.LENGTH_SHORT).show(); 1453 } 1454 1455 // This prevents the draft from being saved in onPause(). 1456 discardChanges(); 1457 finish(); 1458 } 1459 1460 private void saveIfNeeded() { 1461 if (mAccount == null) { 1462 // We have not chosen an account yet so there's no way that we can save. This is ok, 1463 // though, since we are saving our state before AccountsActivity is activated. Thus, the 1464 // user has not interacted with us yet and there is no real state to save. 1465 return; 1466 } 1467 1468 if (shouldSave()) { 1469 doSave(!mAddingAttachment /* show toast */, true /* reset IME */); 1470 } 1471 } 1472 1473 private void saveIfNeededOnOrientationChanged() { 1474 if (mAccount == null) { 1475 // We have not chosen an account yet so there's no way that we can save. This is ok, 1476 // though, since we are saving our state before AccountsActivity is activated. Thus, the 1477 // user has not interacted with us yet and there is no real state to save. 1478 return; 1479 } 1480 1481 if (shouldSave()) { 1482 doSaveOrientationChanged(!mAddingAttachment /* show toast */, true /* reset IME */); 1483 } 1484 } 1485 1486 /** 1487 * Save a draft if a draft already exists or the message is not empty. 1488 */ 1489 public void doSaveOrientationChanged(boolean showToast, boolean resetIME) { 1490 saveOnOrientationChanged(); 1491 if (resetIME) { 1492 // Clear the IME composing suggestions from the body. 1493 BaseInputConnection.removeComposingSpans(mBodyView.getEditableText()); 1494 } 1495 } 1496 1497 protected boolean saveOnOrientationChanged() { 1498 return sendOrSaveWithSanityChecks(true, false, true); 1499 } 1500 1501 @Override 1502 public void onAttachmentDeleted() { 1503 mAttachmentsChanged = true; 1504 updateSaveUi(); 1505 } 1506 1507 1508 /** 1509 * This is called any time one of our text fields changes. 1510 */ 1511 public void afterTextChanged(Editable s) { 1512 mTextChanged = true; 1513 updateSaveUi(); 1514 } 1515 1516 @Override 1517 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 1518 // Do nothing. 1519 } 1520 1521 public void onTextChanged(CharSequence s, int start, int before, int count) { 1522 // Do nothing. 1523 } 1524 1525 1526 // There is a big difference between the text associated with an address changing 1527 // to add the display name or to format properly and a recipient being added or deleted. 1528 // Make sure we only notify of changes when a recipient has been added or deleted. 1529 private class RecipientTextWatcher implements TextWatcher { 1530 private HashMap<String, Integer> mContent = new HashMap<String, Integer>(); 1531 1532 private RecipientEditTextView mView; 1533 1534 private TextWatcher mListener; 1535 1536 public RecipientTextWatcher(RecipientEditTextView view, TextWatcher listener) { 1537 mView = view; 1538 mListener = listener; 1539 } 1540 1541 @Override 1542 public void afterTextChanged(Editable s) { 1543 if (hasChanged()) { 1544 mListener.afterTextChanged(s); 1545 } 1546 } 1547 1548 private boolean hasChanged() { 1549 String[] currRecips = tokenizeRecips(getAddressesFromList(mView)); 1550 int totalCount = currRecips.length; 1551 int totalPrevCount = 0; 1552 for (Entry<String, Integer> entry : mContent.entrySet()) { 1553 totalPrevCount += entry.getValue(); 1554 } 1555 if (totalCount != totalPrevCount) { 1556 return true; 1557 } 1558 1559 for (String recip : currRecips) { 1560 if (!mContent.containsKey(recip)) { 1561 return true; 1562 } else { 1563 int count = mContent.get(recip) - 1; 1564 if (count < 0) { 1565 return true; 1566 } else { 1567 mContent.put(recip, count); 1568 } 1569 } 1570 } 1571 return false; 1572 } 1573 1574 private String[] tokenizeRecips(String[] recips) { 1575 // Tokenize them all and put them in the list. 1576 String[] recipAddresses = new String[recips.length]; 1577 for (int i = 0; i < recips.length; i++) { 1578 recipAddresses[i] = Rfc822Tokenizer.tokenize(recips[i])[0].getAddress(); 1579 } 1580 return recipAddresses; 1581 } 1582 1583 @Override 1584 public void beforeTextChanged(CharSequence s, int start, int count, int after) { 1585 String[] recips = tokenizeRecips(getAddressesFromList(mView)); 1586 for (String recip : recips) { 1587 if (!mContent.containsKey(recip)) { 1588 mContent.put(recip, 1); 1589 } else { 1590 mContent.put(recip, (mContent.get(recip)) + 1); 1591 } 1592 } 1593 } 1594 1595 @Override 1596 public void onTextChanged(CharSequence s, int start, int before, int count) { 1597 // Do nothing. 1598 } 1599 } 1600}