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