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