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