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