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