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