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