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