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