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