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