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