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