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