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