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