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