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