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