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