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