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