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