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