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