MessageCompose.java revision 2193962ca2b3157e79f731736afa2a0c972e778a
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.internet.EmailHtmlUtil; 26import com.android.email.provider.EmailContent; 27import com.android.email.provider.EmailContent.Account; 28import com.android.email.provider.EmailContent.Attachment; 29import com.android.email.provider.EmailContent.Body; 30import com.android.email.provider.EmailContent.BodyColumns; 31import com.android.email.provider.EmailContent.Message; 32import com.android.email.provider.EmailContent.MessageColumns; 33import com.android.emailcommon.internet.MimeUtility; 34import com.android.emailcommon.mail.Address; 35 36import android.app.ActionBar; 37import android.app.Activity; 38import android.app.ActivityManager; 39import android.content.ActivityNotFoundException; 40import android.content.ContentResolver; 41import android.content.ContentUris; 42import android.content.ContentValues; 43import android.content.Context; 44import android.content.Intent; 45import android.content.pm.ActivityInfo; 46import android.database.Cursor; 47import android.net.Uri; 48import android.os.AsyncTask; 49import android.os.Bundle; 50import android.os.Parcelable; 51import android.provider.OpenableColumns; 52import android.text.InputFilter; 53import android.text.SpannableStringBuilder; 54import android.text.Spanned; 55import android.text.TextUtils; 56import android.text.TextWatcher; 57import android.text.util.Rfc822Tokenizer; 58import android.util.Log; 59import android.view.Menu; 60import android.view.MenuItem; 61import android.view.View; 62import android.view.View.OnClickListener; 63import android.view.View.OnFocusChangeListener; 64import android.webkit.WebView; 65import android.widget.CheckBox; 66import android.widget.EditText; 67import android.widget.ImageButton; 68import android.widget.LinearLayout; 69import android.widget.MultiAutoCompleteTextView; 70import android.widget.TextView; 71import android.widget.Toast; 72 73import java.io.File; 74import java.io.UnsupportedEncodingException; 75import java.net.URLDecoder; 76import java.util.ArrayList; 77import java.util.List; 78 79 80/** 81 * Activity to compose a message. 82 * 83 * TODO Revive shortcuts command for removed menu options. 84 * C: add cc/bcc 85 * N: add attachment 86 */ 87public class MessageCompose extends Activity implements OnClickListener, OnFocusChangeListener, 88 DeleteMessageConfirmationDialog.Callback { 89 private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY"; 90 private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL"; 91 private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD"; 92 private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT"; 93 94 private static final String EXTRA_ACCOUNT_ID = "account_id"; 95 private static final String EXTRA_MESSAGE_ID = "message_id"; 96 /** If the intent is sent from the email app itself, it should have this boolean extra. */ 97 private static final String EXTRA_FROM_WITHIN_APP = "from_within_app"; 98 99 private static final String STATE_KEY_CC_SHOWN = 100 "com.android.email.activity.MessageCompose.ccShown"; 101 private static final String STATE_KEY_QUOTED_TEXT_SHOWN = 102 "com.android.email.activity.MessageCompose.quotedTextShown"; 103 private static final String STATE_KEY_SOURCE_MESSAGE_PROCED = 104 "com.android.email.activity.MessageCompose.stateKeySourceMessageProced"; 105 private static final String STATE_KEY_DRAFT_ID = 106 "com.android.email.activity.MessageCompose.draftId"; 107 108 private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1; 109 110 private static final String[] ATTACHMENT_META_SIZE_PROJECTION = { 111 OpenableColumns.SIZE 112 }; 113 private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0; 114 115 // Is set while the draft is saved by a background thread. 116 // Is static in order to be shared between the two activity instances 117 // on orientation change. 118 private static boolean sSaveInProgress = false; 119 // lock and condition for sSaveInProgress 120 private static final Object sSaveInProgressCondition = new Object(); 121 122 private Account mAccount; 123 124 // mDraft has mId > 0 after the first draft save. 125 private Message mDraft = new Message(); 126 127 // mSource is only set for REPLY, REPLY_ALL and FORWARD, and contains the source message. 128 private Message mSource; 129 130 // we use mAction instead of Intent.getAction() because sometimes we need to 131 // re-write the action to EDIT_DRAFT. 132 private String mAction; 133 134 /** 135 * Indicates that the source message has been processed at least once and should not 136 * be processed on any subsequent loads. This protects us from adding attachments that 137 * have already been added from the restore of the view state. 138 */ 139 private boolean mSourceMessageProcessed = false; 140 141 private ActionBar mActionBar; 142 private TextView mFromView; 143 private MultiAutoCompleteTextView mToView; 144 private MultiAutoCompleteTextView mCcView; 145 private MultiAutoCompleteTextView mBccView; 146 private View mCcBccContainer; 147 private EditText mSubjectView; 148 private EditText mMessageContentView; 149 private View mAttachmentContainer; 150 private LinearLayout mAttachments; 151 private View mQuotedTextBar; 152 private CheckBox mIncludeQuotedTextCheckBox; 153 private WebView mQuotedText; 154 155 private Controller mController; 156 private boolean mDraftNeedsSaving; 157 private boolean mMessageLoaded; 158 private AsyncTask<Long, Void, Attachment[]> mLoadAttachmentsTask; 159 private AsyncTask<Void, Void, Object[]> mLoadMessageTask; 160 161 private EmailAddressAdapter mAddressAdapterTo; 162 private EmailAddressAdapter mAddressAdapterCc; 163 private EmailAddressAdapter mAddressAdapterBcc; 164 165 /** Whether the save command should be enabled. */ 166 private boolean mSaveEnabled; 167 168 private static Intent getBaseIntent(Context context) { 169 Intent i = new Intent(context, MessageCompose.class); 170 i.putExtra(EXTRA_FROM_WITHIN_APP, true); 171 return i; 172 } 173 174 /** 175 * Create an {@link Intent} that can start the message compose activity. If accountId -1, 176 * the default account will be used; otherwise, the specified account is used. 177 */ 178 public static Intent getMessageComposeIntent(Context context, long accountId) { 179 Intent i = getBaseIntent(context); 180 i.putExtra(EXTRA_ACCOUNT_ID, accountId); 181 return i; 182 } 183 184 /** 185 * Compose a new message using the given account. If account is -1 the default account 186 * will be used. 187 * @param context 188 * @param accountId 189 */ 190 public static void actionCompose(Context context, long accountId) { 191 try { 192 Intent i = getMessageComposeIntent(context, accountId); 193 context.startActivity(i); 194 } catch (ActivityNotFoundException anfe) { 195 // Swallow it - this is usually a race condition, especially under automated test. 196 // (The message composer might have been disabled) 197 Email.log(anfe.toString()); 198 } 199 } 200 201 /** 202 * Compose a new message using a uri (mailto:) and a given account. If account is -1 the 203 * default account will be used. 204 * @param context 205 * @param uriString 206 * @param accountId 207 * @return true if startActivity() succeeded 208 */ 209 public static boolean actionCompose(Context context, String uriString, long accountId) { 210 try { 211 Intent i = getMessageComposeIntent(context, accountId); 212 i.setAction(Intent.ACTION_SEND); 213 i.setData(Uri.parse(uriString)); 214 context.startActivity(i); 215 return true; 216 } catch (ActivityNotFoundException anfe) { 217 // Swallow it - this is usually a race condition, especially under automated test. 218 // (The message composer might have been disabled) 219 Email.log(anfe.toString()); 220 return false; 221 } 222 } 223 224 /** 225 * Compose a new message as a reply to the given message. If replyAll is true the function 226 * is reply all instead of simply reply. 227 * @param context 228 * @param messageId 229 * @param replyAll 230 */ 231 public static void actionReply(Context context, long messageId, boolean replyAll) { 232 startActivityWithMessage(context, replyAll ? ACTION_REPLY_ALL : ACTION_REPLY, messageId); 233 } 234 235 /** 236 * Compose a new message as a forward of the given message. 237 * @param context 238 * @param messageId 239 */ 240 public static void actionForward(Context context, long messageId) { 241 startActivityWithMessage(context, ACTION_FORWARD, messageId); 242 } 243 244 /** 245 * Continue composition of the given message. This action modifies the way this Activity 246 * handles certain actions. 247 * Save will attempt to replace the message in the given folder with the updated version. 248 * Discard will delete the message from the given folder. 249 * @param context 250 * @param messageId the message id. 251 */ 252 public static void actionEditDraft(Context context, long messageId) { 253 startActivityWithMessage(context, ACTION_EDIT_DRAFT, messageId); 254 } 255 256 private static void startActivityWithMessage(Context context, String action, long messageId) { 257 Intent i = getBaseIntent(context); 258 i.putExtra(EXTRA_MESSAGE_ID, messageId); 259 i.setAction(action); 260 context.startActivity(i); 261 } 262 263 private void setAccount(Intent intent) { 264 long accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1); 265 if (accountId == -1) { 266 accountId = Account.getDefaultAccountId(this); 267 } 268 if (accountId == -1) { 269 // There are no accounts set up. This should not have happened. Prompt the 270 // user to set up an account as an acceptable bailout. 271 AccountFolderList.actionShowAccounts(this); 272 finish(); 273 } else { 274 setAccount(Account.restoreAccountWithId(this, accountId)); 275 } 276 } 277 278 private void setAccount(Account account) { 279 if (account == null) { 280 throw new IllegalArgumentException(); 281 } 282 mAccount = account; 283 mFromView.setText(account.mEmailAddress); 284 mAddressAdapterTo.setAccount(account); 285 mAddressAdapterCc.setAccount(account); 286 mAddressAdapterBcc.setAccount(account); 287 } 288 289 @Override 290 public void onCreate(Bundle savedInstanceState) { 291 super.onCreate(savedInstanceState); 292 ActivityHelper.debugSetWindowFlags(this); 293 setContentView(R.layout.message_compose); 294 295 mController = Controller.getInstance(getApplication()); 296 initViews(); 297 setDraftNeedsSaving(false); 298 299 long draftId = -1; 300 if (savedInstanceState != null) { 301 // This data gets used in onCreate, so grab it here instead of onRestoreInstanceState 302 mSourceMessageProcessed = 303 savedInstanceState.getBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, false); 304 draftId = savedInstanceState.getLong(STATE_KEY_DRAFT_ID, -1); 305 } 306 307 // Show the back arrow on the action bar. 308 getActionBar().setDisplayOptions( 309 ActionBar.DISPLAY_HOME_AS_UP, ActionBar.DISPLAY_HOME_AS_UP); 310 311 Intent intent = getIntent(); 312 mAction = intent.getAction(); 313 314 if (draftId != -1) { 315 // this means that we saved the draft earlier, 316 // so now we need to disregard the intent action and do 317 // EDIT_DRAFT instead. 318 mAction = ACTION_EDIT_DRAFT; 319 mDraft.mId = draftId; 320 } 321 322 // Handle the various intents that launch the message composer 323 if (Intent.ACTION_VIEW.equals(mAction) 324 || Intent.ACTION_SENDTO.equals(mAction) 325 || Intent.ACTION_SEND.equals(mAction) 326 || Intent.ACTION_SEND_MULTIPLE.equals(mAction)) { 327 setAccount(intent); 328 // Use the fields found in the Intent to prefill as much of the message as possible 329 initFromIntent(intent); 330 setDraftNeedsSaving(true); 331 mMessageLoaded = true; 332 mSourceMessageProcessed = true; 333 } else { 334 // Otherwise, handle the internal cases (Message Composer invoked from within app) 335 long messageId = draftId != -1 ? draftId : intent.getLongExtra(EXTRA_MESSAGE_ID, -1); 336 if (messageId != -1) { 337 mLoadMessageTask = new LoadMessageTask(messageId).execute(); 338 } else { 339 setAccount(intent); 340 // Since this is a new message, we don't need to call LoadMessageTask. 341 // But we DO need to set mMessageLoaded to indicate the message can be sent 342 mMessageLoaded = true; 343 mSourceMessageProcessed = true; 344 } 345 setInitialComposeText(null, (mAccount != null) ? mAccount.mSignature : null); 346 } 347 348 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) || 349 ACTION_FORWARD.equals(mAction) || ACTION_EDIT_DRAFT.equals(mAction)) { 350 /* 351 * If we need to load the message we add ourself as a message listener here 352 * so we can kick it off. Normally we add in onResume but we don't 353 * want to reload the message every time the activity is resumed. 354 * There is no harm in adding twice. 355 */ 356 // TODO: signal the controller to load the message 357 } 358 } 359 360 // needed for unit tests 361 @Override 362 public void setIntent(Intent intent) { 363 super.setIntent(intent); 364 mAction = intent.getAction(); 365 } 366 367 @Override 368 public void onResume() { 369 super.onResume(); 370 371 // Exit immediately if the accounts list has changed (e.g. externally deleted) 372 if (Email.getNotifyUiAccountsChanged()) { 373 Welcome.actionStart(this); 374 finish(); 375 return; 376 } 377 } 378 379 @Override 380 public void onPause() { 381 super.onPause(); 382 saveIfNeeded(); 383 } 384 385 /** 386 * We override onDestroy to make sure that the WebView gets explicitly destroyed. 387 * Otherwise it can leak native references. 388 */ 389 @Override 390 public void onDestroy() { 391 super.onDestroy(); 392 mQuotedText.destroy(); 393 mQuotedText = null; 394 395 Utility.cancelTaskInterrupt(mLoadAttachmentsTask); 396 mLoadAttachmentsTask = null; 397 Utility.cancelTaskInterrupt(mLoadMessageTask); 398 mLoadMessageTask = null; 399 400 if (mAddressAdapterTo != null) { 401 mAddressAdapterTo.close(); 402 } 403 if (mAddressAdapterCc != null) { 404 mAddressAdapterCc.close(); 405 } 406 if (mAddressAdapterBcc != null) { 407 mAddressAdapterBcc.close(); 408 } 409 } 410 411 /** 412 * The framework handles most of the fields, but we need to handle stuff that we 413 * dynamically show and hide: 414 * Cc field, 415 * Bcc field, 416 * Quoted text, 417 */ 418 @Override 419 protected void onSaveInstanceState(Bundle outState) { 420 super.onSaveInstanceState(outState); 421 long draftId = getOrCreateDraftId(); 422 if (draftId != -1) { 423 outState.putLong(STATE_KEY_DRAFT_ID, draftId); 424 } 425 outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE); 426 outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN, 427 mQuotedTextBar.getVisibility() == View.VISIBLE); 428 outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed); 429 } 430 431 @Override 432 protected void onRestoreInstanceState(Bundle savedInstanceState) { 433 super.onRestoreInstanceState(savedInstanceState); 434 if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) { 435 showCcBccFields(); 436 } 437 mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 438 View.VISIBLE : View.GONE); 439 mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 440 View.VISIBLE : View.GONE); 441 setDraftNeedsSaving(false); 442 } 443 444 /** 445 * @return true if the activity was opened by the email app itself. 446 */ 447 private boolean isOpenedFromWithinApp() { 448 Intent i = getIntent(); 449 return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false)); 450 } 451 452 private void setDraftNeedsSaving(boolean needsSaving) { 453 mDraftNeedsSaving = needsSaving; 454 mSaveEnabled = needsSaving; 455 invalidateOptionsMenu(); 456 } 457 458 public void setFocusShifter(int fromViewId, final int targetViewId) { 459 View label = findViewById(fromViewId); 460 // Labels don't exist on the phone UI, so null check. 461 if (label != null) { 462 label.setOnClickListener(new View.OnClickListener() { 463 @Override 464 public void onClick(View v) { 465 findViewById(targetViewId).requestFocus(); 466 } 467 }); 468 } 469 } 470 471 private void initViews() { 472 mActionBar = getActionBar(); 473 mFromView = (TextView)findViewById(R.id.from); 474 mToView = (MultiAutoCompleteTextView)findViewById(R.id.to); 475 mCcView = (MultiAutoCompleteTextView)findViewById(R.id.cc); 476 mBccView = (MultiAutoCompleteTextView)findViewById(R.id.bcc); 477 mCcBccContainer = findViewById(R.id.cc_bcc_container); 478 mSubjectView = (EditText)findViewById(R.id.subject); 479 mMessageContentView = (EditText)findViewById(R.id.message_content); 480 mAttachments = (LinearLayout)findViewById(R.id.attachments); 481 mAttachmentContainer = (LinearLayout)findViewById(R.id.attachment_container); 482 mQuotedTextBar = findViewById(R.id.quoted_text_bar); 483 mIncludeQuotedTextCheckBox = (CheckBox) findViewById(R.id.include_quoted_text); 484 mQuotedText = (WebView)findViewById(R.id.quoted_text); 485 486 TextWatcher watcher = new TextWatcher() { 487 public void beforeTextChanged(CharSequence s, int start, 488 int before, int after) { } 489 490 public void onTextChanged(CharSequence s, int start, 491 int before, int count) { 492 setDraftNeedsSaving(true); 493 } 494 495 public void afterTextChanged(android.text.Editable s) { } 496 }; 497 498 /** 499 * Implements special address cleanup rules: 500 * The first space key entry following an "@" symbol that is followed by any combination 501 * of letters and symbols, including one+ dots and zero commas, should insert an extra 502 * comma (followed by the space). 503 */ 504 InputFilter recipientFilter = new InputFilter() { 505 506 public CharSequence filter(CharSequence source, int start, int end, Spanned dest, 507 int dstart, int dend) { 508 509 // quick check - did they enter a single space? 510 if (end-start != 1 || source.charAt(start) != ' ') { 511 return null; 512 } 513 514 // determine if the characters before the new space fit the pattern 515 // follow backwards and see if we find a comma, dot, or @ 516 int scanBack = dstart; 517 boolean dotFound = false; 518 while (scanBack > 0) { 519 char c = dest.charAt(--scanBack); 520 switch (c) { 521 case '.': 522 dotFound = true; // one or more dots are req'd 523 break; 524 case ',': 525 return null; 526 case '@': 527 if (!dotFound) { 528 return null; 529 } 530 531 // we have found a comma-insert case. now just do it 532 // in the least expensive way we can. 533 if (source instanceof Spanned) { 534 SpannableStringBuilder sb = new SpannableStringBuilder(","); 535 sb.append(source); 536 return sb; 537 } else { 538 return ", "; 539 } 540 default: 541 // just keep going 542 } 543 } 544 545 // no termination cases were found, so don't edit the input 546 return null; 547 } 548 }; 549 InputFilter[] recipientFilters = new InputFilter[] { recipientFilter }; 550 551 mToView.addTextChangedListener(watcher); 552 mCcView.addTextChangedListener(watcher); 553 mBccView.addTextChangedListener(watcher); 554 mSubjectView.addTextChangedListener(watcher); 555 mMessageContentView.addTextChangedListener(watcher); 556 557 // NOTE: assumes no other filters are set 558 mToView.setFilters(recipientFilters); 559 mCcView.setFilters(recipientFilters); 560 mBccView.setFilters(recipientFilters); 561 562 /* 563 * We set this to invisible by default. Other methods will turn it back on if it's 564 * needed. 565 */ 566 mQuotedTextBar.setVisibility(View.GONE); 567 setIncludeQuotedText(false, false); 568 569 mIncludeQuotedTextCheckBox.setOnClickListener(this); 570 571 EmailAddressValidator addressValidator = new EmailAddressValidator(); 572 573 setupAddressAdapters(); 574 mToView.setAdapter(mAddressAdapterTo); 575 mToView.setTokenizer(new Rfc822Tokenizer()); 576 mToView.setValidator(addressValidator); 577 578 mCcView.setAdapter(mAddressAdapterCc); 579 mCcView.setTokenizer(new Rfc822Tokenizer()); 580 mCcView.setValidator(addressValidator); 581 582 mBccView.setAdapter(mAddressAdapterBcc); 583 mBccView.setTokenizer(new Rfc822Tokenizer()); 584 mBccView.setValidator(addressValidator); 585 586 findViewById(R.id.add_cc_bcc).setOnClickListener(this); 587 findViewById(R.id.add_attachment).setOnClickListener(this); 588 589 setFocusShifter(R.id.to_label, R.id.to); 590 setFocusShifter(R.id.cc_label, R.id.cc); 591 setFocusShifter(R.id.bcc_label, R.id.bcc); 592 setFocusShifter(R.id.subject_label, R.id.subject); 593 setFocusShifter(R.id.tap_trap, R.id.message_content); 594 595 mSubjectView.setOnFocusChangeListener(this); 596 mMessageContentView.setOnFocusChangeListener(this); 597 598 updateAttachmentContainer(); 599 } 600 601 /** 602 * Set up address auto-completion adapters. 603 */ 604 private void setupAddressAdapters() { 605 mAddressAdapterTo = new EmailAddressAdapter(this); 606 mAddressAdapterCc = new EmailAddressAdapter(this); 607 mAddressAdapterBcc = new EmailAddressAdapter(this); 608 } 609 610 private class LoadMessageTask extends AsyncTask<Void, Void, Object[]> { 611 private final long mMessageId; 612 613 public LoadMessageTask(long messageId) { 614 mMessageId = messageId; 615 } 616 617 @Override 618 protected Object[] doInBackground(Void... params) { 619 synchronized (sSaveInProgressCondition) { 620 while (sSaveInProgress) { 621 try { 622 sSaveInProgressCondition.wait(); 623 } catch (InterruptedException e) { 624 // ignore & retry loop 625 } 626 } 627 } 628 Message message = Message.restoreMessageWithId(MessageCompose.this, mMessageId); 629 if (message == null) { 630 return new Object[] {null, null}; 631 } 632 long accountId = message.mAccountKey; 633 Account account = Account.restoreAccountWithId(MessageCompose.this, accountId); 634 try { 635 // Body body = Body.restoreBodyWithMessageId(MessageCompose.this, message.mId); 636 message.mHtml = Body.restoreBodyHtmlWithMessageId(MessageCompose.this, message.mId); 637 message.mText = Body.restoreBodyTextWithMessageId(MessageCompose.this, message.mId); 638 boolean isEditDraft = ACTION_EDIT_DRAFT.equals(mAction); 639 // the reply fields are only filled/used for Drafts. 640 if (isEditDraft) { 641 message.mHtmlReply = 642 Body.restoreReplyHtmlWithMessageId(MessageCompose.this, message.mId); 643 message.mTextReply = 644 Body.restoreReplyTextWithMessageId(MessageCompose.this, message.mId); 645 message.mIntroText = 646 Body.restoreIntroTextWithMessageId(MessageCompose.this, message.mId); 647 message.mSourceKey = Body.restoreBodySourceKey(MessageCompose.this, 648 message.mId); 649 } else { 650 message.mHtmlReply = null; 651 message.mTextReply = null; 652 message.mIntroText = null; 653 } 654 } catch (RuntimeException e) { 655 Log.d(Email.LOG_TAG, "Exception while loading message body: " + e); 656 return new Object[] {null, null}; 657 } 658 return new Object[]{message, account}; 659 } 660 661 @Override 662 protected void onPostExecute(Object[] messageAndAccount) { 663 if (messageAndAccount == null) { 664 return; 665 } 666 667 final Message message = (Message) messageAndAccount[0]; 668 final Account account = (Account) messageAndAccount[1]; 669 if (message == null && account == null) { 670 // Something unexpected happened: 671 // the message or the body couldn't be loaded by SQLite. 672 // Bail out. 673 Utility.showToast(MessageCompose.this, R.string.error_loading_message_body); 674 finish(); 675 return; 676 } 677 678 // Drafts and "forwards" need to include attachments from the original unless the 679 // account is marked as supporting smart forward 680 if (ACTION_EDIT_DRAFT.equals(mAction) || ACTION_FORWARD.equals(mAction)) { 681 final boolean draft = ACTION_EDIT_DRAFT.equals(mAction); 682 if (draft) { 683 mDraft = message; 684 } else { 685 mSource = message; 686 } 687 mLoadAttachmentsTask = new AsyncTask<Long, Void, Attachment[]>() { 688 @Override 689 protected Attachment[] doInBackground(Long... messageIds) { 690 return Attachment.restoreAttachmentsWithMessageId(MessageCompose.this, 691 messageIds[0]); 692 } 693 @Override 694 protected void onPostExecute(Attachment[] attachments) { 695 if (attachments == null) { 696 return; 697 } 698 boolean smartForward = 699 (account.mFlags & Account.FLAGS_SUPPORTS_SMART_FORWARD) != 0; 700 701 for (Attachment attachment : attachments) { 702 if (smartForward && !draft) { 703 attachment.mFlags |= Attachment.FLAG_SMART_FORWARD; 704 } 705 addAttachment(attachment, !smartForward); 706 } 707 } 708 }.execute(message.mId); 709 } else if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) { 710 mSource = message; 711 } else if (Email.LOGD) { 712 Email.log("Action " + mAction + " has unexpected EXTRA_MESSAGE_ID"); 713 } 714 715 setAccount(account); 716 processSourceMessageGuarded(message, mAccount); 717 mMessageLoaded = true; 718 } 719 } 720 721 public void onFocusChange(View view, boolean focused) { 722 if (focused) { 723 switch (view.getId()) { 724 case R.id.message_content: 725 setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null); 726 } 727 } 728 } 729 730 private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) { 731 if (addresses == null) { 732 return; 733 } 734 for (Address address : addresses) { 735 addAddress(view, address.toString()); 736 } 737 } 738 739 private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) { 740 if (addresses == null) { 741 return; 742 } 743 for (String oneAddress : addresses) { 744 addAddress(view, oneAddress); 745 } 746 } 747 748 private void addAddress(MultiAutoCompleteTextView view, String address) { 749 view.append(address + ", "); 750 } 751 752 private String getPackedAddresses(TextView view) { 753 Address[] addresses = Address.parse(view.getText().toString().trim()); 754 return Address.pack(addresses); 755 } 756 757 private Address[] getAddresses(TextView view) { 758 Address[] addresses = Address.parse(view.getText().toString().trim()); 759 return addresses; 760 } 761 762 /* 763 * Computes a short string indicating the destination of the message based on To, Cc, Bcc. 764 * If only one address appears, returns the friendly form of that address. 765 * Otherwise returns the friendly form of the first address appended with "and N others". 766 */ 767 private String makeDisplayName(String packedTo, String packedCc, String packedBcc) { 768 Address first = null; 769 int nRecipients = 0; 770 for (String packed: new String[] {packedTo, packedCc, packedBcc}) { 771 Address[] addresses = Address.unpack(packed); 772 nRecipients += addresses.length; 773 if (first == null && addresses.length > 0) { 774 first = addresses[0]; 775 } 776 } 777 if (nRecipients == 0) { 778 return ""; 779 } 780 String friendly = first.toFriendly(); 781 if (nRecipients == 1) { 782 return friendly; 783 } 784 return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1); 785 } 786 787 private ContentValues getUpdateContentValues(Message message) { 788 ContentValues values = new ContentValues(); 789 values.put(MessageColumns.TIMESTAMP, message.mTimeStamp); 790 values.put(MessageColumns.FROM_LIST, message.mFrom); 791 values.put(MessageColumns.TO_LIST, message.mTo); 792 values.put(MessageColumns.CC_LIST, message.mCc); 793 values.put(MessageColumns.BCC_LIST, message.mBcc); 794 values.put(MessageColumns.SUBJECT, message.mSubject); 795 values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName); 796 values.put(MessageColumns.FLAG_READ, message.mFlagRead); 797 values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded); 798 values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment); 799 values.put(MessageColumns.FLAGS, message.mFlags); 800 return values; 801 } 802 803 /** 804 * @param message The message to be updated. 805 * @param account the account (used to obtain From: address). 806 * @param hasAttachments true if it has one or more attachment. 807 * @param sending set true if the message is about to sent, in which case we perform final 808 * clean up; 809 */ 810 private void updateMessage(Message message, Account account, boolean hasAttachments, 811 boolean sending) { 812 if (message.mMessageId == null || message.mMessageId.length() == 0) { 813 message.mMessageId = Utility.generateMessageId(); 814 } 815 message.mTimeStamp = System.currentTimeMillis(); 816 message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack(); 817 message.mTo = getPackedAddresses(mToView); 818 message.mCc = getPackedAddresses(mCcView); 819 message.mBcc = getPackedAddresses(mBccView); 820 message.mSubject = mSubjectView.getText().toString(); 821 message.mText = mMessageContentView.getText().toString(); 822 message.mAccountKey = account.mId; 823 message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc); 824 message.mFlagRead = true; 825 message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; 826 message.mFlagAttachment = hasAttachments; 827 // Use the Intent to set flags saying this message is a reply or a forward and save the 828 // unique id of the source message 829 if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) { 830 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) 831 || ACTION_FORWARD.equals(mAction)) { 832 message.mSourceKey = mSource.mId; 833 // Get the body of the source message here 834 message.mHtmlReply = mSource.mHtml; 835 message.mTextReply = mSource.mText; 836 } 837 838 String fromAsString = Address.unpackToString(mSource.mFrom); 839 if (ACTION_FORWARD.equals(mAction)) { 840 message.mFlags |= Message.FLAG_TYPE_FORWARD; 841 String subject = mSource.mSubject; 842 String to = Address.unpackToString(mSource.mTo); 843 String cc = Address.unpackToString(mSource.mCc); 844 message.mIntroText = 845 getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString, 846 to != null ? to : "", cc != null ? cc : ""); 847 } else { 848 message.mFlags |= Message.FLAG_TYPE_REPLY; 849 message.mIntroText = 850 getString(R.string.message_compose_reply_header_fmt, fromAsString); 851 } 852 } 853 854 if (includeQuotedText()) { 855 message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 856 } else { 857 message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 858 if (sending) { 859 // If we are about to send a message, and not including the original message, 860 // clear the related field. 861 // We can't do this until the last minutes, so that the user can change their 862 // mind later and want to include it again. 863 mDraft.mIntroText = null; 864 mDraft.mTextReply = null; 865 mDraft.mHtmlReply = null; 866 mDraft.mSourceKey = 0; 867 mDraft.mFlags &= ~Message.FLAG_TYPE_MASK; 868 } 869 } 870 } 871 872 private Attachment[] getAttachmentsFromUI() { 873 int count = mAttachments.getChildCount(); 874 Attachment[] attachments = new Attachment[count]; 875 for (int i = 0; i < count; ++i) { 876 attachments[i] = (Attachment) mAttachments.getChildAt(i).getTag(); 877 } 878 return attachments; 879 } 880 881 /* This method does DB operations in UI thread because 882 the draftId is needed by onSaveInstanceState() which can't wait for it 883 to be saved in the background. 884 TODO: This will cause ANRs, so we need to find a better solution. 885 */ 886 private long getOrCreateDraftId() { 887 synchronized (mDraft) { 888 if (mDraft.mId > 0) { 889 return mDraft.mId; 890 } 891 // don't save draft if the source message did not load yet 892 if (!mMessageLoaded) { 893 return -1; 894 } 895 final Attachment[] attachments = getAttachmentsFromUI(); 896 updateMessage(mDraft, mAccount, attachments.length > 0, false); 897 mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); 898 return mDraft.mId; 899 } 900 } 901 902 private class SendOrSaveMessageTask extends AsyncTask<Void, Void, Void> { 903 private final boolean mSend; 904 905 public SendOrSaveMessageTask(boolean send) { 906 if (send && ActivityManager.isUserAMonkey()) { 907 Log.d(Email.LOG_TAG, "Inhibiting send while monkey is in charge."); 908 send = false; 909 } 910 mSend = send; 911 } 912 913 @Override 914 protected Void doInBackground(Void... params) { 915 synchronized (mDraft) { 916 final Attachment[] attachments = getAttachmentsFromUI(); 917 updateMessage(mDraft, mAccount, attachments.length > 0, mSend); 918 ContentResolver resolver = getContentResolver(); 919 if (mDraft.isSaved()) { 920 // Update the message 921 Uri draftUri = 922 ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId); 923 resolver.update(draftUri, getUpdateContentValues(mDraft), null, null); 924 // Update the body 925 ContentValues values = new ContentValues(); 926 values.put(BodyColumns.TEXT_CONTENT, mDraft.mText); 927 values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply); 928 values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply); 929 values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText); 930 values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey); 931 Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values); 932 } else { 933 // mDraft.mId is set upon return of saveToMailbox() 934 mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); 935 } 936 // For any unloaded attachment, set the flag saying we need it loaded 937 boolean hasUnloadedAttachments = false; 938 for (Attachment attachment : attachments) { 939 940 if (attachment.mContentUri == null && 941 ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) != 0)) { 942 attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD; 943 hasUnloadedAttachments = true; 944 if (Email.DEBUG){ 945 Log.d("MessageCompose", 946 "Requesting download of attachment #" + attachment.mId); 947 } 948 } 949 // Make sure the UI version of the attachment has the now-correct id; we will 950 // use the id again when coming back from picking new attachments 951 if (!attachment.isSaved()) { 952 // this attachment is new so save it to DB. 953 attachment.mMessageKey = mDraft.mId; 954 attachment.save(MessageCompose.this); 955 } else if (attachment.mMessageKey != mDraft.mId) { 956 // We clone the attachment and save it again; otherwise, it will 957 // continue to point to the source message. From this point forward, 958 // the attachments will be independent of the original message in the 959 // database; however, we still need the message on the server in order 960 // to retrieve unloaded attachments 961 attachment.mMessageKey = mDraft.mId; 962 ContentValues cv = attachment.toContentValues(); 963 cv.put(Attachment.FLAGS, attachment.mFlags); 964 cv.put(Attachment.MESSAGE_KEY, mDraft.mId); 965 getContentResolver().insert(Attachment.CONTENT_URI, cv); 966 } 967 } 968 969 if (mSend) { 970 // Let the user know if message sending might be delayed by background 971 // downlading of unloaded attachments 972 if (hasUnloadedAttachments) { 973 Utility.showToast(MessageCompose.this, 974 R.string.message_view_attachment_background_load); 975 } 976 mController.sendMessage(mDraft.mId, mDraft.mAccountKey); 977 } 978 return null; 979 } 980 } 981 982 @Override 983 protected void onPostExecute(Void param) { 984 synchronized (sSaveInProgressCondition) { 985 sSaveInProgress = false; 986 sSaveInProgressCondition.notify(); 987 } 988 if (isCancelled()) { 989 return; 990 } 991 // Don't display the toast if the user is just changing the orientation 992 if (!mSend && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 993 Toast.makeText(MessageCompose.this, R.string.message_saved_toast, 994 Toast.LENGTH_LONG).show(); 995 } 996 } 997 } 998 999 /** 1000 * Send or save a message: 1001 * - out of the UI thread 1002 * - write to Drafts 1003 * - if send, invoke Controller.sendMessage() 1004 * - when operation is complete, display toast 1005 */ 1006 private void sendOrSaveMessage(boolean send) { 1007 if (!mMessageLoaded) { 1008 // early save, before the message was loaded: do nothing 1009 return; 1010 } 1011 synchronized (sSaveInProgressCondition) { 1012 sSaveInProgress = true; 1013 } 1014 new SendOrSaveMessageTask(send).execute(); 1015 } 1016 1017 private void saveIfNeeded() { 1018 if (!mDraftNeedsSaving) { 1019 return; 1020 } 1021 setDraftNeedsSaving(false); 1022 sendOrSaveMessage(false); 1023 } 1024 1025 /** 1026 * Checks whether all the email addresses listed in TO, CC, BCC are valid. 1027 */ 1028 /* package */ boolean isAddressAllValid() { 1029 for (TextView view : new TextView[]{mToView, mCcView, mBccView}) { 1030 String addresses = view.getText().toString().trim(); 1031 if (!Address.isAllValid(addresses)) { 1032 view.setError(getString(R.string.message_compose_error_invalid_email)); 1033 return false; 1034 } 1035 } 1036 return true; 1037 } 1038 1039 private void onSend() { 1040 if (!isAddressAllValid()) { 1041 Toast.makeText(this, getString(R.string.message_compose_error_invalid_email), 1042 Toast.LENGTH_LONG).show(); 1043 } else if (getAddresses(mToView).length == 0 && 1044 getAddresses(mCcView).length == 0 && 1045 getAddresses(mBccView).length == 0) { 1046 mToView.setError(getString(R.string.message_compose_error_no_recipients)); 1047 Toast.makeText(this, getString(R.string.message_compose_error_no_recipients), 1048 Toast.LENGTH_LONG).show(); 1049 } else { 1050 sendOrSaveMessage(true); 1051 setDraftNeedsSaving(false); 1052 finish(); 1053 } 1054 } 1055 1056 private void onDiscard() { 1057 DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog"); 1058 } 1059 1060 /** 1061 * Called when ok on the "discard draft" dialog is pressed. Actually delete the draft. 1062 */ 1063 @Override 1064 public void onDeleteMessageConfirmationDialogOkPressed() { 1065 if (mDraft.mId > 0) { 1066 // By the way, we can't pass the message ID from onDiscard() to here (using a 1067 // dialog argument or whatever), because you can rotate the screen when the dialog is 1068 // shown, and during rotation we save & restore the draft. If it's the 1069 // first save, we give it an ID at this point for the first time (and last time). 1070 // Which means it's possible for a draft to not have an ID in onDiscard(), 1071 // but here. 1072 mController.deleteMessage(mDraft.mId, mDraft.mAccountKey); 1073 } 1074 Utility.showToast(MessageCompose.this, R.string.message_discarded_toast); 1075 setDraftNeedsSaving(false); 1076 finish(); 1077 } 1078 1079 private void onSave() { 1080 saveIfNeeded(); 1081 finish(); 1082 } 1083 1084 private void showCcBccFieldsIfFilled() { 1085 if ((mCcView.length() > 0) || (mBccView.length() > 0)) { 1086 showCcBccFields(); 1087 } 1088 } 1089 1090 private void showCcBccFields() { 1091 mCcBccContainer.setVisibility(View.VISIBLE); 1092 findViewById(R.id.add_cc_bcc).setVisibility(View.INVISIBLE); 1093 } 1094 1095 /** 1096 * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over. 1097 */ 1098 private void onAddAttachment() { 1099 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 1100 i.addCategory(Intent.CATEGORY_OPENABLE); 1101 i.setType(Email.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]); 1102 startActivityForResult( 1103 Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)), 1104 ACTIVITY_REQUEST_PICK_ATTACHMENT); 1105 } 1106 1107 private Attachment loadAttachmentInfo(Uri uri) { 1108 long size = -1; 1109 ContentResolver contentResolver = getContentResolver(); 1110 1111 // Load name & size independently, because not all providers support both 1112 final String name = Utility.getContentFileName(this, uri); 1113 1114 Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION, 1115 null, null, null); 1116 if (metadataCursor != null) { 1117 try { 1118 if (metadataCursor.moveToFirst()) { 1119 size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE); 1120 } 1121 } finally { 1122 metadataCursor.close(); 1123 } 1124 } 1125 1126 // When the size is not provided, we need to determine it locally. 1127 if (size < 0) { 1128 // if the URI is a file: URI, ask file system for its size 1129 if ("file".equalsIgnoreCase(uri.getScheme())) { 1130 String path = uri.getPath(); 1131 if (path != null) { 1132 File file = new File(path); 1133 size = file.length(); // Returns 0 for file not found 1134 } 1135 } 1136 1137 if (size <= 0) { 1138 // The size was not measurable; This attachment is not safe to use. 1139 // Quick hack to force a relevant error into the UI 1140 // TODO: A proper announcement of the problem 1141 size = Email.MAX_ATTACHMENT_UPLOAD_SIZE + 1; 1142 } 1143 } 1144 1145 String contentType = contentResolver.getType(uri); 1146 if (contentType == null) { 1147 contentType = ""; 1148 } 1149 1150 Attachment attachment = new Attachment(); 1151 attachment.mFileName = name; 1152 attachment.mContentUri = uri.toString(); 1153 attachment.mSize = size; 1154 attachment.mMimeType = contentType; 1155 return attachment; 1156 } 1157 1158 private void addAttachment(Attachment attachment, boolean allowDelete) { 1159 // Before attaching the attachment, make sure it meets any other pre-attach criteria 1160 if (attachment.mSize > Email.MAX_ATTACHMENT_UPLOAD_SIZE) { 1161 Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG) 1162 .show(); 1163 return; 1164 } 1165 1166 View view = getLayoutInflater().inflate(R.layout.message_compose_attachment, 1167 mAttachments, false); 1168 TextView nameView = (TextView)view.findViewById(R.id.attachment_name); 1169 ImageButton delete = (ImageButton)view.findViewById(R.id.attachment_delete); 1170 TextView sizeView = (TextView)view.findViewById(R.id.attachment_size); 1171 1172 nameView.setText(attachment.mFileName); 1173 sizeView.setText(Utility.formatSize(this, attachment.mSize)); 1174 if (allowDelete) { 1175 delete.setOnClickListener(this); 1176 delete.setTag(view); 1177 } else { 1178 delete.setVisibility(View.INVISIBLE); 1179 } 1180 view.setTag(attachment); 1181 mAttachments.addView(view); 1182 updateAttachmentContainer(); 1183 } 1184 1185 private void updateAttachmentContainer() { 1186 mAttachmentContainer.setVisibility(mAttachments.getChildCount() == 0 1187 ? View.GONE : View.VISIBLE); 1188 } 1189 1190 private void addAttachment(Uri uri) { 1191 addAttachment(loadAttachmentInfo(uri), true); 1192 } 1193 1194 @Override 1195 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 1196 if (data == null) { 1197 return; 1198 } 1199 addAttachment(data.getData()); 1200 setDraftNeedsSaving(true); 1201 } 1202 1203 private boolean includeQuotedText() { 1204 return mIncludeQuotedTextCheckBox.isChecked(); 1205 } 1206 1207 public void onClick(View view) { 1208 if (handleCommand(view.getId())) { 1209 return; 1210 } 1211 switch (view.getId()) { 1212 case R.id.attachment_delete: 1213 onDeleteAttachment(view); // Needs a view; can't be a menu item 1214 break; 1215 } 1216 } 1217 1218 private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) { 1219 mIncludeQuotedTextCheckBox.setChecked(include); 1220 mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked() 1221 ? View.VISIBLE : View.GONE); 1222 if (updateNeedsSaving) { 1223 setDraftNeedsSaving(true); 1224 } 1225 } 1226 1227 private void onDeleteAttachment(View delButtonView) { 1228 /* 1229 * The view is the delete button, and we have previously set the tag of 1230 * the delete button to the view that owns it. We don't use parent because the 1231 * view is very complex and could change in the future. 1232 */ 1233 View attachmentView = (View) delButtonView.getTag(); 1234 Attachment attachment = (Attachment) attachmentView.getTag(); 1235 mAttachments.removeView(attachmentView); 1236 updateAttachmentContainer(); 1237 if (attachment.isSaved()) { 1238 // The following async task for deleting attachments: 1239 // - can be started multiple times in parallel (to delete multiple attachments). 1240 // - need not be interrupted on activity exit, instead should run to completion. 1241 new AsyncTask<Long, Void, Void>() { 1242 @Override 1243 protected Void doInBackground(Long... attachmentIds) { 1244 mController.deleteAttachment(attachmentIds[0]); 1245 return null; 1246 } 1247 }.execute(attachment.mId); 1248 } 1249 setDraftNeedsSaving(true); 1250 } 1251 1252 @Override 1253 public boolean onOptionsItemSelected(MenuItem item) { 1254 if (handleCommand(item.getItemId())) { 1255 return true; 1256 } 1257 return super.onOptionsItemSelected(item); 1258 } 1259 1260 private boolean handleCommand(int viewId) { 1261 switch (viewId) { 1262 case android.R.id.home: 1263 onActionBarHomePressed(); 1264 return true; 1265 case R.id.send: 1266 onSend(); 1267 return true; 1268 case R.id.save: 1269 onSave(); 1270 return true; 1271 case R.id.discard: 1272 onDiscard(); 1273 return true; 1274 case R.id.include_quoted_text: 1275 // The checkbox is already toggled at this point. 1276 setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true); 1277 return true; 1278 case R.id.add_cc_bcc: 1279 showCcBccFields(); 1280 return true; 1281 case R.id.add_attachment: 1282 onAddAttachment(); 1283 return true; 1284 } 1285 return false; 1286 } 1287 1288 private void onActionBarHomePressed() { 1289 finish(); 1290 if (isOpenedFromWithinApp()) { 1291 // If opend from within the app, we just close it. 1292 } else { 1293 // Otherwise, need to open the main screen. Let Welcome do that. 1294 Welcome.actionStart(this); 1295 } 1296 } 1297 1298 @Override 1299 public boolean onCreateOptionsMenu(Menu menu) { 1300 super.onCreateOptionsMenu(menu); 1301 getMenuInflater().inflate(R.menu.message_compose_option, menu); 1302 return true; 1303 } 1304 1305 @Override 1306 public boolean onPrepareOptionsMenu(Menu menu) { 1307 menu.findItem(R.id.save).setEnabled(mSaveEnabled); 1308 return true; 1309 } 1310 1311 /** 1312 * Set a message body and a signature when the Activity is launched. 1313 * 1314 * @param text the message body 1315 */ 1316 /* package */ void setInitialComposeText(CharSequence text, String signature) { 1317 int textLength = 0; 1318 if (text != null) { 1319 mMessageContentView.append(text); 1320 textLength = text.length(); 1321 } 1322 if (!TextUtils.isEmpty(signature)) { 1323 if (textLength == 0 || text.charAt(textLength - 1) != '\n') { 1324 mMessageContentView.append("\n"); 1325 } 1326 mMessageContentView.append(signature); 1327 } 1328 } 1329 1330 /** 1331 * Fill all the widgets with the content found in the Intent Extra, if any. 1332 * 1333 * Note that we don't actually check the intent action (typically VIEW, SENDTO, or SEND). 1334 * There is enough overlap in the definitions that it makes more sense to simply check for 1335 * all available data and use as much of it as possible. 1336 * 1337 * With one exception: EXTRA_STREAM is defined as only valid for ACTION_SEND. 1338 * 1339 * @param intent the launch intent 1340 */ 1341 /* package */ void initFromIntent(Intent intent) { 1342 1343 // First, add values stored in top-level extras 1344 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 1345 if (extraStrings != null) { 1346 addAddresses(mToView, extraStrings); 1347 } 1348 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 1349 if (extraStrings != null) { 1350 addAddresses(mCcView, extraStrings); 1351 } 1352 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 1353 if (extraStrings != null) { 1354 addAddresses(mBccView, extraStrings); 1355 } 1356 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 1357 if (extraString != null) { 1358 mSubjectView.setText(extraString); 1359 } 1360 1361 // Next, if we were invoked with a URI, try to interpret it 1362 // We'll take two courses here. If it's mailto:, there is a specific set of rules 1363 // that define various optional fields. However, for any other scheme, we'll simply 1364 // take the entire scheme-specific part and interpret it as a possible list of addresses. 1365 final Uri dataUri = intent.getData(); 1366 if (dataUri != null) { 1367 if ("mailto".equals(dataUri.getScheme())) { 1368 initializeFromMailTo(dataUri.toString()); 1369 } else { 1370 String toText = dataUri.getSchemeSpecificPart(); 1371 if (toText != null) { 1372 addAddresses(mToView, toText.split(",")); 1373 } 1374 } 1375 } 1376 1377 // Next, fill in the plaintext (note, this will override mailto:?body=) 1378 CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); 1379 if (text != null) { 1380 setInitialComposeText(text, null); 1381 } 1382 1383 // Next, convert EXTRA_STREAM into an attachment 1384 if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) { 1385 String type = intent.getType(); 1386 Uri stream = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); 1387 if (stream != null && type != null) { 1388 if (MimeUtility.mimeTypeMatches(type, 1389 Email.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) { 1390 addAttachment(stream); 1391 } 1392 } 1393 } 1394 1395 if (Intent.ACTION_SEND_MULTIPLE.equals(mAction) 1396 && intent.hasExtra(Intent.EXTRA_STREAM)) { 1397 ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 1398 if (list != null) { 1399 for (Parcelable parcelable : list) { 1400 Uri uri = (Uri) parcelable; 1401 if (uri != null) { 1402 Attachment attachment = loadAttachmentInfo(uri); 1403 if (MimeUtility.mimeTypeMatches(attachment.mMimeType, 1404 Email.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) { 1405 addAttachment(attachment, true); 1406 } 1407 } 1408 } 1409 } 1410 } 1411 1412 // Finally - expose fields that were filled in but are normally hidden, and set focus 1413 showCcBccFieldsIfFilled(); 1414 setNewMessageFocus(); 1415 setDraftNeedsSaving(false); 1416 } 1417 1418 /** 1419 * When we are launched with an intent that includes a mailto: URI, we can actually 1420 * gather quite a few of our message fields from it. 1421 * 1422 * @mailToString the href (which must start with "mailto:"). 1423 */ 1424 private void initializeFromMailTo(String mailToString) { 1425 1426 // Chop up everything between mailto: and ? to find recipients 1427 int index = mailToString.indexOf("?"); 1428 int length = "mailto".length() + 1; 1429 String to; 1430 try { 1431 // Extract the recipient after mailto: 1432 if (index == -1) { 1433 to = decode(mailToString.substring(length)); 1434 } else { 1435 to = decode(mailToString.substring(length, index)); 1436 } 1437 addAddresses(mToView, to.split(" ,")); 1438 } catch (UnsupportedEncodingException e) { 1439 Log.e(Email.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'"); 1440 } 1441 1442 // Extract the other parameters 1443 1444 // We need to disguise this string as a URI in order to parse it 1445 Uri uri = Uri.parse("foo://" + mailToString); 1446 1447 List<String> cc = uri.getQueryParameters("cc"); 1448 addAddresses(mCcView, cc.toArray(new String[cc.size()])); 1449 1450 List<String> otherTo = uri.getQueryParameters("to"); 1451 addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()])); 1452 1453 List<String> bcc = uri.getQueryParameters("bcc"); 1454 addAddresses(mBccView, bcc.toArray(new String[bcc.size()])); 1455 1456 List<String> subject = uri.getQueryParameters("subject"); 1457 if (subject.size() > 0) { 1458 mSubjectView.setText(subject.get(0)); 1459 } 1460 1461 List<String> body = uri.getQueryParameters("body"); 1462 if (body.size() > 0) { 1463 setInitialComposeText(body.get(0), (mAccount != null) ? mAccount.mSignature : null); 1464 } 1465 } 1466 1467 private String decode(String s) throws UnsupportedEncodingException { 1468 return URLDecoder.decode(s, "UTF-8"); 1469 } 1470 1471 // used by processSourceMessage() 1472 private void displayQuotedText(String textBody, String htmlBody) { 1473 /* Use plain-text body if available, otherwise use HTML body. 1474 * This matches the desired behavior for IMAP/POP where we only send plain-text, 1475 * and for EAS which sends HTML and has no plain-text body. 1476 */ 1477 boolean plainTextFlag = textBody != null; 1478 String text = plainTextFlag ? textBody : htmlBody; 1479 if (text != null) { 1480 text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text; 1481 // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML 1482 // EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount, 1483 // text, message, 0); 1484 mQuotedTextBar.setVisibility(View.VISIBLE); 1485 if (mQuotedText != null) { 1486 mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); 1487 } 1488 } 1489 } 1490 1491 /** 1492 * Given a packed address String, the address of our sending account, a view, and a list of 1493 * addressees already added to other addressing views, adds unique addressees that don't 1494 * match our address to the passed in view 1495 */ 1496 private boolean safeAddAddresses(String addrs, String ourAddress, 1497 MultiAutoCompleteTextView view, ArrayList<Address> addrList) { 1498 boolean added = false; 1499 for (Address address : Address.unpack(addrs)) { 1500 // Don't send to ourselves or already-included addresses 1501 if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) { 1502 addrList.add(address); 1503 addAddress(view, address.toString()); 1504 added = true; 1505 } 1506 } 1507 return added; 1508 } 1509 1510 /** 1511 * Set up the to and cc views properly for the "reply" and "replyAll" cases. What's important 1512 * is that we not 1) send to ourselves, and 2) duplicate addressees. 1513 * @param message the message we're replying to 1514 * @param account the account we're sending from 1515 * @param toView the "To" view 1516 * @param ccView the "Cc" view 1517 * @param replyAll whether this is a replyAll (vs a reply) 1518 */ 1519 /*package*/ void setupAddressViews(Message message, Account account, 1520 MultiAutoCompleteTextView toView, MultiAutoCompleteTextView ccView, boolean replyAll) { 1521 /* 1522 * If a reply-to was included with the message use that, otherwise use the from 1523 * or sender address. 1524 */ 1525 Address[] replyToAddresses = Address.unpack(message.mReplyTo); 1526 if (replyToAddresses.length == 0) { 1527 replyToAddresses = Address.unpack(message.mFrom); 1528 } 1529 addAddresses(mToView, replyToAddresses); 1530 1531 if (replyAll) { 1532 // Keep a running list of addresses we're sending to 1533 ArrayList<Address> allAddresses = new ArrayList<Address>(); 1534 String ourAddress = account.mEmailAddress; 1535 1536 for (Address address: replyToAddresses) { 1537 allAddresses.add(address); 1538 } 1539 1540 safeAddAddresses(message.mTo, ourAddress, mToView, allAddresses); 1541 safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses); 1542 } 1543 showCcBccFieldsIfFilled(); 1544 } 1545 1546 void processSourceMessageGuarded(Message message, Account account) { 1547 // Make sure we only do this once (otherwise we'll duplicate addresses!) 1548 if (!mSourceMessageProcessed) { 1549 processSourceMessage(message, account); 1550 mSourceMessageProcessed = true; 1551 } 1552 1553 /* The quoted text is displayed in a WebView whose content is not automatically 1554 * saved/restored by onRestoreInstanceState(), so we need to *always* restore it here, 1555 * regardless of the value of mSourceMessageProcessed. 1556 * This only concerns EDIT_DRAFT because after a configuration change we're always 1557 * in EDIT_DRAFT. 1558 */ 1559 if (ACTION_EDIT_DRAFT.equals(mAction)) { 1560 displayQuotedText(message.mTextReply, message.mHtmlReply); 1561 setIncludeQuotedText((mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0, 1562 false); 1563 } 1564 } 1565 1566 /** 1567 * Pull out the parts of the now loaded source message and apply them to the new message 1568 * depending on the type of message being composed. 1569 * @param message 1570 */ 1571 /* package */ 1572 void processSourceMessage(Message message, Account account) { 1573 setDraftNeedsSaving(true); 1574 final String subject = message.mSubject; 1575 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) { 1576 setupAddressViews(message, account, mToView, mCcView, 1577 ACTION_REPLY_ALL.equals(mAction)); 1578 if (subject != null && !subject.toLowerCase().startsWith("re:")) { 1579 mSubjectView.setText("Re: " + subject); 1580 } else { 1581 mSubjectView.setText(subject); 1582 } 1583 displayQuotedText(message.mText, message.mHtml); 1584 setIncludeQuotedText(true, false); 1585 setInitialComposeText(null, (account != null) ? account.mSignature : null); 1586 } else if (ACTION_FORWARD.equals(mAction)) { 1587 mSubjectView.setText(subject != null && !subject.toLowerCase().startsWith("fwd:") ? 1588 "Fwd: " + subject : subject); 1589 displayQuotedText(message.mText, message.mHtml); 1590 setIncludeQuotedText(true, false); 1591 setInitialComposeText(null, (account != null) ? account.mSignature : null); 1592 } else if (ACTION_EDIT_DRAFT.equals(mAction)) { 1593 mSubjectView.setText(subject); 1594 addAddresses(mToView, Address.unpack(message.mTo)); 1595 Address[] cc = Address.unpack(message.mCc); 1596 if (cc.length > 0) { 1597 addAddresses(mCcView, cc); 1598 } 1599 Address[] bcc = Address.unpack(message.mBcc); 1600 if (bcc.length > 0) { 1601 addAddresses(mBccView, bcc); 1602 } 1603 1604 mMessageContentView.setText(message.mText); 1605 // TODO: re-enable loadAttachments 1606 // loadAttachments(message, 0); 1607 setDraftNeedsSaving(false); 1608 } 1609 showCcBccFieldsIfFilled(); 1610 setNewMessageFocus(); 1611 } 1612 1613 /** 1614 * Set a cursor to the end of a body except a signature 1615 */ 1616 /* package */ void setMessageContentSelection(String signature) { 1617 // when selecting the message content, explicitly move IP to the end of the message, 1618 // so you can quickly resume typing into a draft 1619 int selection = mMessageContentView.length(); 1620 if (!TextUtils.isEmpty(signature)) { 1621 int signatureLength = signature.length(); 1622 int estimatedSelection = selection - signatureLength; 1623 if (estimatedSelection >= 0) { 1624 CharSequence text = mMessageContentView.getText(); 1625 int i = 0; 1626 while (i < signatureLength 1627 && text.charAt(estimatedSelection + i) == signature.charAt(i)) { 1628 ++i; 1629 } 1630 if (i == signatureLength) { 1631 selection = estimatedSelection; 1632 while (selection > 0 && text.charAt(selection - 1) == '\n') { 1633 --selection; 1634 } 1635 } 1636 } 1637 } 1638 mMessageContentView.setSelection(selection, selection); 1639 } 1640 1641 /** 1642 * In order to accelerate typing, position the cursor in the first empty field, 1643 * or at the end of the body composition field if none are empty. Typically, this will 1644 * play out as follows: 1645 * Reply / Reply All - put cursor in the empty message body 1646 * Forward - put cursor in the empty To field 1647 * Edit Draft - put cursor in whatever field still needs entry 1648 */ 1649 private void setNewMessageFocus() { 1650 if (mToView.length() == 0) { 1651 mToView.requestFocus(); 1652 } else if (mSubjectView.length() == 0) { 1653 mSubjectView.requestFocus(); 1654 } else { 1655 mMessageContentView.requestFocus(); 1656 setMessageContentSelection((mAccount != null) ? mAccount.mSignature : null); 1657 } 1658 } 1659} 1660