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