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