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