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