1 /* 2 * Copyright (C) 2008 The Android Open Source Project 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 17 package com.android.email.activity; 18 19 import android.app.ActionBar; 20 import android.app.ActionBar.OnNavigationListener; 21 import android.app.Activity; 22 import android.app.ActivityManager; 23 import android.app.FragmentTransaction; 24 import android.content.ActivityNotFoundException; 25 import android.content.ContentResolver; 26 import android.content.ContentUris; 27 import android.content.ContentValues; 28 import android.content.Context; 29 import android.content.Intent; 30 import android.database.Cursor; 31 import android.net.Uri; 32 import android.os.Bundle; 33 import android.os.Parcelable; 34 import android.provider.OpenableColumns; 35 import android.text.InputFilter; 36 import android.text.SpannableStringBuilder; 37 import android.text.Spanned; 38 import android.text.TextUtils; 39 import android.text.TextWatcher; 40 import android.text.util.Rfc822Tokenizer; 41 import android.util.Log; 42 import android.view.Menu; 43 import android.view.MenuItem; 44 import android.view.View; 45 import android.view.View.OnClickListener; 46 import android.view.View.OnFocusChangeListener; 47 import android.view.ViewGroup; 48 import android.webkit.WebView; 49 import android.widget.ArrayAdapter; 50 import android.widget.CheckBox; 51 import android.widget.EditText; 52 import android.widget.ImageView; 53 import android.widget.MultiAutoCompleteTextView; 54 import android.widget.TextView; 55 import android.widget.Toast; 56 57 import com.android.common.contacts.DataUsageStatUpdater; 58 import com.android.email.Controller; 59 import com.android.email.Email; 60 import com.android.email.EmailAddressAdapter; 61 import com.android.email.EmailAddressValidator; 62 import com.android.email.R; 63 import com.android.email.RecipientAdapter; 64 import com.android.email.activity.setup.AccountSettings; 65 import com.android.email.mail.internet.EmailHtmlUtil; 66 import com.android.emailcommon.Logging; 67 import com.android.emailcommon.internet.MimeUtility; 68 import com.android.emailcommon.mail.Address; 69 import com.android.emailcommon.provider.Account; 70 import com.android.emailcommon.provider.EmailContent; 71 import com.android.emailcommon.provider.EmailContent.Attachment; 72 import com.android.emailcommon.provider.EmailContent.Body; 73 import com.android.emailcommon.provider.EmailContent.BodyColumns; 74 import com.android.emailcommon.provider.EmailContent.Message; 75 import com.android.emailcommon.provider.EmailContent.MessageColumns; 76 import com.android.emailcommon.provider.EmailContent.QuickResponseColumns; 77 import com.android.emailcommon.provider.Mailbox; 78 import com.android.emailcommon.provider.QuickResponse; 79 import com.android.emailcommon.utility.AttachmentUtilities; 80 import com.android.emailcommon.utility.EmailAsyncTask; 81 import com.android.emailcommon.utility.Utility; 82 import com.android.ex.chips.AccountSpecifier; 83 import com.android.ex.chips.ChipsUtil; 84 import com.android.ex.chips.RecipientEditTextView; 85 import com.google.common.annotations.VisibleForTesting; 86 import com.google.common.base.Objects; 87 import com.google.common.collect.Lists; 88 89 import java.io.File; 90 import java.io.UnsupportedEncodingException; 91 import java.net.URLDecoder; 92 import java.util.ArrayList; 93 import java.util.HashMap; 94 import java.util.HashSet; 95 import java.util.List; 96 import java.util.concurrent.ConcurrentHashMap; 97 import java.util.concurrent.ExecutionException; 98 99 100 /** 101 * Activity to compose a message. 102 * 103 * TODO Revive shortcuts command for removed menu options. 104 * C: add cc/bcc 105 * N: add attachment 106 */ 107 public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener, 108 DeleteMessageConfirmationDialog.Callback, InsertQuickResponseDialog.Callback { 109 110 private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY"; 111 private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL"; 112 private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD"; 113 private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT"; 114 115 private static final String EXTRA_ACCOUNT_ID = "account_id"; 116 private static final String EXTRA_MESSAGE_ID = "message_id"; 117 /** If the intent is sent from the email app itself, it should have this boolean extra. */ 118 public static final String EXTRA_FROM_WITHIN_APP = "from_within_app"; 119 /** If the intent is sent from thw widget. */ 120 public static final String EXTRA_FROM_WIDGET = "from_widget"; 121 122 private static final String STATE_KEY_CC_SHOWN = 123 "com.android.email.activity.MessageCompose.ccShown"; 124 private static final String STATE_KEY_QUOTED_TEXT_SHOWN = 125 "com.android.email.activity.MessageCompose.quotedTextShown"; 126 private static final String STATE_KEY_DRAFT_ID = 127 "com.android.email.activity.MessageCompose.draftId"; 128 private static final String STATE_KEY_LAST_SAVE_TASK_ID = 129 "com.android.email.activity.MessageCompose.requestId"; 130 private static final String STATE_KEY_ACTION = 131 "com.android.email.activity.MessageCompose.action"; 132 133 private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1; 134 135 private static final String[] ATTACHMENT_META_SIZE_PROJECTION = { 136 OpenableColumns.SIZE 137 }; 138 private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0; 139 140 /** 141 * A registry of the active tasks used to save messages. 142 */ 143 private static final ConcurrentHashMap<Long, SendOrSaveMessageTask> sActiveSaveTasks = 144 new ConcurrentHashMap<Long, SendOrSaveMessageTask>(); 145 146 private static long sNextSaveTaskId = 1; 147 148 /** 149 * The ID of the latest save or send task requested by this Activity. 150 */ 151 private long mLastSaveTaskId = -1; 152 153 private Account mAccount; 154 155 /** 156 * The contents of the current message being edited. This is not always in sync with what's 157 * on the UI. {@link #updateMessage(Message, Account, boolean, boolean)} must be called to sync 158 * the UI values into this object. 159 */ 160 private Message mDraft = new Message(); 161 162 /** 163 * A collection of attachments the user is currently wanting to attach to this message. 164 */ 165 private final ArrayList<Attachment> mAttachments = new ArrayList<Attachment>(); 166 167 /** 168 * The source message for a reply, reply all, or forward. This is asynchronously loaded. 169 */ 170 private Message mSource; 171 172 /** 173 * The attachments associated with the source attachments. Usually included in a forward. 174 */ 175 private ArrayList<Attachment> mSourceAttachments = new ArrayList<Attachment>(); 176 177 /** 178 * The action being handled by this activity. This is initially populated from the 179 * {@link Intent}, but can switch between reply/reply all/forward where appropriate. 180 * This value is nullable (a null value indicating a regular "compose"). 181 */ 182 private String mAction; 183 184 private TextView mFromView; 185 private MultiAutoCompleteTextView mToView; 186 private MultiAutoCompleteTextView mCcView; 187 private MultiAutoCompleteTextView mBccView; 188 private View mCcBccContainer; 189 private EditText mSubjectView; 190 private EditText mMessageContentView; 191 private View mAttachmentContainer; 192 private ViewGroup mAttachmentContentView; 193 private View mQuotedTextArea; 194 private CheckBox mIncludeQuotedTextCheckBox; 195 private WebView mQuotedText; 196 private ActionSpinnerAdapter mActionSpinnerAdapter; 197 198 private Controller mController; 199 private boolean mDraftNeedsSaving; 200 private boolean mMessageLoaded; 201 private boolean mInitiallyEmpty; 202 private boolean mPickingAttachment = false; 203 private Boolean mQuickResponsesAvailable = true; 204 private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker(); 205 206 private AccountSpecifier mAddressAdapterTo; 207 private AccountSpecifier mAddressAdapterCc; 208 private AccountSpecifier mAddressAdapterBcc; 209 210 /** 211 * Watches the to, cc, bcc, subject, and message body fields. 212 */ 213 private final TextWatcher mWatcher = new TextWatcher() { 214 @Override 215 public void beforeTextChanged(CharSequence s, int start, 216 int before, int after) { } 217 218 @Override 219 public void onTextChanged(CharSequence s, int start, 220 int before, int count) { 221 setMessageChanged(true); 222 } 223 224 @Override 225 public void afterTextChanged(android.text.Editable s) { } 226 }; 227 228 private static Intent getBaseIntent(Context context) { 229 return new Intent(context, MessageCompose.class); 230 } 231 232 /** 233 * Create an {@link Intent} that can start the message compose activity. If accountId -1, 234 * the default account will be used; otherwise, the specified account is used. 235 */ 236 public static Intent getMessageComposeIntent(Context context, long accountId) { 237 Intent i = getBaseIntent(context); 238 i.putExtra(EXTRA_ACCOUNT_ID, accountId); 239 return i; 240 } 241 242 /** 243 * Creates an {@link Intent} that can start the message compose activity from the main Email 244 * activity. This should not be used for Intents to be fired from outside of the main Email 245 * activity, such as from widgets, as the behavior of the compose screen differs subtly from 246 * those cases. 247 */ 248 private static Intent getMainAppIntent(Context context, long accountId) { 249 Intent result = getMessageComposeIntent(context, accountId); 250 result.putExtra(EXTRA_FROM_WITHIN_APP, true); 251 return result; 252 } 253 254 /** 255 * Compose a new message using the given account. If account is {@link Account#NO_ACCOUNT} 256 * the default account will be used. 257 * This should only be called from the main Email application. 258 * @param context 259 * @param accountId 260 */ 261 public static void actionCompose(Context context, long accountId) { 262 try { 263 Intent i = getMainAppIntent(context, accountId); 264 context.startActivity(i); 265 } catch (ActivityNotFoundException anfe) { 266 // Swallow it - this is usually a race condition, especially under automated test. 267 // (The message composer might have been disabled) 268 Email.log(anfe.toString()); 269 } 270 } 271 272 /** 273 * Compose a new message using a uri (mailto:) and a given account. If account is -1 the 274 * default account will be used. 275 * This should only be called from the main Email application. 276 * @param context 277 * @param uriString 278 * @param accountId 279 * @return true if startActivity() succeeded 280 */ 281 public static boolean actionCompose(Context context, String uriString, long accountId) { 282 try { 283 Intent i = getMainAppIntent(context, accountId); 284 i.setAction(Intent.ACTION_SEND); 285 i.setData(Uri.parse(uriString)); 286 context.startActivity(i); 287 return true; 288 } catch (ActivityNotFoundException anfe) { 289 // Swallow it - this is usually a race condition, especially under automated test. 290 // (The message composer might have been disabled) 291 Email.log(anfe.toString()); 292 return false; 293 } 294 } 295 296 /** 297 * Compose a new message as a reply to the given message. If replyAll is true the function 298 * is reply all instead of simply reply. 299 * @param context 300 * @param messageId 301 * @param replyAll 302 */ 303 public static void actionReply(Context context, long messageId, boolean replyAll) { 304 startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId); 305 } 306 307 /** 308 * Compose a new message as a forward of the given message. 309 * @param context 310 * @param messageId 311 */ 312 public static void actionForward(Context context, long messageId) { 313 startActivityWithMessage(context, ACTION_FORWARD, messageId); 314 } 315 316 /** 317 * Continue composition of the given message. This action modifies the way this Activity 318 * handles certain actions. 319 * Save will attempt to replace the message in the given folder with the updated version. 320 * Discard will delete the message from the given folder. 321 * @param context 322 * @param messageId the message id. 323 */ 324 public static void actionEditDraft(Context context, long messageId) { 325 startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId); 326 } 327 328 /** 329 * Starts a compose activity with a message as a reference message (e.g. for reply or forward). 330 */ 331 private static void startActivityWithMessage(Context context, String action, long messageId) { 332 Intent i = getBaseIntent(context); 333 i.putExtra(EXTRA_MESSAGE_ID, messageId); 334 i.setAction(action); 335 context.startActivity(i); 336 } 337 338 private void setAccount(Intent intent) { 339 long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1); 340 Account account = null; 341 if (accountId != Account.NO_ACCOUNT) { 342 // User supplied an account; make sure it exists 343 account = Account.restoreAccountWithId(this, accountId); 344 // Deleted account is no account... 345 if (account == null) { 346 accountId = Account.NO_ACCOUNT; 347 } 348 } 349 // If we still have no account, try the default 350 if (accountId == Account.NO_ACCOUNT) { 351 accountId = Account.getDefaultAccountId(this); 352 if (accountId != Account.NO_ACCOUNT) { 353 // Make sure it exists... 354 account = Account.restoreAccountWithId(this, accountId); 355 // Deleted account is no account... 356 if (account == null) { 357 accountId = Account.NO_ACCOUNT; 358 } 359 } 360 } 361 // If we can't find an account, set one up 362 if (accountId == Account.NO_ACCOUNT || account == null) { 363 // There are no accounts set up. This should not have happened. Prompt the 364 // user to set up an account as an acceptable bailout. 365 Welcome.actionStart(this); 366 finish(); 367 } else { 368 setAccount(account); 369 } 370 } 371 372 private void setAccount(Account account) { 373 if (account == null) { 374 Utility.showToast(this, R.string.widget_no_accounts); 375 Log.d(Logging.LOG_TAG, "The account has been deleted, force finish it"); 376 finish(); 377 } 378 mAccount = account; 379 mFromView.setText(account.mEmailAddress); 380 mAddressAdapterTo 381 .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown")); 382 mAddressAdapterCc 383 .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown")); 384 mAddressAdapterBcc 385 .setAccount(new android.accounts.Account(account.mEmailAddress, "unknown")); 386 387 new QuickResponseChecker(mTaskTracker).executeParallel((Void) null); 388 } 389 390 @Override 391 public void onCreate(Bundle savedInstanceState) { 392 super.onCreate(savedInstanceState); 393 ActivityHelper.debugSetWindowFlags(this); 394 setContentView(R.layout.message_compose); 395 396 mController = Controller.getInstance(getApplication()); 397 initViews(); 398 399 // Show the back arrow on the action bar. 400 getActionBar().setDisplayOptions( 401 ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP); 402 403 if (savedInstanceState != null) { 404 long draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, Message.NOT_SAVED); 405 long existingSaveTaskId = savedInstanceState.getLong(STATE_KEY_LAST_SAVE_TASK_ID, -1); 406 setAction(savedInstanceState.getString(STATE_KEY_ACTION)); 407 SendOrSaveMessageTask existingSaveTask = sActiveSaveTasks.get(existingSaveTaskId); 408 409 if ((draftId != Message.NOT_SAVED) || (existingSaveTask != null)) { 410 // Restoring state and there was an existing message saved or in the process of 411 // being saved. 412 resumeDraft(draftId, existingSaveTask, false /* don't restore views */); 413 } else { 414 // Restoring state but there was nothing saved - probably means the user rotated 415 // the device immediately - just use the Intent. 416 resolveIntent(getIntent()); 417 } 418 } else { 419 Intent intent = getIntent(); 420 setAction(intent.getAction()); 421 resolveIntent(intent); 422 } 423 } 424 425 private void resolveIntent(Intent intent) { 426 if (Intent.ACTION_VIEW.equals(mAction) 427 || Intent.ACTION_SENDTO.equals(mAction) 428 || Intent.ACTION_SEND.equals(mAction) 429 || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) { 430 initFromIntent(intent); 431 setMessageChanged(true); 432 setMessageLoaded(true); 433 } else if (ACTION_REPLY.equals(mAction) 434 || ACTION_REPLY_ALL.equals(mAction) 435 || ACTION_FORWARD.equals(mAction)) { 436 long sourceMessageId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED); 437 loadSourceMessage(sourceMessageId, true); 438 439 } else if (ACTION_EDIT_DRAFT.equals(mAction)) { 440 // Assert getIntent.hasExtra(EXTRA_MESSAGE_ID) 441 long draftId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, Message.NOT_SAVED); 442 resumeDraft(draftId, null, true /* restore views */); 443 444 } else { 445 // Normal compose flow for a new message. 446 setAccount(intent); 447 setInitialComposeText(null, getAccountSignature(mAccount)); 448 setMessageLoaded(true); 449 } 450 } 451 452 @Override 453 protected void onRestoreInstanceState(Bundle savedInstanceState) { 454 // Temporarily disable onTextChanged listeners while restoring the fields 455 removeListeners(); 456 super.onRestoreInstanceState(savedInstanceState); 457 if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) { 458 showCcBccFields(); 459 } 460 mQuotedTextArea.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) 461 ? View.VISIBLE : View.GONE); 462 mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) 463 ? View.VISIBLE : View.GONE); 464 addListeners(); 465 } 466 467 // needed for unit tests 468 @Override 469 public void setIntent(Intent intent) { 470 super.setIntent(intent); 471 setAction(intent.getAction()); 472 } 473 474 private void setQuickResponsesAvailable(boolean quickResponsesAvailable) { 475 if (mQuickResponsesAvailable != quickResponsesAvailable) { 476 mQuickResponsesAvailable = quickResponsesAvailable; 477 invalidateOptionsMenu(); 478 } 479 } 480 481 /** 482 * Given an accountId and context, finds if the database has any QuickResponse 483 * entries and returns the result to the Callback. 484 */ 485 private class QuickResponseChecker extends EmailAsyncTask<Void, Void, Boolean> { 486 public QuickResponseChecker(EmailAsyncTask.Tracker tracker) { 487 super(tracker); 488 } 489 490 @Override 491 protected Boolean doInBackground(Void... params) { 492 return EmailContent.count(MessageCompose.this, QuickResponse.CONTENT_URI, 493 QuickResponseColumns.ACCOUNT_KEY + "=?", 494 new String[] {Long.toString(mAccount.mId)}) > 0; 495 } 496 497 @Override 498 protected void onSuccess(Boolean quickResponsesAvailable) { 499 setQuickResponsesAvailable(quickResponsesAvailable); 500 } 501 } 502 503 @Override 504 public void onResume() { 505 super.onResume(); 506 507 // Exit immediately if the accounts list has changed (e.g. externally deleted) 508 if (Email.getNotifyUiAccountsChanged()) { 509 Welcome.actionStart(this); 510 finish(); 511 return; 512 } 513 514 // If activity paused and quick responses are removed/added, possibly update options menu 515 if (mAccount != null) { 516 new QuickResponseChecker(mTaskTracker).executeParallel((Void) null); 517 } 518 } 519 520 @Override 521 public void onPause() { 522 super.onPause(); 523 saveIfNeeded(); 524 } 525 526 /** 527 * We override onDestroy to make sure that the WebView gets explicitly destroyed. 528 * Otherwise it can leak native references. 529 */ 530 @Override 531 public void onDestroy() { 532 super.onDestroy(); 533 mQuotedText.destroy(); 534 mQuotedText = null; 535 536 mTaskTracker.cancellAllInterrupt(); 537 538 if (mAddressAdapterTo != null && mAddressAdapterTo instanceof EmailAddressAdapter) { 539 ((EmailAddressAdapter) mAddressAdapterTo).close(); 540 } 541 if (mAddressAdapterCc != null && mAddressAdapterCc instanceof EmailAddressAdapter) { 542 ((EmailAddressAdapter) mAddressAdapterCc).close(); 543 } 544 if (mAddressAdapterBcc != null && mAddressAdapterBcc instanceof EmailAddressAdapter) { 545 ((EmailAddressAdapter) mAddressAdapterBcc).close(); 546 } 547 } 548 549 /** 550 * The framework handles most of the fields, but we need to handle stuff that we 551 * dynamically show and hide: 552 * Cc field, 553 * Bcc field, 554 * Quoted text, 555 */ 556 @Override 557 protected void onSaveInstanceState(Bundle outState) { 558 super.onSaveInstanceState(outState); 559 560 long draftId = mDraft.mId; 561 if (draftId != Message.NOT_SAVED) { 562 outState.putLong(STATE_KEY_DRAFT_ID, draftId); 563 } 564 outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE); 565 outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN, 566 mQuotedTextArea.getVisibility() == View.VISIBLE); 567 outState.putString(STATE_KEY_ACTION, mAction); 568 569 // If there are any outstanding save requests, ensure that it's noted in case it hasn't 570 // finished by the time the activity is restored. 571 outState.putLong(STATE_KEY_LAST_SAVE_TASK_ID, mLastSaveTaskId); 572 } 573 574 @Override 575 public void onBackPressed() { 576 onBack(true /* systemKey */); 577 } 578 579 /** 580 * Whether or not the current message being edited has a source message (i.e. is a reply, 581 * or forward) that is loaded. 582 */ 583 private boolean hasSourceMessage() { 584 return mSource != null; 585 } 586 587 /** 588 * @return true if the activity was opened by the email app itself. 589 */ 590 private boolean isOpenedFromWithinApp() { 591 Intent i = getIntent(); 592 return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false)); 593 } 594 595 private boolean isOpenedFromWidget() { 596 Intent i = getIntent(); 597 return (i != null && i.getBooleanExtra(EXTRA_FROM_WIDGET, false)); 598 } 599 600 /** 601 * Sets message as loaded and then initializes the TextWatchers. 602 * @param isLoaded - value to which to set mMessageLoaded 603 */ 604 private void setMessageLoaded(boolean isLoaded) { 605 if (mMessageLoaded != isLoaded) { 606 mMessageLoaded = isLoaded; 607 addListeners(); 608 mInitiallyEmpty = areViewsEmpty(); 609 } 610 } 611 612 private void setMessageChanged(boolean messageChanged) { 613 boolean needsSaving = messageChanged && !(mInitiallyEmpty && areViewsEmpty()); 614 615 if (mDraftNeedsSaving != needsSaving) { 616 mDraftNeedsSaving = needsSaving; 617 invalidateOptionsMenu(); 618 } 619 } 620 621 /** 622 * @return whether or not all text fields are empty (i.e. the entire compose message is empty) 623 */ 624 private boolean areViewsEmpty() { 625 return (mToView.length() == 0) 626 && (mCcView.length() == 0) 627 && (mBccView.length() == 0) 628 && (mSubjectView.length() == 0) 629 && isBodyEmpty() 630 && mAttachments.isEmpty(); 631 } 632 633 private boolean isBodyEmpty() { 634 return (mMessageContentView.length() == 0) 635 || mMessageContentView.getText() 636 .toString().equals("\n" + getAccountSignature(mAccount)); 637 } 638 639 public void setFocusShifter(int fromViewId, final int targetViewId) { 640 View label = findViewById(fromViewId); // xlarge only 641 if (label != null) { 642 final View target = UiUtilities.getView(this, targetViewId); 643 label.setOnClickListener(new View.OnClickListener() { 644 @Override 645 public void onClick(View v) { 646 target.requestFocus(); 647 } 648 }); 649 } 650 } 651 652 /** 653 * An {@link InputFilter} that implements special address cleanup rules. 654 * The first space key entry following an "@" symbol that is followed by any combination 655 * of letters and symbols, including one+ dots and zero commas, should insert an extra 656 * comma (followed by the space). 657 */ 658 @VisibleForTesting 659 static final InputFilter RECIPIENT_FILTER = new InputFilter() { 660 @Override 661 public CharSequence filter(CharSequence source, int start, int end, Spanned dest, 662 int dstart, int dend) { 663 664 // Quick check - did they enter a single space? 665 if (end-start != 1 || source.charAt(start) != ' ') { 666 return null; 667 } 668 669 // determine if the characters before the new space fit the pattern 670 // follow backwards and see if we find a comma, dot, or @ 671 int scanBack = dstart; 672 boolean dotFound = false; 673 while (scanBack > 0) { 674 char c = dest.charAt(--scanBack); 675 switch (c) { 676 case '.': 677 dotFound = true; // one or more dots are req'd 678 break; 679 case ',': 680 return null; 681 case '@': 682 if (!dotFound) { 683 return null; 684 } 685 686 // we have found a comma-insert case. now just do it 687 // in the least expensive way we can. 688 if (source instanceof Spanned) { 689 SpannableStringBuilder sb = new SpannableStringBuilder(","); 690 sb.append(source); 691 return sb; 692 } else { 693 return ", "; 694 } 695 default: 696 // just keep going 697 } 698 } 699 700 // no termination cases were found, so don't edit the input 701 return null; 702 } 703 }; 704 705 private void initViews() { 706 ViewGroup toParent = UiUtilities.getViewOrNull(this, R.id.to_content); 707 if (toParent != null) { 708 mToView = (MultiAutoCompleteTextView) toParent.findViewById(R.id.to); 709 ViewGroup ccParent, bccParent; 710 ccParent = (ViewGroup) findViewById(R.id.cc_content); 711 mCcView = (MultiAutoCompleteTextView) ccParent.findViewById(R.id.cc); 712 bccParent = (ViewGroup) findViewById(R.id.bcc_content); 713 mBccView = (MultiAutoCompleteTextView) bccParent.findViewById(R.id.bcc); 714 } else { 715 mToView = UiUtilities.getView(this, R.id.to); 716 mCcView = UiUtilities.getView(this, R.id.cc); 717 mBccView = UiUtilities.getView(this, R.id.bcc); 718 } 719 720 mFromView = UiUtilities.getView(this, R.id.from); 721 mCcBccContainer = UiUtilities.getView(this, R.id.cc_bcc_wrapper); 722 mSubjectView = UiUtilities.getView(this, R.id.subject); 723 mMessageContentView = UiUtilities.getView(this, R.id.body_text); 724 mAttachmentContentView = UiUtilities.getView(this, R.id.attachments); 725 mAttachmentContainer = UiUtilities.getView(this, R.id.attachment_container); 726 mQuotedTextArea = UiUtilities.getView(this, R.id.quoted_text_area); 727 mIncludeQuotedTextCheckBox = UiUtilities.getView(this, R.id.include_quoted_text); 728 mQuotedText = UiUtilities.getView(this, R.id.quoted_text); 729 730 InputFilter[] recipientFilters = new InputFilter[] { RECIPIENT_FILTER }; 731 732 // NOTE: assumes no other filters are set 733 mToView.setFilters(recipientFilters); 734 mCcView.setFilters(recipientFilters); 735 mBccView.setFilters(recipientFilters); 736 737 /* 738 * We set this to invisible by default. Other methods will turn it back on if it's 739 * needed. 740 */ 741 mQuotedTextArea.setVisibility(View.GONE); 742 setIncludeQuotedText(false, false); 743 744 mIncludeQuotedTextCheckBox.setOnClickListener(this); 745 746 EmailAddressValidator addressValidator = new EmailAddressValidator(); 747 748 setupAddressAdapters(); 749 mToView.setTokenizer(new Rfc822Tokenizer()); 750 mToView.setValidator(addressValidator); 751 752 mCcView.setTokenizer(new Rfc822Tokenizer()); 753 mCcView.setValidator(addressValidator); 754 755 mBccView.setTokenizer(new Rfc822Tokenizer()); 756 mBccView.setValidator(addressValidator); 757 758 final View addCcBccView = UiUtilities.getViewOrNull(this, R.id.add_cc_bcc); 759 if (addCcBccView != null) { 760 // Tablet view. 761 addCcBccView.setOnClickListener(this); 762 } 763 764 final View addAttachmentView = UiUtilities.getViewOrNull(this, R.id.add_attachment); 765 if (addAttachmentView != null) { 766 // Tablet view. 767 addAttachmentView.setOnClickListener(this); 768 } 769 770 setFocusShifter(R.id.to_label, R.id.to); 771 setFocusShifter(R.id.cc_label, R.id.cc); 772 setFocusShifter(R.id.bcc_label, R.id.bcc); 773 setFocusShifter(R.id.composearea_tap_trap_bottom, R.id.body_text); 774 775 mMessageContentView.setOnFocusChangeListener(this); 776 777 updateAttachmentContainer(); 778 mToView.requestFocus(); 779 } 780 781 /** 782 * Initializes listeners. Should only be called once initializing of views is complete to 783 * avoid unnecessary draft saving. 784 */ 785 private void addListeners() { 786 mToView.addTextChangedListener(mWatcher); 787 mCcView.addTextChangedListener(mWatcher); 788 mBccView.addTextChangedListener(mWatcher); 789 mSubjectView.addTextChangedListener(mWatcher); 790 mMessageContentView.addTextChangedListener(mWatcher); 791 } 792 793 /** 794 * Removes listeners from the user-editable fields. Can be used to temporarily disable them 795 * while resetting fields (such as when changing from reply to reply all) to avoid 796 * unnecessary saving. 797 */ 798 private void removeListeners() { 799 mToView.removeTextChangedListener(mWatcher); 800 mCcView.removeTextChangedListener(mWatcher); 801 mBccView.removeTextChangedListener(mWatcher); 802 mSubjectView.removeTextChangedListener(mWatcher); 803 mMessageContentView.removeTextChangedListener(mWatcher); 804 } 805 806 /** 807 * Set up address auto-completion adapters. 808 */ 809 private void setupAddressAdapters() { 810 boolean supportsChips = ChipsUtil.supportsChipsUi(); 811 812 if (supportsChips && mToView instanceof RecipientEditTextView) { 813 mAddressAdapterTo = new RecipientAdapter(this, (RecipientEditTextView) mToView); 814 mToView.setAdapter((RecipientAdapter) mAddressAdapterTo); 815 } else { 816 mAddressAdapterTo = new EmailAddressAdapter(this); 817 mToView.setAdapter((EmailAddressAdapter) mAddressAdapterTo); 818 } 819 if (supportsChips && mCcView instanceof RecipientEditTextView) { 820 mAddressAdapterCc = new RecipientAdapter(this, (RecipientEditTextView) mCcView); 821 mCcView.setAdapter((RecipientAdapter) mAddressAdapterCc); 822 } else { 823 mAddressAdapterCc = new EmailAddressAdapter(this); 824 mCcView.setAdapter((EmailAddressAdapter) mAddressAdapterCc); 825 } 826 if (supportsChips && mBccView instanceof RecipientEditTextView) { 827 mAddressAdapterBcc = new RecipientAdapter(this, (RecipientEditTextView) mBccView); 828 mBccView.setAdapter((RecipientAdapter) mAddressAdapterBcc); 829 } else { 830 mAddressAdapterBcc = new EmailAddressAdapter(this); 831 mBccView.setAdapter((EmailAddressAdapter) mAddressAdapterBcc); 832 } 833 } 834 835 /** 836 * Asynchronously loads a draft message for editing. 837 * This may or may not restore the view contents, depending on whether or not callers want, 838 * since in the case of screen rotation, those are restored automatically. 839 */ 840 private void resumeDraft( 841 long draftId, 842 SendOrSaveMessageTask existingSaveTask, 843 final boolean restoreViews) { 844 // Note - this can be Message.NOT_SAVED if there is an existing save task in progress 845 // for the draft we need to load. 846 mDraft.mId = draftId; 847 848 new LoadMessageTask(draftId, existingSaveTask, new OnMessageLoadHandler() { 849 @Override 850 public void onMessageLoaded(Message message, Body body) { 851 message.mHtml = body.mHtmlContent; 852 message.mText = body.mTextContent; 853 message.mHtmlReply = body.mHtmlReply; 854 message.mTextReply = body.mTextReply; 855 message.mIntroText = body.mIntroText; 856 message.mSourceKey = body.mSourceKey; 857 858 mDraft = message; 859 processDraftMessage(message, restoreViews); 860 861 // Load attachments related to the draft. 862 loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() { 863 @Override 864 public void onAttachmentLoaded(Attachment[] attachments) { 865 for (Attachment attachment: attachments) { 866 addAttachment(attachment); 867 } 868 } 869 }); 870 871 // If we're resuming an edit of a reply, reply-all, or forward, re-load the 872 // source message if available so that we get more information. 873 if (message.mSourceKey != Message.NOT_SAVED) { 874 loadSourceMessage(message.mSourceKey, false /* restore views */); 875 } 876 } 877 878 @Override 879 public void onLoadFailed() { 880 Utility.showToast(MessageCompose.this, R.string.error_loading_message_body); 881 finish(); 882 } 883 }).executeSerial((Void[]) null); 884 } 885 886 @VisibleForTesting 887 void processDraftMessage(Message message, boolean restoreViews) { 888 if (restoreViews) { 889 mSubjectView.setText(message.mSubject); 890 addAddresses(mToView, Address.unpack(message.mTo)); 891 Address[] cc = Address.unpack(message.mCc); 892 if (cc.length > 0) { 893 addAddresses(mCcView, cc); 894 } 895 Address[] bcc = Address.unpack(message.mBcc); 896 if (bcc.length > 0) { 897 addAddresses(mBccView, bcc); 898 } 899 900 mMessageContentView.setText(message.mText); 901 902 showCcBccFieldsIfFilled(); 903 setNewMessageFocus(); 904 } 905 setMessageChanged(false); 906 907 // The quoted text must always be restored. 908 displayQuotedText(message.mTextReply, message.mHtmlReply); 909 setIncludeQuotedText( 910 (mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0, false); 911 } 912 913 /** 914 * Asynchronously loads a source message (to be replied or forwarded in this current view), 915 * populating text fields and quoted text fields when the load finishes, if requested. 916 */ 917 private void loadSourceMessage(long sourceMessageId, final boolean restoreViews) { 918 new LoadMessageTask(sourceMessageId, null, new OnMessageLoadHandler() { 919 @Override 920 public void onMessageLoaded(Message message, Body body) { 921 message.mHtml = body.mHtmlContent; 922 message.mText = body.mTextContent; 923 message.mHtmlReply = null; 924 message.mTextReply = null; 925 message.mIntroText = null; 926 mSource = message; 927 mSourceAttachments = new ArrayList<Attachment>(); 928 929 if (restoreViews) { 930 processSourceMessage(mSource, mAccount); 931 setInitialComposeText(null, getAccountSignature(mAccount)); 932 } 933 934 loadAttachments(message.mId, mAccount, new AttachmentLoadedCallback() { 935 @Override 936 public void onAttachmentLoaded(Attachment[] attachments) { 937 final boolean supportsSmartForward = 938 (mAccount.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0; 939 940 // Process the attachments to have the appropriate smart forward flags. 941 for (Attachment attachment : attachments) { 942 if (supportsSmartForward) { 943 attachment.mFlags |= Attachment.FLAG_SMART_FORWARD; 944 } 945 mSourceAttachments.add(attachment); 946 } 947 if (isForward() && restoreViews) { 948 if (processSourceMessageAttachments( 949 mAttachments, mSourceAttachments, true)) { 950 updateAttachmentUi(); 951 setMessageChanged(true); 952 } 953 } 954 } 955 }); 956 957 if (mAction.equals(ACTION_EDIT_DRAFT)) { 958 // Resuming a draft may in fact be resuming a reply/reply all/forward. 959 // Use a best guess and infer the action here. 960 String inferredAction = inferAction(); 961 if (inferredAction != null) { 962 setAction(inferredAction); 963 // No need to update the action selector as switching actions should do it. 964 return; 965 } 966 } 967 968 updateActionSelector(); 969 } 970 971 @Override 972 public void onLoadFailed() { 973 // The loading of the source message is only really required if it is needed 974 // immediately to restore the view contents. In the case of resuming draft, it 975 // is only needed to gather additional information. 976 if (restoreViews) { 977 Utility.showToast(MessageCompose.this, R.string.error_loading_message_body); 978 finish(); 979 } 980 } 981 }).executeSerial((Void[]) null); 982 } 983 984 /** 985 * Infers whether or not the current state of the message best reflects either a reply, 986 * reply-all, or forward. 987 */ 988 @VisibleForTesting 989 String inferAction() { 990 String subject = mSubjectView.getText().toString(); 991 if (subject == null) { 992 return null; 993 } 994 if (subject.toLowerCase().startsWith("fwd:")) { 995 return ACTION_FORWARD; 996 } else if (subject.toLowerCase().startsWith("re:")) { 997 int numRecipients = getAddresses(mToView).length 998 + getAddresses(mCcView).length 999 + getAddresses(mBccView).length; 1000 if (numRecipients > 1) { 1001 return ACTION_REPLY_ALL; 1002 } else { 1003 return ACTION_REPLY; 1004 } 1005 } else { 1006 // Unsure. 1007 return null; 1008 } 1009 } 1010 1011 private interface OnMessageLoadHandler { 1012 /** 1013 * Handles a load to a message (e.g. a draft message or a source message). 1014 */ 1015 void onMessageLoaded(Message message, Body body); 1016 1017 /** 1018 * Handles a failure to load a message. 1019 */ 1020 void onLoadFailed(); 1021 } 1022 1023 /** 1024 * Asynchronously loads a message and the account information. 1025 * This can be used to load a reference message (when replying) or when restoring a draft. 1026 */ 1027 private class LoadMessageTask extends EmailAsyncTask<Void, Void, Object[]> { 1028 /** 1029 * The message ID to load, if available. 1030 */ 1031 private long mMessageId; 1032 1033 /** 1034 * A future-like reference to the save task which must complete prior to this load. 1035 */ 1036 private final SendOrSaveMessageTask mSaveTask; 1037 1038 /** 1039 * A callback to pass the results of the load to. 1040 */ 1041 private final OnMessageLoadHandler mCallback; 1042 1043 public LoadMessageTask( 1044 long messageId, SendOrSaveMessageTask saveTask, OnMessageLoadHandler callback) { 1045 super(mTaskTracker); 1046 mMessageId = messageId; 1047 mSaveTask = saveTask; 1048 mCallback = callback; 1049 } 1050 1051 private long getIdToLoad() throws InterruptedException, ExecutionException { 1052 if (mMessageId == -1) { 1053 mMessageId = mSaveTask.get(); 1054 } 1055 return mMessageId; 1056 } 1057 1058 @Override 1059 protected Object[] doInBackground(Void... params) { 1060 long messageId; 1061 try { 1062 messageId = getIdToLoad(); 1063 } catch (InterruptedException e) { 1064 // Don't have a good message ID to load - bail. 1065 Log.e(Logging.LOG_TAG, 1066 "Unable to load draft message since existing save task failed: " + e); 1067 return null; 1068 } catch (ExecutionException e) { 1069 // Don't have a good message ID to load - bail. 1070 Log.e(Logging.LOG_TAG, 1071 "Unable to load draft message since existing save task failed: " + e); 1072 return null; 1073 } 1074 Message message = Message.restoreMessageWithId(MessageCompose.this, messageId); 1075 if (message == null) { 1076 return null; 1077 } 1078 long accountId = message.mAccountKey; 1079 Account account = Account.restoreAccountWithId(MessageCompose.this, accountId); 1080 Body body; 1081 try { 1082 body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId); 1083 } catch (RuntimeException e) { 1084 Log.d(Logging.LOG_TAG, "Exception while loading message body: " + e); 1085 return null; 1086 } 1087 return new Object[] {message, body, account}; 1088 } 1089 1090 @Override 1091 protected void onSuccess(Object[] results) { 1092 if ((results == null) || (results.length != 3)) { 1093 mCallback.onLoadFailed(); 1094 return; 1095 } 1096 1097 final Message message = (Message) results[0]; 1098 final Body body = (Body) results[1]; 1099 final Account account = (Account) results[2]; 1100 if ((message == null) || (body == null) || (account == null)) { 1101 mCallback.onLoadFailed(); 1102 return; 1103 } 1104 1105 setAccount(account); 1106 mCallback.onMessageLoaded(message, body); 1107 setMessageLoaded(true); 1108 } 1109 } 1110 1111 private interface AttachmentLoadedCallback { 1112 /** 1113 * Handles completion of the loading of a set of attachments. 1114 * Callback will always happen on the main thread. 1115 */ 1116 void onAttachmentLoaded(Attachment[] attachment); 1117 } 1118 1119 private void loadAttachments( 1120 final long messageId, 1121 final Account account, 1122 final AttachmentLoadedCallback callback) { 1123 new EmailAsyncTask<Void, Void, Attachment[]>(mTaskTracker) { 1124 @Override 1125 protected Attachment[] doInBackground(Void... params) { 1126 return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this, messageId); 1127 } 1128 1129 @Override 1130 protected void onSuccess(Attachment[] attachments) { 1131 if (attachments == null) { 1132 attachments = new Attachment[0]; 1133 } 1134 callback.onAttachmentLoaded(attachments); 1135 } 1136 }.executeSerial((Void[]) null); 1137 } 1138 1139 @Override 1140 public void onFocusChange(View view, boolean focused) { 1141 if (focused) { 1142 switch (view.getId()) { 1143 case R.id.body_text: 1144 // When focusing on the message content via tabbing to it, or other means of 1145 // auto focusing, move the cursor to the end of the body (before the signature). 1146 if (mMessageContentView.getSelectionStart() == 0 1147 && mMessageContentView.getSelectionEnd() == 0) { 1148 // There is no way to determine if the focus change was programmatic or due 1149 // to keyboard event, or if it was due to a tap/restore. Use a best-guess 1150 // by using the fact that auto-focus/keyboard tabs set the selection to 0. 1151 setMessageContentSelection(getAccountSignature(mAccount)); 1152 } 1153 } 1154 } 1155 } 1156 1157 private static void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) { 1158 if (addresses == null) { 1159 return; 1160 } 1161 for (Address address : addresses) { 1162 addAddress(view, address.toString()); 1163 } 1164 } 1165 1166 private static void addAddresses(MultiAutoCompleteTextView view, String[] addresses) { 1167 if (addresses == null) { 1168 return; 1169 } 1170 for (String oneAddress : addresses) { 1171 addAddress(view, oneAddress); 1172 } 1173 } 1174 1175 private static void addAddresses(MultiAutoCompleteTextView view, String addresses) { 1176 if (addresses == null) { 1177 return; 1178 } 1179 Address[] unpackedAddresses = Address.unpack(addresses); 1180 for (Address address : unpackedAddresses) { 1181 addAddress(view, address.toString()); 1182 } 1183 } 1184 1185 private static void addAddress(MultiAutoCompleteTextView view, String address) { 1186 view.append(address + ", "); 1187 } 1188 1189 private static String getPackedAddresses(TextView view) { 1190 Address[] addresses = Address.parse(view.getText().toString().trim()); 1191 return Address.pack(addresses); 1192 } 1193 1194 private static Address[] getAddresses(TextView view) { 1195 Address[] addresses = Address.parse(view.getText().toString().trim()); 1196 return addresses; 1197 } 1198 1199 /* 1200 * Computes a short string indicating the destination of the message based on To, Cc, Bcc. 1201 * If only one address appears, returns the friendly form of that address. 1202 * Otherwise returns the friendly form of the first address appended with "and N others". 1203 */ 1204 private String makeDisplayName(String packedTo, String packedCc, String packedBcc) { 1205 Address first = null; 1206 int nRecipients = 0; 1207 for (String packed: new String[] {packedTo, packedCc, packedBcc}) { 1208 Address[] addresses = Address.unpack(packed); 1209 nRecipients += addresses.length; 1210 if (first == null && addresses.length > 0) { 1211 first = addresses[0]; 1212 } 1213 } 1214 if (nRecipients == 0) { 1215 return ""; 1216 } 1217 String friendly = first.toFriendly(); 1218 if (nRecipients == 1) { 1219 return friendly; 1220 } 1221 return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1); 1222 } 1223 1224 private ContentValues getUpdateContentValues(Message message) { 1225 ContentValues values = new ContentValues(); 1226 values.put(MessageColumns.TIMESTAMP, message.mTimeStamp); 1227 values.put(MessageColumns.FROM_LIST, message.mFrom); 1228 values.put(MessageColumns.TO_LIST, message.mTo); 1229 values.put(MessageColumns.CC_LIST, message.mCc); 1230 values.put(MessageColumns.BCC_LIST, message.mBcc); 1231 values.put(MessageColumns.SUBJECT, message.mSubject); 1232 values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName); 1233 values.put(MessageColumns.FLAG_READ, message.mFlagRead); 1234 values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded); 1235 values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment); 1236 values.put(MessageColumns.FLAGS, message.mFlags); 1237 return values; 1238 } 1239 1240 /** 1241 * Updates the given message using values from the compose UI. 1242 * 1243 * @param message The message to be updated. 1244 * @param account the account (used to obtain From: address). 1245 * @param hasAttachments true if it has one or more attachment. 1246 * @param sending set true if the message is about to sent, in which case we perform final 1247 * clean up; 1248 */ 1249 private void updateMessage(Message message, Account account, boolean hasAttachments, 1250 boolean sending) { 1251 if (message.mMessageId == null || message.mMessageId.length() == 0) { 1252 message.mMessageId = Utility.generateMessageId(); 1253 } 1254 message.mTimeStamp = System.currentTimeMillis(); 1255 message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack(); 1256 message.mTo = getPackedAddresses(mToView); 1257 message.mCc = getPackedAddresses(mCcView); 1258 message.mBcc = getPackedAddresses(mBccView); 1259 message.mSubject = mSubjectView.getText().toString(); 1260 message.mText = mMessageContentView.getText().toString(); 1261 message.mAccountKey = account.mId; 1262 message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc); 1263 message.mFlagRead = true; 1264 message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; 1265 message.mFlagAttachment = hasAttachments; 1266 // Use the Intent to set flags saying this message is a reply or a forward and save the 1267 // unique id of the source message 1268 if (mSource != null && mQuotedTextArea.getVisibility() == View.VISIBLE) { 1269 message.mSourceKey = mSource.mId; 1270 // If the quote bar is visible; this must either be a reply or forward 1271 // Get the body of the source message here 1272 message.mHtmlReply = mSource.mHtml; 1273 message.mTextReply = mSource.mText; 1274 String fromAsString = Address.unpackToString(mSource.mFrom); 1275 if (isForward()) { 1276 message.mFlags |= Message.FLAG_TYPE_FORWARD; 1277 String subject = mSource.mSubject; 1278 String to = Address.unpackToString(mSource.mTo); 1279 String cc = Address.unpackToString(mSource.mCc); 1280 message.mIntroText = 1281 getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString, 1282 to != null ? to : "", cc != null ? cc : ""); 1283 } else { 1284 message.mFlags |= Message.FLAG_TYPE_REPLY; 1285 message.mIntroText = 1286 getString(R.string.message_compose_reply_header_fmt, fromAsString); 1287 } 1288 } 1289 1290 if (includeQuotedText()) { 1291 message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 1292 } else { 1293 message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 1294 if (sending) { 1295 // If we are about to send a message, and not including the original message, 1296 // clear the related field. 1297 // We can't do this until the last minutes, so that the user can change their 1298 // mind later and want to include it again. 1299 mDraft.mIntroText = null; 1300 mDraft.mTextReply = null; 1301 mDraft.mHtmlReply = null; 1302 1303 // Note that mSourceKey is not cleared out as this is still considered a 1304 // reply/forward. 1305 } 1306 } 1307 } 1308 1309 private class SendOrSaveMessageTask extends EmailAsyncTask<Void, Void, Long> { 1310 private final boolean mSend; 1311 private final long mTaskId; 1312 1313 /** A context that will survive even past activity destruction. */ 1314 private final Context mContext; 1315 1316 public SendOrSaveMessageTask(long taskId, boolean send) { 1317 super(null /* DO NOT cancel in onDestroy */); 1318 if (send && ActivityManager.isUserAMonkey()) { 1319 Log.d(Logging.LOG_TAG, "Inhibiting send while monkey is in charge."); 1320 send = false; 1321 } 1322 mTaskId = taskId; 1323 mSend = send; 1324 mContext = getApplicationContext(); 1325 1326 sActiveSaveTasks.put(mTaskId, this); 1327 } 1328 1329 @Override 1330 protected Long doInBackground(Void... params) { 1331 synchronized (mDraft) { 1332 updateMessage(mDraft, mAccount, mAttachments.size() > 0, mSend); 1333 ContentResolver resolver = getContentResolver(); 1334 if (mDraft.isSaved()) { 1335 // Update the message 1336 Uri draftUri = 1337 ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId); 1338 resolver.update(draftUri, getUpdateContentValues(mDraft), null, null); 1339 // Update the body 1340 ContentValues values = new ContentValues(); 1341 values.put(BodyColumns.TEXT_CONTENT, mDraft.mText); 1342 values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply); 1343 values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply); 1344 values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText); 1345 values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey); 1346 Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values); 1347 } else { 1348 // mDraft.mId is set upon return of saveToMailbox() 1349 mController.saveToMailbox(mDraft, Mailbox.TYPE_DRAFTS); 1350 } 1351 // For any unloaded attachment, set the flag saying we need it loaded 1352 boolean hasUnloadedAttachments = false; 1353 for (Attachment attachment : mAttachments) { 1354 if (attachment.mContentUri == null && 1355 ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0)) { 1356 attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD; 1357 hasUnloadedAttachments = true; 1358 if (Email.DEBUG) { 1359 Log.d(Logging.LOG_TAG, 1360 "Requesting download of attachment #" + attachment.mId); 1361 } 1362 } 1363 // Make sure the UI version of the attachment has the now-correct id; we will 1364 // use the id again when coming back from picking new attachments 1365 if (!attachment.isSaved()) { 1366 // this attachment is new so save it to DB. 1367 attachment.mMessageKey = mDraft.mId; 1368 attachment.save(MessageCompose.this); 1369 } else if (attachment.mMessageKey != mDraft.mId) { 1370 // We clone the attachment and save it again; otherwise, it will 1371 // continue to point to the source message. From this point forward, 1372 // the attachments will be independent of the original message in the 1373 // database; however, we still need the message on the server in order 1374 // to retrieve unloaded attachments 1375 attachment.mMessageKey = mDraft.mId; 1376 ContentValues cv = attachment.toContentValues(); 1377 cv.put(Attachment.FLAGS, attachment.mFlags); 1378 cv.put(Attachment.MESSAGE_KEY, mDraft.mId); 1379 getContentResolver().insert(Attachment.CONTENT_URI, cv); 1380 } 1381 } 1382 1383 if (mSend) { 1384 // Let the user know if message sending might be delayed by background 1385 // downlading of unloaded attachments 1386 if (hasUnloadedAttachments) { 1387 Utility.showToast(MessageCompose.this, 1388 R.string.message_view_attachment_background_load); 1389 } 1390 mController.sendMessage(mDraft); 1391 1392 ArrayList<CharSequence> addressTexts = new ArrayList<CharSequence>(); 1393 addressTexts.add(mToView.getText()); 1394 addressTexts.add(mCcView.getText()); 1395 addressTexts.add(mBccView.getText()); 1396 DataUsageStatUpdater updater = new DataUsageStatUpdater(mContext); 1397 updater.updateWithRfc822Address(addressTexts); 1398 } 1399 return mDraft.mId; 1400 } 1401 } 1402 1403 private boolean shouldShowSaveToast() { 1404 // Don't show the toast when rotating, or when opening an Activity on top of this one. 1405 return !isChangingConfigurations() && !mPickingAttachment; 1406 } 1407 1408 @Override 1409 protected void onSuccess(Long draftId) { 1410 // Note that send or save tasks are always completed, even if the activity 1411 // finishes earlier. 1412 sActiveSaveTasks.remove(mTaskId); 1413 // Don't display the toast if the user is just changing the orientation 1414 if (!mSend && shouldShowSaveToast()) { 1415 Toast.makeText(mContext, R.string.message_saved_toast, Toast.LENGTH_LONG).show(); 1416 } 1417 } 1418 } 1419 1420 /** 1421 * Send or save a message: 1422 * - out of the UI thread 1423 * - write to Drafts 1424 * - if send, invoke Controller.sendMessage() 1425 * - when operation is complete, display toast 1426 */ 1427 private void sendOrSaveMessage(boolean send) { 1428 if (!mMessageLoaded) { 1429 Log.w(Logging.LOG_TAG, 1430 "Attempted to save draft message prior to the state being fully loaded"); 1431 return; 1432 } 1433 synchronized (sActiveSaveTasks) { 1434 mLastSaveTaskId = sNextSaveTaskId++; 1435 1436 SendOrSaveMessageTask task = new SendOrSaveMessageTask(mLastSaveTaskId, send); 1437 1438 // Ensure the tasks are executed serially so that rapid scheduling doesn't result 1439 // in inconsistent data. 1440 task.executeSerial(); 1441 } 1442 } 1443 1444 private void saveIfNeeded() { 1445 if (!mDraftNeedsSaving) { 1446 return; 1447 } 1448 setMessageChanged(false); 1449 sendOrSaveMessage(false); 1450 } 1451 1452 /** 1453 * Checks whether all the email addresses listed in TO, CC, BCC are valid. 1454 */ 1455 @VisibleForTesting 1456 boolean isAddressAllValid() { 1457 boolean supportsChips = ChipsUtil.supportsChipsUi(); 1458 for (TextView view : new TextView[]{mToView, mCcView, mBccView}) { 1459 String addresses = view.getText().toString().trim(); 1460 if (!Address.isAllValid(addresses)) { 1461 // Don't show an error message if we're using chips as the chips have 1462 // their own error state. 1463 if (!supportsChips || !(view instanceof RecipientEditTextView)) { 1464 view.setError(getString(R.string.message_compose_error_invalid_email)); 1465 } 1466 return false; 1467 } 1468 } 1469 return true; 1470 } 1471 1472 private void onSend() { 1473 if (!isAddressAllValid()) { 1474 Toast.makeText(this, getString(R.string.message_compose_error_invalid_email), 1475 Toast.LENGTH_LONG).show(); 1476 } else if (getAddresses(mToView).length == 0 && 1477 getAddresses(mCcView).length == 0 && 1478 getAddresses(mBccView).length == 0) { 1479 mToView.setError(getString(R.string.message_compose_error_no_recipients)); 1480 Toast.makeText(this, getString(R.string.message_compose_error_no_recipients), 1481 Toast.LENGTH_LONG).show(); 1482 } else { 1483 sendOrSaveMessage(true); 1484 setMessageChanged(false); 1485 finish(); 1486 } 1487 } 1488 1489 private void showQuickResponseDialog() { 1490 if (mAccount == null) { 1491 // Load not finished, bail. 1492 return; 1493 } 1494 InsertQuickResponseDialog.newInstance(null, mAccount) 1495 .show(getFragmentManager(), null); 1496 } 1497 1498 /** 1499 * Inserts the selected QuickResponse into the message body at the current cursor position. 1500 */ 1501 @Override 1502 public void onQuickResponseSelected(CharSequence text) { 1503 int start = mMessageContentView.getSelectionStart(); 1504 int end = mMessageContentView.getSelectionEnd(); 1505 mMessageContentView.getEditableText().replace(start, end, text); 1506 } 1507 1508 private void onDiscard() { 1509 DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog"); 1510 } 1511 1512 /** 1513 * Called when ok on the "discard draft" dialog is pressed. Actually delete the draft. 1514 */ 1515 @Override 1516 public void onDeleteMessageConfirmationDialogOkPressed() { 1517 if (mDraft.mId > 0) { 1518 // By the way, we can't pass the message ID from onDiscard() to here (using a 1519 // dialog argument or whatever), because you can rotate the screen when the dialog is 1520 // shown, and during rotation we save & restore the draft. If it's the 1521 // first save, we give it an ID at this point for the first time (and last time). 1522 // Which means it's possible for a draft to not have an ID in onDiscard(), 1523 // but here. 1524 mController.deleteMessage(mDraft.mId); 1525 } 1526 Utility.showToast(MessageCompose.this, R.string.message_discarded_toast); 1527 setMessageChanged(false); 1528 finish(); 1529 } 1530 1531 /** 1532 * Handles an explicit user-initiated action to save a draft. 1533 */ 1534 private void onSave() { 1535 saveIfNeeded(); 1536 } 1537 1538 private void showCcBccFieldsIfFilled() { 1539 if ((mCcView.length() > 0) || (mBccView.length() > 0)) { 1540 showCcBccFields(); 1541 } 1542 } 1543 1544 private void showCcBccFields() { 1545 if (mCcBccContainer.getVisibility() != View.VISIBLE) { 1546 mCcBccContainer.setVisibility(View.VISIBLE); 1547 mCcView.requestFocus(); 1548 UiUtilities.setVisibilitySafe(this, R.id.add_cc_bcc, View.INVISIBLE); 1549 invalidateOptionsMenu(); 1550 } 1551 } 1552 1553 /** 1554 * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over. 1555 */ 1556 private void onAddAttachment() { 1557 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 1558 i.addCategory(Intent.CATEGORY_OPENABLE); 1559 i.addFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 1560 i.setType(AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]); 1561 mPickingAttachment = true; 1562 startActivityForResult( 1563 Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)), 1564 ACTIVITY_REQUEST_PICK_ATTACHMENT); 1565 } 1566 1567 private Attachment loadAttachmentInfo(Uri uri) { 1568 long size = -1; 1569 ContentResolver contentResolver = getContentResolver(); 1570 1571 // Load name & size independently, because not all providers support both 1572 final String name = Utility.getContentFileName(this, uri); 1573 1574 Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION, 1575 null, null, null); 1576 if (metadataCursor != null) { 1577 try { 1578 if (metadataCursor.moveToFirst()) { 1579 size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE); 1580 } 1581 } finally { 1582 metadataCursor.close(); 1583 } 1584 } 1585 1586 // When the size is not provided, we need to determine it locally. 1587 if (size < 0) { 1588 // if the URI is a file: URI, ask file system for its size 1589 if ("file".equalsIgnoreCase(uri.getScheme())) { 1590 String path = uri.getPath(); 1591 if (path != null) { 1592 File file = new File(path); 1593 size = file.length(); // Returns 0 for file not found 1594 } 1595 } 1596 1597 if (size <= 0) { 1598 // The size was not measurable; This attachment is not safe to use. 1599 // Quick hack to force a relevant error into the UI 1600 // TODO: A proper announcement of the problem 1601 size = AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE + 1; 1602 } 1603 } 1604 1605 Attachment attachment = new Attachment(); 1606 attachment.mFileName = name; 1607 attachment.mContentUri = uri.toString(); 1608 attachment.mSize = size; 1609 attachment.mMimeType = AttachmentUtilities.inferMimeTypeForUri(this, uri); 1610 return attachment; 1611 } 1612 1613 private void addAttachment(Attachment attachment) { 1614 // Before attaching the attachment, make sure it meets any other pre-attach criteria 1615 if (attachment.mSize > AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE) { 1616 Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG) 1617 .show(); 1618 return; 1619 } 1620 1621 mAttachments.add(attachment); 1622 updateAttachmentUi(); 1623 } 1624 1625 private void updateAttachmentUi() { 1626 mAttachmentContentView.removeAllViews(); 1627 1628 for (Attachment attachment : mAttachments) { 1629 // Note: allowDelete is set in two cases: 1630 // 1. First time a message (w/ attachments) is forwarded, 1631 // where action == ACTION_FORWARD 1632 // 2. 1 -> Save -> Reopen 1633 // but FLAG_SMART_FORWARD is already set at 1. 1634 // Even if the account supports smart-forward, attachments added 1635 // manually are still removable. 1636 final boolean allowDelete = (attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0; 1637 1638 View view = getLayoutInflater().inflate(R.layout.attachment, mAttachmentContentView, 1639 false); 1640 TextView nameView = UiUtilities.getView(view, R.id.attachment_name); 1641 ImageView delete = UiUtilities.getView(view, R.id.remove_attachment); 1642 TextView sizeView = UiUtilities.getView(view, R.id.attachment_size); 1643 1644 nameView.setText(attachment.mFileName); 1645 if (attachment.mSize > 0) { 1646 sizeView.setText(UiUtilities.formatSize(this, attachment.mSize)); 1647 } else { 1648 sizeView.setVisibility(View.GONE); 1649 } 1650 if (allowDelete) { 1651 delete.setOnClickListener(this); 1652 delete.setTag(view); 1653 } else { 1654 delete.setVisibility(View.INVISIBLE); 1655 } 1656 view.setTag(attachment); 1657 mAttachmentContentView.addView(view); 1658 } 1659 updateAttachmentContainer(); 1660 } 1661 1662 private void updateAttachmentContainer() { 1663 mAttachmentContainer.setVisibility(mAttachmentContentView.getChildCount() == 0 1664 ? View.GONE : View.VISIBLE); 1665 } 1666 1667 private void addAttachmentFromUri(Uri uri) { 1668 addAttachment(loadAttachmentInfo(uri)); 1669 } 1670 1671 /** 1672 * Same as {@link #addAttachmentFromUri}, but does the mime-type check against 1673 * {@link AttachmentUtilities#ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES}. 1674 */ 1675 private void addAttachmentFromSendIntent(Uri uri) { 1676 final Attachment attachment = loadAttachmentInfo(uri); 1677 final String mimeType = attachment.mMimeType; 1678 if (!TextUtils.isEmpty(mimeType) && MimeUtility.mimeTypeMatches(mimeType, 1679 AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) { 1680 addAttachment(attachment); 1681 } 1682 } 1683 1684 @Override 1685 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 1686 mPickingAttachment = false; 1687 if (data == null) { 1688 return; 1689 } 1690 addAttachmentFromUri(data.getData()); 1691 setMessageChanged(true); 1692 } 1693 1694 private boolean includeQuotedText() { 1695 return mIncludeQuotedTextCheckBox.isChecked(); 1696 } 1697 1698 @Override 1699 public void onClick(View view) { 1700 if (handleCommand(view.getId())) { 1701 return; 1702 } 1703 switch (view.getId()) { 1704 case R.id.remove_attachment: 1705 onDeleteAttachmentIconClicked(view); 1706 break; 1707 } 1708 } 1709 1710 private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) { 1711 mIncludeQuotedTextCheckBox.setChecked(include); 1712 mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked() 1713 ? View.VISIBLE : View.GONE); 1714 if (updateNeedsSaving) { 1715 setMessageChanged(true); 1716 } 1717 } 1718 1719 private void onDeleteAttachmentIconClicked(View delButtonView) { 1720 View attachmentView = (View) delButtonView.getTag(); 1721 Attachment attachment = (Attachment) attachmentView.getTag(); 1722 deleteAttachment(mAttachments, attachment); 1723 updateAttachmentUi(); 1724 setMessageChanged(true); 1725 } 1726 1727 /** 1728 * Removes an attachment from the current message. 1729 * If the attachment has previous been saved in the db (i.e. this is a draft message which 1730 * has previously been saved), then the draft is deleted from the db. 1731 * 1732 * This does not update the UI to remove the attachment view. 1733 * @param attachments the list of attachments to delete from. Injected for tests. 1734 * @param attachment the attachment to delete 1735 */ 1736 private void deleteAttachment(List<Attachment> attachments, Attachment attachment) { 1737 attachments.remove(attachment); 1738 if ((attachment.mMessageKey == mDraft.mId) && attachment.isSaved()) { 1739 final long attachmentId = attachment.mId; 1740 EmailAsyncTask.runAsyncParallel(new Runnable() { 1741 @Override 1742 public void run() { 1743 mController.deleteAttachment(attachmentId); 1744 } 1745 }); 1746 } 1747 } 1748 1749 @Override 1750 public boolean onOptionsItemSelected(MenuItem item) { 1751 if (handleCommand(item.getItemId())) { 1752 return true; 1753 } 1754 return super.onOptionsItemSelected(item); 1755 } 1756 1757 private boolean handleCommand(int viewId) { 1758 switch (viewId) { 1759 case android.R.id.home: 1760 onBack(false /* systemKey */); 1761 return true; 1762 case R.id.send: 1763 onSend(); 1764 return true; 1765 case R.id.save: 1766 onSave(); 1767 return true; 1768 case R.id.show_quick_text_list_dialog: 1769 showQuickResponseDialog(); 1770 return true; 1771 case R.id.discard: 1772 onDiscard(); 1773 return true; 1774 case R.id.include_quoted_text: 1775 // The checkbox is already toggled at this point. 1776 setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true); 1777 return true; 1778 case R.id.add_cc_bcc: 1779 showCcBccFields(); 1780 return true; 1781 case R.id.add_attachment: 1782 onAddAttachment(); 1783 return true; 1784 case R.id.settings: 1785 AccountSettings.actionSettings(this, mAccount.mId); 1786 return true; 1787 } 1788 return false; 1789 } 1790 1791 /** 1792 * Handle a tap to the system back key, or the "app up" button in the action bar. 1793 * @param systemKey whether or not the system key was pressed 1794 */ 1795 private void onBack(boolean systemKey) { 1796 finish(); 1797 if (isOpenedFromWithinApp()) { 1798 // If opened from within the app, we just close it. 1799 return; 1800 } 1801 1802 if ((isOpenedFromWidget() || !systemKey) && (mAccount != null)) { 1803 // Otherwise, need to open the main screen for the appropriate account. 1804 // Note that mAccount should always be set by the time the action bar is set up. 1805 startActivity(Welcome.createOpenAccountInboxIntent(this, mAccount.mId)); 1806 } 1807 } 1808 1809 private void setAction(String action) { 1810 if (Objects.equal(action, mAction)) { 1811 return; 1812 } 1813 1814 mAction = action; 1815 onActionChanged(); 1816 } 1817 1818 /** 1819 * Handles changing from reply/reply all/forward states. Note: this activity cannot transition 1820 * from a standard compose state to any of the other three states. 1821 */ 1822 private void onActionChanged() { 1823 if (!hasSourceMessage()) { 1824 return; 1825 } 1826 // Temporarily remove listeners so that changing action does not invalidate and save message 1827 removeListeners(); 1828 1829 processSourceMessage(mSource, mAccount); 1830 1831 // Note that the attachments might not be loaded yet, but this will safely noop 1832 // if that's the case, and the attachments will be processed when they load. 1833 if (processSourceMessageAttachments(mAttachments, mSourceAttachments, isForward())) { 1834 updateAttachmentUi(); 1835 setMessageChanged(true); 1836 } 1837 1838 updateActionSelector(); 1839 addListeners(); 1840 } 1841 1842 /** 1843 * Updates UI components that allows the user to switch between reply/reply all/forward. 1844 */ 1845 private void updateActionSelector() { 1846 ActionBar actionBar = getActionBar(); 1847 // Spinner based mode switching. 1848 if (mActionSpinnerAdapter == null) { 1849 mActionSpinnerAdapter = new ActionSpinnerAdapter(this); 1850 actionBar.setListNavigationCallbacks(mActionSpinnerAdapter, ACTION_SPINNER_LISTENER); 1851 } 1852 actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); 1853 actionBar.setSelectedNavigationItem(ActionSpinnerAdapter.getActionPosition(mAction)); 1854 actionBar.setDisplayShowTitleEnabled(false); 1855 } 1856 1857 private final OnNavigationListener ACTION_SPINNER_LISTENER = new OnNavigationListener() { 1858 @Override 1859 public boolean onNavigationItemSelected(int itemPosition, long itemId) { 1860 setAction(ActionSpinnerAdapter.getAction(itemPosition)); 1861 return true; 1862 } 1863 }; 1864 1865 private static class ActionSpinnerAdapter extends ArrayAdapter<String> { 1866 public ActionSpinnerAdapter(final Context context) { 1867 super(context, 1868 android.R.layout.simple_spinner_dropdown_item, 1869 android.R.id.text1, 1870 Lists.newArrayList(ACTION_REPLY, ACTION_REPLY_ALL, ACTION_FORWARD)); 1871 } 1872 1873 @Override 1874 public View getDropDownView(int position, View convertView, ViewGroup parent) { 1875 View result = super.getDropDownView(position, convertView, parent); 1876 ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position)); 1877 return result; 1878 } 1879 1880 @Override 1881 public View getView(int position, View convertView, ViewGroup parent) { 1882 View result = super.getView(position, convertView, parent); 1883 ((TextView) result.findViewById(android.R.id.text1)).setText(getDisplayValue(position)); 1884 return result; 1885 } 1886 1887 private String getDisplayValue(int position) { 1888 switch (position) { 1889 case 0: 1890 return getContext().getString(R.string.reply_action); 1891 case 1: 1892 return getContext().getString(R.string.reply_all_action); 1893 case 2: 1894 return getContext().getString(R.string.forward_action); 1895 default: 1896 throw new IllegalArgumentException("Invalid action type for spinner"); 1897 } 1898 } 1899 1900 public static String getAction(int position) { 1901 switch (position) { 1902 case 0: 1903 return ACTION_REPLY; 1904 case 1: 1905 return ACTION_REPLY_ALL; 1906 case 2: 1907 return ACTION_FORWARD; 1908 default: 1909 throw new IllegalArgumentException("Invalid action type for spinner"); 1910 } 1911 } 1912 1913 public static int getActionPosition(String action) { 1914 if (ACTION_REPLY.equals(action)) { 1915 return 0; 1916 } else if (ACTION_REPLY_ALL.equals(action)) { 1917 return 1; 1918 } else if (ACTION_FORWARD.equals(action)) { 1919 return 2; 1920 } 1921 Log.w(Logging.LOG_TAG, "Invalid action type for spinner"); 1922 return -1; 1923 } 1924 } 1925 1926 @Override 1927 public boolean onCreateOptionsMenu(Menu menu) { 1928 super.onCreateOptionsMenu(menu); 1929 getMenuInflater().inflate(R.menu.message_compose_option, menu); 1930 return true; 1931 } 1932 1933 @Override 1934 public boolean onPrepareOptionsMenu(Menu menu) { 1935 menu.findItem(R.id.save).setEnabled(mDraftNeedsSaving); 1936 MenuItem addCcBcc = menu.findItem(R.id.add_cc_bcc); 1937 if (addCcBcc != null) { 1938 // Only available on phones. 1939 addCcBcc.setVisible( 1940 (mCcBccContainer == null) || (mCcBccContainer.getVisibility() != View.VISIBLE)); 1941 } 1942 MenuItem insertQuickResponse = menu.findItem(R.id.show_quick_text_list_dialog); 1943 insertQuickResponse.setVisible(mQuickResponsesAvailable); 1944 insertQuickResponse.setEnabled(mQuickResponsesAvailable); 1945 return true; 1946 } 1947 1948 /** 1949 * Set a message body and a signature when the Activity is launched. 1950 * 1951 * @param text the message body 1952 */ 1953 @VisibleForTesting 1954 void setInitialComposeText(CharSequence text, String signature) { 1955 mMessageContentView.setText(""); 1956 int textLength = 0; 1957 if (text != null) { 1958 mMessageContentView.append(text); 1959 textLength = text.length(); 1960 } 1961 if (!TextUtils.isEmpty(signature)) { 1962 if (textLength == 0 || text.charAt(textLength - 1) != '\n') { 1963 mMessageContentView.append("\n"); 1964 } 1965 mMessageContentView.append(signature); 1966 1967 // Reset cursor to right before the signature. 1968 mMessageContentView.setSelection(textLength); 1969 } 1970 } 1971 1972 /** 1973 * Fill all the widgets with the content found in the Intent Extra, if any. 1974 * 1975 * Note that we don't actually check the intent action (typically VIEW, SENDTO, or SEND). 1976 * There is enough overlap in the definitions that it makes more sense to simply check for 1977 * all available data and use as much of it as possible. 1978 * 1979 * With one exception: EXTRA_STREAM is defined as only valid for ACTION_SEND. 1980 * 1981 * @param intent the launch intent 1982 */ 1983 @VisibleForTesting 1984 void initFromIntent(Intent intent) { 1985 1986 setAccount(intent); 1987 1988 // First, add values stored in top-level extras 1989 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 1990 if (extraStrings != null) { 1991 addAddresses(mToView, extraStrings); 1992 } 1993 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 1994 if (extraStrings != null) { 1995 addAddresses(mCcView, extraStrings); 1996 } 1997 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 1998 if (extraStrings != null) { 1999 addAddresses(mBccView, extraStrings); 2000 } 2001 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 2002 if (extraString != null) { 2003 mSubjectView.setText(extraString); 2004 } 2005 2006 // Next, if we were invoked with a URI, try to interpret it 2007 // We'll take two courses here. If it's mailto:, there is a specific set of rules 2008 // that define various optional fields. However, for any other scheme, we'll simply 2009 // take the entire scheme-specific part and interpret it as a possible list of addresses. 2010 final Uri dataUri = intent.getData(); 2011 if (dataUri != null) { 2012 if ("mailto".equals(dataUri.getScheme())) { 2013 initializeFromMailTo(dataUri.toString()); 2014 } else { 2015 String toText = dataUri.getSchemeSpecificPart(); 2016 if (toText != null) { 2017 addAddresses(mToView, toText.split(",")); 2018 } 2019 } 2020 } 2021 2022 // Next, fill in the plaintext (note, this will override mailto:?body=) 2023 CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); 2024 setInitialComposeText(text, getAccountSignature(mAccount)); 2025 2026 // Next, convert EXTRA_STREAM into an attachment 2027 if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) { 2028 Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); 2029 if (uri != null) { 2030 addAttachmentFromSendIntent(uri); 2031 } 2032 } 2033 2034 if (Intent.ACTION_SEND_MULTIPLE.equals(mAction) 2035 && intent.hasExtra(Intent.EXTRA_STREAM)) { 2036 ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 2037 if (list != null) { 2038 for (Parcelable parcelable : list) { 2039 Uri uri = (Uri) parcelable; 2040 if (uri != null) { 2041 addAttachmentFromSendIntent(uri); 2042 } 2043 } 2044 } 2045 } 2046 2047 // Finally - expose fields that were filled in but are normally hidden, and set focus 2048 showCcBccFieldsIfFilled(); 2049 setNewMessageFocus(); 2050 } 2051 2052 /** 2053 * When we are launched with an intent that includes a mailto: URI, we can actually 2054 * gather quite a few of our message fields from it. 2055 * 2056 * @param mailToString the href (which must start with "mailto:"). 2057 */ 2058 private void initializeFromMailTo(String mailToString) { 2059 2060 // Chop up everything between mailto: and ? to find recipients 2061 int index = mailToString.indexOf("?"); 2062 int length = "mailto".length() + 1; 2063 String to; 2064 try { 2065 // Extract the recipient after mailto: 2066 if (index == -1) { 2067 to = decode(mailToString.substring(length)); 2068 } else { 2069 to = decode(mailToString.substring(length, index)); 2070 } 2071 addAddresses(mToView, to.split(" ,")); 2072 } catch (UnsupportedEncodingException e) { 2073 Log.e(Logging.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'"); 2074 } 2075 2076 // Extract the other parameters 2077 2078 // We need to disguise this string as a URI in order to parse it 2079 Uri uri = Uri.parse("foo://" + mailToString); 2080 2081 List<String> cc = uri.getQueryParameters("cc"); 2082 addAddresses(mCcView, cc.toArray(new String[cc.size()])); 2083 2084 List<String> otherTo = uri.getQueryParameters("to"); 2085 addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()])); 2086 2087 List<String> bcc = uri.getQueryParameters("bcc"); 2088 addAddresses(mBccView, bcc.toArray(new String[bcc.size()])); 2089 2090 List<String> subject = uri.getQueryParameters("subject"); 2091 if (subject.size() > 0) { 2092 mSubjectView.setText(subject.get(0)); 2093 } 2094 2095 List<String> body = uri.getQueryParameters("body"); 2096 if (body.size() > 0) { 2097 setInitialComposeText(body.get(0), getAccountSignature(mAccount)); 2098 } 2099 } 2100 2101 private String decode(String s) throws UnsupportedEncodingException { 2102 return URLDecoder.decode(s, "UTF-8"); 2103 } 2104 2105 /** 2106 * Displays quoted text from the original email 2107 */ 2108 private void displayQuotedText(String textBody, String htmlBody) { 2109 // Only use plain text if there is no HTML body 2110 boolean plainTextFlag = TextUtils.isEmpty(htmlBody); 2111 String text = plainTextFlag ? textBody : htmlBody; 2112 if (text != null) { 2113 text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text; 2114 // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML 2115 // EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount, 2116 // text, message, 0); 2117 mQuotedTextArea.setVisibility(View.VISIBLE); 2118 if (mQuotedText != null) { 2119 mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); 2120 } 2121 } 2122 } 2123 2124 /** 2125 * Given a packed address String, the address of our sending account, a view, and a list of 2126 * addressees already added to other addressing views, adds unique addressees that don't 2127 * match our address to the passed in view 2128 */ 2129 private static boolean safeAddAddresses(String addrs, String ourAddress, 2130 MultiAutoCompleteTextView view, ArrayList<Address> addrList) { 2131 boolean added = false; 2132 for (Address address : Address.unpack(addrs)) { 2133 // Don't send to ourselves or already-included addresses 2134 if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) { 2135 addrList.add(address); 2136 addAddress(view, address.toString()); 2137 added = true; 2138 } 2139 } 2140 return added; 2141 } 2142 2143 /** 2144 * Set up the to and cc views properly for the "reply" and "replyAll" cases. What's important 2145 * is that we not 1) send to ourselves, and 2) duplicate addressees. 2146 * @param message the message we're replying to 2147 * @param account the account we're sending from 2148 * @param replyAll whether this is a replyAll (vs a reply) 2149 */ 2150 @VisibleForTesting 2151 void setupAddressViews(Message message, Account account, boolean replyAll) { 2152 // Start clean. 2153 clearAddressViews(); 2154 2155 // If Reply-to: addresses are included, use those; otherwise, use the From: address. 2156 Address[] replyToAddresses = Address.unpack(message.mReplyTo); 2157 if (replyToAddresses.length == 0) { 2158 replyToAddresses = Address.unpack(message.mFrom); 2159 } 2160 2161 // Check if ourAddress is one of the replyToAddresses to decide how to populate To: field 2162 String ourAddress = account.mEmailAddress; 2163 boolean containsOurAddress = false; 2164 for (Address address : replyToAddresses) { 2165 if (ourAddress.equalsIgnoreCase(address.getAddress())) { 2166 containsOurAddress = true; 2167 break; 2168 } 2169 } 2170 2171 if (containsOurAddress) { 2172 addAddresses(mToView, message.mTo); 2173 } else { 2174 addAddresses(mToView, replyToAddresses); 2175 } 2176 2177 if (replyAll) { 2178 // Keep a running list of addresses we're sending to 2179 ArrayList<Address> allAddresses = new ArrayList<Address>(); 2180 for (Address address: replyToAddresses) { 2181 allAddresses.add(address); 2182 } 2183 2184 if (!containsOurAddress) { 2185 safeAddAddresses(message.mTo, ourAddress, mCcView, allAddresses); 2186 } 2187 2188 safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses); 2189 } 2190 showCcBccFieldsIfFilled(); 2191 } 2192 2193 private void clearAddressViews() { 2194 mToView.setText(""); 2195 mCcView.setText(""); 2196 mBccView.setText(""); 2197 } 2198 2199 /** 2200 * Pull out the parts of the now loaded source message and apply them to the new message 2201 * depending on the type of message being composed. 2202 */ 2203 @VisibleForTesting 2204 void processSourceMessage(Message message, Account account) { 2205 String subject = message.mSubject; 2206 if (subject == null) { 2207 subject = ""; 2208 } 2209 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) { 2210 setupAddressViews(message, account, ACTION_REPLY_ALL.equals(mAction)); 2211 if (!subject.toLowerCase().startsWith("re:")) { 2212 mSubjectView.setText("Re: " + subject); 2213 } else { 2214 mSubjectView.setText(subject); 2215 } 2216 displayQuotedText(message.mText, message.mHtml); 2217 setIncludeQuotedText(true, false); 2218 } else if (ACTION_FORWARD.equals(mAction)) { 2219 // If we had previously filled the recipients from a draft, don't erase them here! 2220 if (!ACTION_EDIT_DRAFT.equals(getIntent().getAction())) { 2221 clearAddressViews(); 2222 } 2223 mSubjectView.setText(!subject.toLowerCase().startsWith("fwd:") 2224 ? "Fwd: " + subject : subject); 2225 displayQuotedText(message.mText, message.mHtml); 2226 setIncludeQuotedText(true, false); 2227 } else { 2228 Log.w(Logging.LOG_TAG, "Unexpected action for a call to processSourceMessage " 2229 + mAction); 2230 } 2231 showCcBccFieldsIfFilled(); 2232 setNewMessageFocus(); 2233 } 2234 2235 /** 2236 * Processes the source attachments and ensures they're either included or excluded from 2237 * a list of active attachments. This can be used to add attachments for a forwarded message, or 2238 * to remove them if going from a "Forward" to a "Reply" 2239 * Uniqueness is based on filename. 2240 * 2241 * @param current the list of active attachments on the current message. Injected for tests. 2242 * @param sourceAttachments the list of attachments related with the source message. Injected 2243 * for tests. 2244 * @param include whether or not the sourceMessages should be included or excluded from the 2245 * current list of active attachments 2246 * @return whether or not the current attachments were modified 2247 */ 2248 @VisibleForTesting 2249 boolean processSourceMessageAttachments( 2250 List<Attachment> current, List<Attachment> sourceAttachments, boolean include) { 2251 2252 // Build a map of filename to the active attachments. 2253 HashMap<String, Attachment> currentNames = new HashMap<String, Attachment>(); 2254 for (Attachment attachment : current) { 2255 currentNames.put(attachment.mFileName, attachment); 2256 } 2257 2258 boolean dirty = false; 2259 if (include) { 2260 // Needs to make sure it's in the list. 2261 for (Attachment attachment : sourceAttachments) { 2262 if (!currentNames.containsKey(attachment.mFileName)) { 2263 current.add(attachment); 2264 dirty = true; 2265 } 2266 } 2267 } else { 2268 // Need to remove the source attachments. 2269 HashSet<String> sourceNames = new HashSet<String>(); 2270 for (Attachment attachment : sourceAttachments) { 2271 if (currentNames.containsKey(attachment.mFileName)) { 2272 deleteAttachment(current, currentNames.get(attachment.mFileName)); 2273 dirty = true; 2274 } 2275 } 2276 } 2277 2278 return dirty; 2279 } 2280 2281 /** 2282 * Set a cursor to the end of a body except a signature. 2283 */ 2284 @VisibleForTesting 2285 void setMessageContentSelection(String signature) { 2286 int selection = mMessageContentView.length(); 2287 if (!TextUtils.isEmpty(signature)) { 2288 int signatureLength = signature.length(); 2289 int estimatedSelection = selection - signatureLength; 2290 if (estimatedSelection >= 0) { 2291 CharSequence text = mMessageContentView.getText(); 2292 int i = 0; 2293 while (i < signatureLength 2294 && text.charAt(estimatedSelection + i) == signature.charAt(i)) { 2295 ++i; 2296 } 2297 if (i == signatureLength) { 2298 selection = estimatedSelection; 2299 while (selection > 0 && text.charAt(selection - 1) == '\n') { 2300 --selection; 2301 } 2302 } 2303 } 2304 } 2305 mMessageContentView.setSelection(selection, selection); 2306 } 2307 2308 /** 2309 * In order to accelerate typing, position the cursor in the first empty field, 2310 * or at the end of the body composition field if none are empty. Typically, this will 2311 * play out as follows: 2312 * Reply / Reply All - put cursor in the empty message body 2313 * Forward - put cursor in the empty To field 2314 * Edit Draft - put cursor in whatever field still needs entry 2315 */ 2316 private void setNewMessageFocus() { 2317 if (mToView.length() == 0) { 2318 mToView.requestFocus(); 2319 } else if (mSubjectView.length() == 0) { 2320 mSubjectView.requestFocus(); 2321 } else { 2322 mMessageContentView.requestFocus(); 2323 } 2324 } 2325 2326 private boolean isForward() { 2327 return ACTION_FORWARD.equals(mAction); 2328 } 2329 2330 /** 2331 * @return the signature for the specified account, if non-null. If the account specified is 2332 * null or has no signature, {@code null} is returned. 2333 */ 2334 private static String getAccountSignature(Account account) { 2335 return (account == null) ? null : account.mSignature; 2336 } 2337 } 2338