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