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