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