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