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