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