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