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