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