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