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