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