MessageCompose.java revision c0033f24a26a08c47aa38d957f42cf63cfa3c345
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.Email; 20import com.android.email.EmailAddressAdapter; 21import com.android.email.EmailAddressValidator; 22import com.android.email.MessagingController; 23import com.android.email.MessagingListener; 24import com.android.email.R; 25import com.android.email.Utility; 26import com.android.email.mail.Address; 27import com.android.email.mail.Body; 28import com.android.email.mail.Message; 29import com.android.email.mail.MessagingException; 30import com.android.email.mail.Multipart; 31import com.android.email.mail.Part; 32import com.android.email.mail.Message.RecipientType; 33import com.android.email.mail.internet.EmailHtmlUtil; 34import com.android.email.mail.internet.MimeBodyPart; 35import com.android.email.mail.internet.MimeHeader; 36import com.android.email.mail.internet.MimeMessage; 37import com.android.email.mail.internet.MimeMultipart; 38import com.android.email.mail.internet.MimeUtility; 39import com.android.email.mail.internet.TextBody; 40import com.android.email.mail.store.LocalStore; 41import com.android.email.mail.store.LocalStore.LocalAttachmentBody; 42import com.android.email.provider.EmailStore; 43 44import android.app.Activity; 45import android.content.ActivityNotFoundException; 46import android.content.ContentResolver; 47import android.content.Context; 48import android.content.Intent; 49import android.content.pm.ActivityInfo; 50import android.database.Cursor; 51import android.net.Uri; 52import android.os.Bundle; 53import android.os.Handler; 54import android.os.Parcelable; 55import android.provider.OpenableColumns; 56import android.text.InputFilter; 57import android.text.SpannableStringBuilder; 58import android.text.Spanned; 59import android.text.TextWatcher; 60import android.text.util.Rfc822Tokenizer; 61import android.util.Config; 62import android.util.Log; 63import android.view.Menu; 64import android.view.MenuItem; 65import android.view.View; 66import android.view.Window; 67import android.view.View.OnClickListener; 68import android.view.View.OnFocusChangeListener; 69import android.webkit.WebView; 70import android.widget.Button; 71import android.widget.EditText; 72import android.widget.ImageButton; 73import android.widget.LinearLayout; 74import android.widget.MultiAutoCompleteTextView; 75import android.widget.TextView; 76import android.widget.Toast; 77import android.widget.AutoCompleteTextView.Validator; 78 79import java.io.Serializable; 80import java.io.UnsupportedEncodingException; 81import java.net.URLDecoder; 82import java.util.ArrayList; 83import java.util.Date; 84import java.util.List; 85 86public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener { 87 private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY"; 88 private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL"; 89 private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD"; 90 private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT"; 91 92 private static final String EXTRA_ACCOUNT_ID = "account_id"; 93 private static final String EXTRA_FOLDER = "folder"; 94 private static final String EXTRA_MESSAGE = "message"; 95 96 private static final String STATE_KEY_ATTACHMENTS = 97 "com.android.email.activity.MessageCompose.attachments"; 98 private static final String STATE_KEY_CC_SHOWN = 99 "com.android.email.activity.MessageCompose.ccShown"; 100 private static final String STATE_KEY_BCC_SHOWN = 101 "com.android.email.activity.MessageCompose.bccShown"; 102 private static final String STATE_KEY_QUOTED_TEXT_SHOWN = 103 "com.android.email.activity.MessageCompose.quotedTextShown"; 104 private static final String STATE_KEY_SOURCE_MESSAGE_PROCED = 105 "com.android.email.activity.MessageCompose.stateKeySourceMessageProced"; 106 private static final String STATE_KEY_DRAFT_UID = 107 "com.android.email.activity.MessageCompose.draftUid"; 108 109 private static final int MSG_PROGRESS_ON = 1; 110 private static final int MSG_PROGRESS_OFF = 2; 111 private static final int MSG_UPDATE_TITLE = 3; 112 private static final int MSG_SKIPPED_ATTACHMENTS = 4; 113 private static final int MSG_SAVED_DRAFT = 5; 114 private static final int MSG_DISCARDED_DRAFT = 6; 115 116 private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1; 117 118 private long mAccountId; 119 private EmailStore.Account mAccount; 120 private String mFolder; 121 private String mSourceMessageUid; 122 private Message mSourceMessage; 123 /** 124 * Indicates that the source message has been processed at least once and should not 125 * be processed on any subsequent loads. This protects us from adding attachments that 126 * have already been added from the restore of the view state. 127 */ 128 private boolean mSourceMessageProcessed = false; 129 130 private MultiAutoCompleteTextView mToView; 131 private MultiAutoCompleteTextView mCcView; 132 private MultiAutoCompleteTextView mBccView; 133 private EditText mSubjectView; 134 private EditText mMessageContentView; 135 private Button mSendButton; 136 private Button mDiscardButton; 137 private Button mSaveButton; 138 private LinearLayout mAttachments; 139 private View mQuotedTextBar; 140 private ImageButton mQuotedTextDelete; 141 private WebView mQuotedText; 142 143 private boolean mDraftNeedsSaving = false; 144 145 /** 146 * The draft uid of this message. This is used when saving drafts so that the same draft is 147 * overwritten instead of being created anew. This property is null until the first save. 148 */ 149 private String mDraftUid; 150 151 private Handler mHandler = new Handler() { 152 @Override 153 public void handleMessage(android.os.Message msg) { 154 switch (msg.what) { 155 case MSG_PROGRESS_ON: 156 setProgressBarIndeterminateVisibility(true); 157 break; 158 case MSG_PROGRESS_OFF: 159 setProgressBarIndeterminateVisibility(false); 160 break; 161 case MSG_UPDATE_TITLE: 162 updateTitle(); 163 break; 164 case MSG_SKIPPED_ATTACHMENTS: 165 Toast.makeText( 166 MessageCompose.this, 167 getString(R.string.message_compose_attachments_skipped_toast), 168 Toast.LENGTH_LONG).show(); 169 break; 170 case MSG_SAVED_DRAFT: 171 Toast.makeText( 172 MessageCompose.this, 173 getString(R.string.message_saved_toast), 174 Toast.LENGTH_LONG).show(); 175 break; 176 case MSG_DISCARDED_DRAFT: 177 Toast.makeText( 178 MessageCompose.this, 179 getString(R.string.message_discarded_toast), 180 Toast.LENGTH_LONG).show(); 181 break; 182 default: 183 super.handleMessage(msg); 184 break; 185 } 186 } 187 }; 188 189 private Listener mListener = new Listener(); 190 private EmailAddressAdapter mAddressAdapter; 191 private Validator mAddressValidator; 192 193 /** 194 * Encapsulates known information about a single attachment. 195 */ 196 private static class Attachment implements Serializable { 197 public String name; 198 public String contentType; 199 public long size; 200 public Uri uri; 201 } 202 203 /** 204 * Compose a new message using the given account. If account is -1 the default account 205 * will be used. 206 * @param context 207 * @param account 208 */ 209 public static void actionCompose(Context context, long accountId) { 210 try { 211 Intent i = new Intent(context, MessageCompose.class); 212 i.putExtra(EXTRA_ACCOUNT_ID, accountId); 213 /* 214 * TODO handle drafts via role # note via string 215 * Is this used? Do we ever compose anywhere else but in drafts? 216 */ 217 i.putExtra(EXTRA_FOLDER, context.getString(R.string.special_mailbox_name_drafts)); 218 context.startActivity(i); 219 } catch (ActivityNotFoundException anfe) { 220 // Swallow it - this is usually a race condition, especially under automated test. 221 // (The message composer might have been disabled) 222 if (Config.LOGD) { 223 Log.d(Email.LOG_TAG, anfe.toString()); 224 } 225 } 226 } 227 228 /** 229 * Compose a new message using the given account, or the default account if -1 230 * @param context 231 * @param defaultAccountId 232 */ 233 public static void actionCompose(Activity context, long accountId) { 234 // TODO Auto-generated method stub 235 // DO NOT CHECK IN until written 236 } 237 238 /** 239 * Compose a new message as a reply to the given message. If replyAll is true the function 240 * is reply all instead of simply reply. 241 * @param context 242 * @param account 243 * @param message 244 * @param replyAll 245 */ 246 public static void actionReply( 247 Context context, 248 long accountId, 249 Message message, 250 boolean replyAll) { 251 Intent i = new Intent(context, MessageCompose.class); 252 i.putExtra(EXTRA_ACCOUNT_ID, accountId); 253 i.putExtra(EXTRA_FOLDER, message.getFolder().getName()); 254 i.putExtra(EXTRA_MESSAGE, message.getUid()); 255 if (replyAll) { 256 i.setAction(ACTION_REPLY_ALL); 257 } 258 else { 259 i.setAction(ACTION_REPLY); 260 } 261 context.startActivity(i); 262 } 263 264 /** 265 * Compose a new message as a forward of the given message. 266 * @param context 267 * @param account 268 * @param message 269 */ 270 public static void actionForward(Context context, long accountId, Message message) { 271 Intent i = new Intent(context, MessageCompose.class); 272 i.putExtra(EXTRA_ACCOUNT_ID, accountId); 273 i.putExtra(EXTRA_FOLDER, message.getFolder().getName()); 274 i.putExtra(EXTRA_MESSAGE, message.getUid()); 275 i.setAction(ACTION_FORWARD); 276 context.startActivity(i); 277 } 278 279 /** 280 * Continue composition of the given message. This action modifies the way this Activity 281 * handles certain actions. 282 * Save will attempt to replace the message in the given folder with the updated version. 283 * Discard will delete the message from the given folder. 284 * @param context 285 * @param account 286 * @param message 287 */ 288 public static void actionEditDraft(Context context, long accountId, Message message) { 289 Intent i = new Intent(context, MessageCompose.class); 290 i.putExtra(EXTRA_ACCOUNT_ID, accountId); 291 i.putExtra(EXTRA_FOLDER, message.getFolder().getName()); 292 i.putExtra(EXTRA_MESSAGE, message.getUid()); 293 i.setAction(ACTION_EDIT_DRAFT); 294 context.startActivity(i); 295 } 296 297 @Override 298 public void onCreate(Bundle savedInstanceState) { 299 super.onCreate(savedInstanceState); 300 301 requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); 302 303 setContentView(R.layout.message_compose); 304 305 mAddressAdapter = new EmailAddressAdapter(this); 306 mAddressValidator = new EmailAddressValidator(); 307 308 mToView = (MultiAutoCompleteTextView)findViewById(R.id.to); 309 mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc); 310 mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc); 311 mSubjectView = (EditText)findViewById(R.id.subject); 312 mMessageContentView = (EditText)findViewById(R.id.message_content); 313 mSendButton = (Button)findViewById(R.id.send); 314 mDiscardButton = (Button)findViewById(R.id.discard); 315 mSaveButton = (Button)findViewById(R.id.save); 316 mAttachments = (LinearLayout)findViewById(R.id.attachments); 317 mQuotedTextBar = findViewById(R.id.quoted_text_bar); 318 mQuotedTextDelete = (ImageButton)findViewById(R.id.quoted_text_delete); 319 mQuotedText = (WebView)findViewById(R.id.quoted_text); 320 321 TextWatcher watcher = new TextWatcher() { 322 public void beforeTextChanged(CharSequence s, int start, 323 int before, int after) { } 324 325 public void onTextChanged(CharSequence s, int start, 326 int before, int count) { 327 mDraftNeedsSaving = true; 328 } 329 330 public void afterTextChanged(android.text.Editable s) { } 331 }; 332 333 /** 334 * Implements special address cleanup rules: 335 * The first space key entry following an "@" symbol that is followed by any combination 336 * of letters and symbols, including one+ dots and zero commas, should insert an extra 337 * comma (followed by the space). 338 */ 339 InputFilter recipientFilter = new InputFilter() { 340 341 public CharSequence filter(CharSequence source, int start, int end, Spanned dest, 342 int dstart, int dend) { 343 344 // quick check - did they enter a single space? 345 if (end-start != 1 || source.charAt(start) != ' ') { 346 return null; 347 } 348 349 // determine if the characters before the new space fit the pattern 350 // follow backwards and see if we find a comma, dot, or @ 351 int scanBack = dstart; 352 boolean dotFound = false; 353 while (scanBack > 0) { 354 char c = dest.charAt(--scanBack); 355 switch (c) { 356 case '.': 357 dotFound = true; // one or more dots are req'd 358 break; 359 case ',': 360 return null; 361 case '@': 362 if (!dotFound) { 363 return null; 364 } 365 // we have found a comma-insert case. now just do it 366 // in the least expensive way we can. 367 if (source instanceof Spanned) { 368 SpannableStringBuilder sb = new SpannableStringBuilder(","); 369 sb.append(source); 370 return sb; 371 } else { 372 return ", "; 373 } 374 default: 375 // just keep going 376 } 377 } 378 379 // no termination cases were found, so don't edit the input 380 return null; 381 } 382 }; 383 InputFilter[] recipientFilters = new InputFilter[] { recipientFilter }; 384 385 mToView.addTextChangedListener(watcher); 386 mCcView.addTextChangedListener(watcher); 387 mBccView.addTextChangedListener(watcher); 388 mSubjectView.addTextChangedListener(watcher); 389 mMessageContentView.addTextChangedListener(watcher); 390 391 // NOTE: assumes no other filters are set 392 mToView.setFilters(recipientFilters); 393 mCcView.setFilters(recipientFilters); 394 mBccView.setFilters(recipientFilters); 395 396 /* 397 * We set this to invisible by default. Other methods will turn it back on if it's 398 * needed. 399 */ 400 mQuotedTextBar.setVisibility(View.GONE); 401 mQuotedText.setVisibility(View.GONE); 402 403 mQuotedTextDelete.setOnClickListener(this); 404 405 mToView.setAdapter(mAddressAdapter); 406 mToView.setTokenizer(new Rfc822Tokenizer()); 407 mToView.setValidator(mAddressValidator); 408 409 mCcView.setAdapter(mAddressAdapter); 410 mCcView.setTokenizer(new Rfc822Tokenizer()); 411 mCcView.setValidator(mAddressValidator); 412 413 mBccView.setAdapter(mAddressAdapter); 414 mBccView.setTokenizer(new Rfc822Tokenizer()); 415 mBccView.setValidator(mAddressValidator); 416 417 mSendButton.setOnClickListener(this); 418 mDiscardButton.setOnClickListener(this); 419 mSaveButton.setOnClickListener(this); 420 421 mSubjectView.setOnFocusChangeListener(this); 422 423 if (savedInstanceState != null) { 424 /* 425 * This data gets used in onCreate, so grab it here instead of onRestoreIntstanceState 426 */ 427 mSourceMessageProcessed = 428 savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false); 429 } 430 431 Intent intent = getIntent(); 432 433 String action = intent.getAction(); 434 435 // Handle the various intents that launch the message composer 436 if (Intent.ACTION_VIEW.equals(action) || Intent.ACTION_SENDTO.equals(action) || 437 (Intent.ACTION_SEND.equals(action))) { 438 439 // Check first for a valid account 440 mAccount = EmailStore.Account.getDefaultAccount(this); 441 if (mAccount == null) { 442 // There are no accounts set up. This should not have happened. Prompt the 443 // user to set up an account as an acceptable bailout. 444 Accounts.actionShowAccounts(this); 445 mDraftNeedsSaving = false; 446 finish(); 447 return; 448 } 449 450 // Use the fields found in the Intent to prefill as much of the message as possible 451 initFromIntent(intent); 452 } 453 else { 454 // Otherwise, handle the internal cases (Message Composer invoked from within app) 455 mAccountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1); 456 mAccount = EmailStore.Account.restoreAccountWithId(this, mAccountId); 457 mFolder = intent.getStringExtra(EXTRA_FOLDER); 458 mSourceMessageUid = intent.getStringExtra(EXTRA_MESSAGE); 459 } 460 461 if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action) || 462 ACTION_FORWARD.equals(action) || ACTION_EDIT_DRAFT.equals(action)) { 463 /* 464 * If we need to load the message we add ourself as a message listener here 465 * so we can kick it off. Normally we add in onResume but we don't 466 * want to reload the message every time the activity is resumed. 467 * There is no harm in adding twice. 468 */ 469 MessagingController.getInstance(getApplication()).addListener(mListener); 470 MessagingController.getInstance(getApplication()).loadMessageForView( 471 mAccount, 472 mFolder, 473 mSourceMessageUid, 474 mListener); 475 } 476 477 updateTitle(); 478 } 479 480 @Override 481 public void onResume() { 482 super.onResume(); 483 MessagingController.getInstance(getApplication()).addListener(mListener); 484 } 485 486 @Override 487 public void onPause() { 488 super.onPause(); 489 saveIfNeeded(); 490 MessagingController.getInstance(getApplication()).removeListener(mListener); 491 } 492 493 /** 494 * We override onDestroy to make sure that the WebView gets explicitly destroyed. 495 * Otherwise it can leak native references. 496 */ 497 @Override 498 public void onDestroy() { 499 super.onDestroy(); 500 mQuotedText.destroy(); 501 mQuotedText = null; 502 } 503 504 /** 505 * The framework handles most of the fields, but we need to handle stuff that we 506 * dynamically show and hide: 507 * Attachment list, 508 * Cc field, 509 * Bcc field, 510 * Quoted text, 511 */ 512 @Override 513 protected void onSaveInstanceState(Bundle outState) { 514 super.onSaveInstanceState(outState); 515 saveIfNeeded(); 516 ArrayList<Uri> attachments = new ArrayList<Uri>(); 517 for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { 518 View view = mAttachments.getChildAt(i); 519 Attachment attachment = (Attachment) view.getTag(); 520 attachments.add(attachment.uri); 521 } 522 outState.putParcelableArrayList(STATE_KEY_ATTACHMENTS, attachments); 523 outState.putBoolean(STATE_KEY_CC_SHOWN, mCcView.getVisibility() == View.VISIBLE); 524 outState.putBoolean(STATE_KEY_BCC_SHOWN, mBccView.getVisibility() == View.VISIBLE); 525 outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN, 526 mQuotedTextBar.getVisibility() == View.VISIBLE); 527 outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed); 528 outState.putString(STATE_KEY_DRAFT_UID, mDraftUid); 529 } 530 531 @Override 532 protected void onRestoreInstanceState(Bundle savedInstanceState) { 533 super.onRestoreInstanceState(savedInstanceState); 534 ArrayList<Parcelable> attachments = 535 savedInstanceState.getParcelableArrayList(STATE_KEY_ATTACHMENTS); 536 mAttachments.removeAllViews(); 537 for (Parcelable p : attachments) { 538 Uri uri = (Uri) p; 539 addAttachment(uri); 540 } 541 542 mCcView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN) ? 543 View.VISIBLE : View.GONE); 544 mBccView.setVisibility(savedInstanceState.getBoolean(STATE_KEY_BCC_SHOWN) ? 545 View.VISIBLE : View.GONE); 546 mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 547 View.VISIBLE : View.GONE); 548 mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 549 View.VISIBLE : View.GONE); 550 mDraftUid = savedInstanceState.getString(STATE_KEY_DRAFT_UID); 551 mDraftNeedsSaving = false; 552 } 553 554 private void updateTitle() { 555 if (mSubjectView.getText().length() == 0) { 556 setTitle(R.string.compose_title); 557 } else { 558 setTitle(mSubjectView.getText().toString()); 559 } 560 } 561 562 public void onFocusChange(View view, boolean focused) { 563 if (!focused) { 564 updateTitle(); 565 } 566 } 567 568 private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) { 569 if (addresses == null) { 570 return; 571 } 572 for (Address address : addresses) { 573 addAddress(view, address.toString()); 574 } 575 } 576 577 private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) { 578 if (addresses == null) { 579 return; 580 } 581 for (String oneAddress : addresses) { 582 addAddress(view, oneAddress); 583 } 584 } 585 586 private void addAddress(MultiAutoCompleteTextView view, String address) { 587 view.append(address + ", "); 588 } 589 590 private Address[] getAddresses(MultiAutoCompleteTextView view) { 591 Address[] addresses = Address.parse(view.getText().toString().trim()); 592 return addresses; 593 } 594 595 private MimeMessage createMessage() throws MessagingException { 596 MimeMessage message = new MimeMessage(); 597 message.setSentDate(new Date()); 598 Address from = new Address(mAccount.getEmail(), mAccount.getName()); 599 message.setFrom(from); 600 message.setRecipients(RecipientType.TO, getAddresses(mToView)); 601 message.setRecipients(RecipientType.CC, getAddresses(mCcView)); 602 message.setRecipients(RecipientType.BCC, getAddresses(mBccView)); 603 message.setSubject(mSubjectView.getText().toString()); 604 605 // Preserve Message-ID header if found 606 // This makes sure that multiply-saved drafts are identified as the same message 607 if (mSourceMessage != null && mSourceMessage instanceof MimeMessage) { 608 String messageIdHeader = ((MimeMessage)mSourceMessage).getMessageId(); 609 if (messageIdHeader != null) { 610 message.setMessageId(messageIdHeader); 611 } 612 } 613 614 /* 615 * Build the Body that will contain the text of the message. We'll decide where to 616 * include it later. 617 */ 618 619 String text = mMessageContentView.getText().toString(); 620 621 if (mQuotedTextBar.getVisibility() == View.VISIBLE) { 622 String action = getIntent().getAction(); 623 String quotedText = null; 624 Part part = MimeUtility.findFirstPartByMimeType(mSourceMessage, 625 "text/plain"); 626 if (part != null) { 627 quotedText = MimeUtility.getTextFromPart(part); 628 } 629 if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) { 630 text += String.format( 631 getString(R.string.message_compose_reply_header_fmt), 632 Address.toString(mSourceMessage.getFrom())); 633 if (quotedText != null) { 634 text += quotedText.replaceAll("(?m)^", ">"); 635 } 636 } 637 else if (ACTION_FORWARD.equals(action)) { 638 text += String.format( 639 getString(R.string.message_compose_fwd_header_fmt), 640 mSourceMessage.getSubject(), 641 Address.toString(mSourceMessage.getFrom()), 642 Address.toString( 643 mSourceMessage.getRecipients(RecipientType.TO)), 644 Address.toString( 645 mSourceMessage.getRecipients(RecipientType.CC))); 646 if (quotedText != null) { 647 text += quotedText; 648 } 649 } 650 } 651 652 TextBody body = new TextBody(text); 653 654 if (mAttachments.getChildCount() > 0) { 655 /* 656 * The message has attachments that need to be included. First we add the part 657 * containing the text that will be sent and then we include each attachment. 658 */ 659 660 MimeMultipart mp; 661 662 mp = new MimeMultipart(); 663 mp.addBodyPart(new MimeBodyPart(body, "text/plain")); 664 665 for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { 666 Attachment attachment = (Attachment) mAttachments.getChildAt(i).getTag(); 667 MimeBodyPart bp = new MimeBodyPart( 668 new LocalStore.LocalAttachmentBody(attachment.uri, getApplication())); 669 bp.setHeader(MimeHeader.HEADER_CONTENT_TYPE, String.format("%s;\n name=\"%s\"", 670 attachment.contentType, 671 attachment.name)); 672 bp.setHeader(MimeHeader.HEADER_CONTENT_TRANSFER_ENCODING, "base64"); 673 bp.setHeader(MimeHeader.HEADER_CONTENT_DISPOSITION, 674 String.format("attachment;\n filename=\"%s\"", 675 attachment.name)); 676 mp.addBodyPart(bp); 677 } 678 679 message.setBody(mp); 680 } 681 else { 682 /* 683 * No attachments to include, just stick the text body in the message and call 684 * it good. 685 */ 686 message.setBody(body); 687 } 688 689 return message; 690 } 691 692 private void sendOrSaveMessage(boolean save) { 693 /* 694 * Create the message from all the data the user has entered. 695 */ 696 MimeMessage message; 697 try { 698 message = createMessage(); 699 } 700 catch (MessagingException me) { 701 Log.e(Email.LOG_TAG, "Failed to create new message for send or save.", me); 702 throw new RuntimeException("Failed to create a new message for send or save.", me); 703 } 704 705 if (save) { 706 /* 707 * Save a draft 708 */ 709 if (mDraftUid != null) { 710 message.setUid(mDraftUid); 711 } 712 else if (ACTION_EDIT_DRAFT.equals(getIntent().getAction())) { 713 /* 714 * We're saving a previously saved draft, so update the new message's uid 715 * to the old message's uid. 716 */ 717 message.setUid(mSourceMessageUid); 718 } 719 MessagingController.getInstance(getApplication()).saveDraft(mAccount, message); 720 mDraftUid = message.getUid(); 721 722 // Don't display the toast if the user is just changing the orientation 723 if ((getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 724 mHandler.sendEmptyMessage(MSG_SAVED_DRAFT); 725 } 726 } 727 else { 728 /* 729 * Send the message 730 * If the source message is in other folder than draft, it should not be deleted while 731 * sending message. 732 */ 733 if (ACTION_EDIT_DRAFT.equals(getIntent().getAction()) 734 && mSourceMessageUid != null 735 && mFolder.equals(mAccount.getDraftsFolderName(this))) { 736 /* 737 * We're sending a previously saved draft, so delete the old draft first. 738 */ 739 MessagingController.getInstance(getApplication()).deleteMessage( 740 mAccount, 741 mFolder, 742 mSourceMessage, 743 null); 744 } 745 MessagingController.getInstance(getApplication()).sendMessage(mAccount, message, null); 746 } 747 } 748 749 private void saveIfNeeded() { 750 if (!mDraftNeedsSaving) { 751 return; 752 } 753 mDraftNeedsSaving = false; 754 sendOrSaveMessage(true); 755 } 756 757 private void onSend() { 758 if (getAddresses(mToView).length == 0 && 759 getAddresses(mCcView).length == 0 && 760 getAddresses(mBccView).length == 0) { 761 mToView.setError(getString(R.string.message_compose_error_no_recipients)); 762 Toast.makeText(this, getString(R.string.message_compose_error_no_recipients), 763 Toast.LENGTH_LONG).show(); 764 return; 765 } 766 sendOrSaveMessage(false); 767 mDraftNeedsSaving = false; 768 finish(); 769 } 770 771 private void onDiscard() { 772 if (mSourceMessageUid != null) { 773 if (ACTION_EDIT_DRAFT.equals(getIntent().getAction()) && mSourceMessageUid != null) { 774 MessagingController.getInstance(getApplication()).deleteMessage( 775 mAccount, 776 mFolder, 777 mSourceMessage, 778 null); 779 } 780 } 781 mHandler.sendEmptyMessage(MSG_DISCARDED_DRAFT); 782 mDraftNeedsSaving = false; 783 finish(); 784 } 785 786 private void onSave() { 787 saveIfNeeded(); 788 finish(); 789 } 790 791 private void onAddCcBcc() { 792 mCcView.setVisibility(View.VISIBLE); 793 mBccView.setVisibility(View.VISIBLE); 794 } 795 796 /** 797 * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over. 798 */ 799 private void onAddAttachment() { 800 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 801 i.addCategory(Intent.CATEGORY_OPENABLE); 802 i.setType(Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES[0]); 803 startActivityForResult( 804 Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)), 805 ACTIVITY_REQUEST_PICK_ATTACHMENT); 806 } 807 808 private void addAttachment(Uri uri) { 809 addAttachment(uri, -1, null); 810 } 811 812 private void addAttachment(Uri uri, int size, String name) { 813 ContentResolver contentResolver = getContentResolver(); 814 815 String contentType = contentResolver.getType(uri); 816 817 if (contentType == null) { 818 contentType = ""; 819 } 820 821 Attachment attachment = new Attachment(); 822 attachment.name = name; 823 attachment.contentType = contentType; 824 attachment.size = size; 825 attachment.uri = uri; 826 827 if (attachment.size == -1 || attachment.name == null) { 828 Cursor metadataCursor = contentResolver.query( 829 uri, 830 new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }, 831 null, 832 null, 833 null); 834 if (metadataCursor != null) { 835 try { 836 if (metadataCursor.moveToFirst()) { 837 if (attachment.name == null) { 838 attachment.name = metadataCursor.getString(0); 839 } 840 if (attachment.size == -1) { 841 attachment.size = metadataCursor.getInt(1); 842 } 843 } 844 } finally { 845 metadataCursor.close(); 846 } 847 } 848 } 849 850 if (attachment.name == null) { 851 attachment.name = uri.getLastPathSegment(); 852 } 853 854 // Before attaching the attachment, make sure it meets any other pre-attach criteria 855 if (attachment.size > Email.MAX_ATTACHMENT_UPLOAD_SIZE) { 856 Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG) 857 .show(); 858 return; 859 } 860 861 View view = getLayoutInflater().inflate( 862 R.layout.message_compose_attachment, 863 mAttachments, 864 false); 865 TextView nameView = (TextView)view.findViewById(R.id.attachment_name); 866 ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete); 867 nameView.setText(attachment.name); 868 delete.setOnClickListener(this); 869 delete.setTag(view); 870 view.setTag(attachment); 871 mAttachments.addView(view); 872 } 873 874 @Override 875 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 876 if (data == null) { 877 return; 878 } 879 addAttachment(data.getData()); 880 mDraftNeedsSaving = true; 881 } 882 883 public void onClick(View view) { 884 switch (view.getId()) { 885 case R.id.send: 886 onSend(); 887 break; 888 case R.id.save: 889 onSave(); 890 break; 891 case R.id.discard: 892 onDiscard(); 893 break; 894 case R.id.attachment_delete: 895 /* 896 * The view is the delete button, and we have previously set the tag of 897 * the delete button to the view that owns it. We don't use parent because the 898 * view is very complex and could change in the future. 899 */ 900 mAttachments.removeView((View) view.getTag()); 901 mDraftNeedsSaving = true; 902 break; 903 case R.id.quoted_text_delete: 904 mQuotedTextBar.setVisibility(View.GONE); 905 mQuotedText.setVisibility(View.GONE); 906 mDraftNeedsSaving = true; 907 break; 908 } 909 } 910 911 @Override 912 public boolean onOptionsItemSelected(MenuItem item) { 913 switch (item.getItemId()) { 914 case R.id.send: 915 onSend(); 916 break; 917 case R.id.save: 918 onSave(); 919 break; 920 case R.id.discard: 921 onDiscard(); 922 break; 923 case R.id.add_cc_bcc: 924 onAddCcBcc(); 925 break; 926 case R.id.add_attachment: 927 onAddAttachment(); 928 break; 929 default: 930 return super.onOptionsItemSelected(item); 931 } 932 return true; 933 } 934 935 @Override 936 public boolean onCreateOptionsMenu(Menu menu) { 937 super.onCreateOptionsMenu(menu); 938 getMenuInflater().inflate(R.menu.message_compose_option, menu); 939 return true; 940 } 941 942 /** 943 * Returns true if all attachments were able to be attached, otherwise returns false. 944 */ 945 private boolean loadAttachments(Part part, int depth) throws MessagingException { 946 if (part.getBody() instanceof Multipart) { 947 Multipart mp = (Multipart) part.getBody(); 948 boolean ret = true; 949 for (int i = 0, count = mp.getCount(); i < count; i++) { 950 if (!loadAttachments(mp.getBodyPart(i), depth + 1)) { 951 ret = false; 952 } 953 } 954 return ret; 955 } else { 956 String contentType = MimeUtility.unfoldAndDecode(part.getContentType()); 957 String name = MimeUtility.getHeaderParameter(contentType, "name"); 958 if (name != null) { 959 Body body = part.getBody(); 960 if (body != null && body instanceof LocalAttachmentBody) { 961 final Uri uri = ((LocalAttachmentBody) body).getContentUri(); 962 mHandler.post(new Runnable() { 963 public void run() { 964 addAttachment(uri); 965 } 966 }); 967 } 968 else { 969 return false; 970 } 971 } 972 return true; 973 } 974 } 975 976 /** 977 * Fill all the widgets with the content found in the Intent Extra, if any. 978 * 979 * Note that we don't actually check the intent action (typically VIEW, SENDTO, or SEND). 980 * There is enough overlap in the definitions that it makes more sense to simply check for 981 * all available data and use as much of it as possible. 982 * 983 * With one exception: EXTRA_STREAM is defined as only valid for ACTION_SEND. 984 * 985 * @param intent the launch intent 986 */ 987 /* package */ void initFromIntent(Intent intent) { 988 989 // First, add values stored in top-level extras 990 991 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 992 if (extraStrings != null) { 993 addAddresses(mToView, extraStrings); 994 } 995 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 996 if (extraStrings != null) { 997 addAddresses(mCcView, extraStrings); 998 } 999 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 1000 if (extraStrings != null) { 1001 addAddresses(mBccView, extraStrings); 1002 } 1003 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 1004 if (extraString != null) { 1005 mSubjectView.setText(extraString); 1006 } 1007 1008 // Next, if we were invoked with a URI, try to interpret it 1009 // We'll take two courses here. If it's mailto:, there is a specific set of rules 1010 // that define various optional fields. However, for any other scheme, we'll simply 1011 // take the entire scheme-specific part and interpret it as a possible list of addresses. 1012 1013 final Uri dataUri = intent.getData(); 1014 if (dataUri != null) { 1015 if ("mailto".equals(dataUri.getScheme())) { 1016 initializeFromMailTo(dataUri.toString()); 1017 } else { 1018 String toText = dataUri.getSchemeSpecificPart(); 1019 if (toText != null) { 1020 addAddresses(mToView, toText.split(",")); 1021 } 1022 } 1023 } 1024 1025 // Next, fill in the plaintext (note, this will override mailto:?body=) 1026 1027 CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); 1028 if (text != null) { 1029 mMessageContentView.setText(text); 1030 } 1031 1032 // Next, convert EXTRA_STREAM into an attachment 1033 1034 if (Intent.ACTION_SEND.equals(intent.getAction()) && intent.hasExtra(Intent.EXTRA_STREAM)) { 1035 String type = intent.getType(); 1036 Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); 1037 if (stream != null && type != null) { 1038 if (MimeUtility.mimeTypeMatches(type, Email.ACCEPTABLE_ATTACHMENT_SEND_TYPES)) { 1039 addAttachment(stream); 1040 } 1041 } 1042 } 1043 1044 // Finally - expose fields that were filled in but are normally hidden, and set focus 1045 1046 if (mCcView.length() > 0) { 1047 mCcView.setVisibility(View.VISIBLE); 1048 } 1049 if (mBccView.length() > 0) { 1050 mBccView.setVisibility(View.VISIBLE); 1051 } 1052 setNewMessageFocus(); 1053 mDraftNeedsSaving = false; 1054 } 1055 1056 /** 1057 * When we are launched with an intent that includes a mailto: URI, we can actually 1058 * gather quite a few of our message fields from it. 1059 * 1060 * @mailToString the href (which must start with "mailto:"). 1061 */ 1062 private void initializeFromMailTo(String mailToString) { 1063 1064 // Chop up everything between mailto: and ? to find recipients 1065 int index = mailToString.indexOf("?"); 1066 int length = "mailto".length() + 1; 1067 String to; 1068 try { 1069 // Extract the recipient after mailto: 1070 if (index == -1) { 1071 to = decode(mailToString.substring(length)); 1072 } else { 1073 to = decode(mailToString.substring(length, index)); 1074 } 1075 addAddresses(mToView, to.split(" ,")); 1076 } catch (UnsupportedEncodingException e) { 1077 Log.e(Email.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'"); 1078 } 1079 1080 // Extract the other parameters 1081 1082 // We need to disguise this string as a URI in order to parse it 1083 Uri uri = Uri.parse("foo://" + mailToString); 1084 1085 List<String> cc = uri.getQueryParameters("cc"); 1086 addAddresses(mCcView, cc.toArray(new String[cc.size()])); 1087 1088 List<String> otherTo = uri.getQueryParameters("to"); 1089 addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()])); 1090 1091 List<String> bcc = uri.getQueryParameters("bcc"); 1092 addAddresses(mBccView, bcc.toArray(new String[bcc.size()])); 1093 1094 List<String> subject = uri.getQueryParameters("subject"); 1095 if (subject.size() > 0) { 1096 mSubjectView.setText(subject.get(0)); 1097 } 1098 1099 List<String> body = uri.getQueryParameters("body"); 1100 if (body.size() > 0) { 1101 mMessageContentView.setText(body.get(0)); 1102 } 1103 } 1104 1105 private String decode(String s) throws UnsupportedEncodingException { 1106 return URLDecoder.decode(s, "UTF-8"); 1107 } 1108 1109 /** 1110 * Pull out the parts of the now loaded source message and apply them to the new message 1111 * depending on the type of message being composed. 1112 * @param message 1113 */ 1114 /* package */ /** 1115 * @param message 1116 */ 1117 void processSourceMessage(Message message) { 1118 String action = getIntent().getAction(); 1119 if (ACTION_REPLY.equals(action) || ACTION_REPLY_ALL.equals(action)) { 1120 try { 1121 if (message.getSubject() != null && 1122 !message.getSubject().toLowerCase().startsWith("re:")) { 1123 mSubjectView.setText("Re: " + message.getSubject()); 1124 } 1125 else { 1126 mSubjectView.setText(message.getSubject()); 1127 } 1128 /* 1129 * If a reply-to was included with the message use that, otherwise use the from 1130 * or sender address. 1131 */ 1132 Address[] replyToAddresses; 1133 if (message.getReplyTo().length > 0) { 1134 addAddresses(mToView, replyToAddresses = message.getReplyTo()); 1135 } 1136 else { 1137 addAddresses(mToView, replyToAddresses = message.getFrom()); 1138 } 1139 if (ACTION_REPLY_ALL.equals(action)) { 1140 for (Address address : message.getRecipients(RecipientType.TO)) { 1141 if (!address.getAddress().equalsIgnoreCase(mAccount.getEmail())) { 1142 addAddress(mToView, address.toString()); 1143 } 1144 } 1145 if (message.getRecipients(RecipientType.CC).length > 0) { 1146 for (Address address : message.getRecipients(RecipientType.CC)) { 1147 if (!Utility.arrayContains(replyToAddresses, address)) { 1148 addAddress(mCcView, address.toString()); 1149 } 1150 } 1151 mCcView.setVisibility(View.VISIBLE); 1152 } 1153 } 1154 1155 Boolean plainTextFlag = false; 1156 Part part = MimeUtility.findFirstPartByMimeType(message, "text/html"); 1157 if (part == null) { 1158 part = MimeUtility.findFirstPartByMimeType(message, "text/plain"); 1159 plainTextFlag = true; 1160 } 1161 1162 if (part != null) { 1163 String text = MimeUtility.getTextFromPart(part); 1164 if (text != null) { 1165 if (!plainTextFlag) { 1166 text = EmailHtmlUtil.resolveInlineImage( 1167 getContentResolver(), mAccount, text, message, 0); 1168 } else { 1169 text = EmailHtmlUtil.escapeCharacterToDisplay(text); 1170 } 1171 mQuotedTextBar.setVisibility(View.VISIBLE); 1172 mQuotedText.setVisibility(View.VISIBLE); 1173 mQuotedText.loadDataWithBaseURL("email://", text, "text/html", 1174 "utf-8", null); 1175 } 1176 } 1177 } 1178 catch (MessagingException me) { 1179 /* 1180 * This really should not happen at this point but if it does it's okay. 1181 * The user can continue composing their message. 1182 */ 1183 } 1184 } 1185 else if (ACTION_FORWARD.equals(action)) { 1186 try { 1187 if (message.getSubject() != null && 1188 !message.getSubject().toLowerCase().startsWith("fwd:")) { 1189 mSubjectView.setText("Fwd: " + message.getSubject()); 1190 } 1191 else { 1192 mSubjectView.setText(message.getSubject()); 1193 } 1194 1195 1196 Boolean plainTextFlag = false; 1197 Part part = MimeUtility.findFirstPartByMimeType(message, "text/html"); 1198 if (part == null) { 1199 part = MimeUtility.findFirstPartByMimeType(message, "text/plain"); 1200 plainTextFlag = true; 1201 } 1202 1203 if (part != null) { 1204 String text = MimeUtility.getTextFromPart(part); 1205 if (text != null) { 1206 if (!plainTextFlag) { 1207 text = EmailHtmlUtil.resolveInlineImage( 1208 getContentResolver(), mAccount, text, message, 0); 1209 } else { 1210 text = EmailHtmlUtil.escapeCharacterToDisplay(text); 1211 } 1212 mQuotedTextBar.setVisibility(View.VISIBLE); 1213 mQuotedText.setVisibility(View.VISIBLE); 1214 mQuotedText.loadDataWithBaseURL("email://", text, "text/html", 1215 "utf-8", null); 1216 } 1217 } 1218 if (!mSourceMessageProcessed) { 1219 if (!loadAttachments(message, 0)) { 1220 mHandler.sendEmptyMessage(MSG_SKIPPED_ATTACHMENTS); 1221 } 1222 } 1223 } 1224 catch (MessagingException me) { 1225 /* 1226 * This really should not happen at this point but if it does it's okay. 1227 * The user can continue composing their message. 1228 */ 1229 } 1230 } 1231 else if (ACTION_EDIT_DRAFT.equals(action)) { 1232 try { 1233 mSubjectView.setText(message.getSubject()); 1234 addAddresses(mToView, message.getRecipients(RecipientType.TO)); 1235 if (message.getRecipients(RecipientType.CC).length > 0) { 1236 addAddresses(mCcView, message.getRecipients(RecipientType.CC)); 1237 mCcView.setVisibility(View.VISIBLE); 1238 } 1239 if (message.getRecipients(RecipientType.BCC).length > 0) { 1240 addAddresses(mBccView, message.getRecipients(RecipientType.BCC)); 1241 mBccView.setVisibility(View.VISIBLE); 1242 } 1243 Part part = MimeUtility.findFirstPartByMimeType(message, "text/plain"); 1244 if (part != null) { 1245 String text = MimeUtility.getTextFromPart(part); 1246 mMessageContentView.setText(text); 1247 } 1248 if (!mSourceMessageProcessed) { 1249 loadAttachments(message, 0); 1250 } 1251 } 1252 catch (MessagingException me) { 1253 // TODO 1254 } 1255 } 1256 1257 setNewMessageFocus(); 1258 1259 mSourceMessageProcessed = true; 1260 mDraftNeedsSaving = mFolder != null && !mFolder.equals(mAccount.getDraftsFolderName(this)); 1261 } 1262 1263 /** 1264 * In order to accelerate typing, position the cursor in the first empty field, 1265 * or at the end of the body composition field if none are empty. Typically, this will 1266 * play out as follows: 1267 * Reply / Reply All - put cursor in the empty message body 1268 * Forward - put cursor in the empty To field 1269 * Edit Draft - put cursor in whatever field still needs entry 1270 */ 1271 private void setNewMessageFocus() { 1272 if (mToView.length() == 0) { 1273 mToView.requestFocus(); 1274 } else if (mSubjectView.length() == 0) { 1275 mSubjectView.requestFocus(); 1276 } else { 1277 mMessageContentView.requestFocus(); 1278 // when selecting the message content, explicitly move IP to the end, so you can 1279 // quickly resume typing into a draft 1280 int selection = mMessageContentView.length(); 1281 mMessageContentView.setSelection(selection, selection); 1282 } 1283 } 1284 1285 class Listener extends MessagingListener { 1286 @Override 1287 public void loadMessageForViewStarted(EmailStore.Account account, String folder, 1288 String uid) { 1289 mHandler.sendEmptyMessage(MSG_PROGRESS_ON); 1290 } 1291 1292 @Override 1293 public void loadMessageForViewFinished(EmailStore.Account account, String folder, 1294 String uid, Message message) { 1295 mHandler.sendEmptyMessage(MSG_PROGRESS_OFF); 1296 } 1297 1298 @Override 1299 public void loadMessageForViewBodyAvailable(EmailStore.Account account, String folder, 1300 String uid, final Message message) { 1301 mSourceMessage = message; 1302 runOnUiThread(new Runnable() { 1303 public void run() { 1304 processSourceMessage(message); 1305 } 1306 }); 1307 } 1308 1309 @Override 1310 public void loadMessageForViewFailed(EmailStore.Account account, String folder, String uid, 1311 final String message) { 1312 mHandler.sendEmptyMessage(MSG_PROGRESS_OFF); 1313 // TODO show network error 1314 } 1315 1316 @Override 1317 public void messageUidChanged(EmailStore.Account account, String folder,String oldUid, 1318 String newUid) { 1319 if (account.equals(mAccount) && (folder.equals(mFolder) 1320 || (mFolder == null 1321 && folder.equals(mAccount.getDraftsFolderName(MessageCompose.this))))) { 1322 if (oldUid.equals(mDraftUid)) { 1323 mDraftUid = newUid; 1324 } 1325 if (oldUid.equals(mSourceMessageUid)) { 1326 mSourceMessageUid = newUid; 1327 } 1328 if (mSourceMessage != null && (oldUid.equals(mSourceMessage.getUid()))) { 1329 mSourceMessage.setUid(newUid); 1330 } 1331 } 1332 } 1333 } 1334} 1335