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