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