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