MessageCompose.java revision 6d75ae39f0274651249150588789dfaa60a7077c
1/* 2 * Copyright (C) 2008 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17package com.android.email.activity; 18 19import com.android.email.Controller; 20import com.android.email.Email; 21import com.android.email.EmailAddressAdapter; 22import com.android.email.EmailAddressValidator; 23import com.android.email.R; 24import com.android.email.mail.internet.EmailHtmlUtil; 25import com.android.emailcommon.Logging; 26import com.android.emailcommon.internet.MimeUtility; 27import com.android.emailcommon.mail.Address; 28import com.android.emailcommon.provider.EmailContent; 29import com.android.emailcommon.provider.EmailContent.Account; 30import com.android.emailcommon.provider.EmailContent.Attachment; 31import com.android.emailcommon.provider.EmailContent.Body; 32import com.android.emailcommon.provider.EmailContent.BodyColumns; 33import com.android.emailcommon.provider.EmailContent.Message; 34import com.android.emailcommon.provider.EmailContent.MessageColumns; 35import com.android.emailcommon.utility.AttachmentUtilities; 36import com.android.emailcommon.utility.EmailAsyncTask; 37import com.android.emailcommon.utility.Utility; 38import com.google.common.annotations.VisibleForTesting; 39 40import android.app.ActionBar; 41import android.app.Activity; 42import android.app.ActivityManager; 43import android.content.ActivityNotFoundException; 44import android.content.ContentResolver; 45import android.content.ContentUris; 46import android.content.ContentValues; 47import android.content.Context; 48import android.content.Intent; 49import android.content.pm.ActivityInfo; 50import android.database.Cursor; 51import android.net.Uri; 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 93 private static final String ACTION_REPLY = "com.android.email.intent.action.REPLY"; 94 private static final String ACTION_REPLY_ALL = "com.android.email.intent.action.REPLY_ALL"; 95 private static final String ACTION_FORWARD = "com.android.email.intent.action.FORWARD"; 96 private static final String ACTION_EDIT_DRAFT = "com.android.email.intent.action.EDIT_DRAFT"; 97 98 private static final String EXTRA_ACCOUNT_ID = "account_id"; 99 private static final String EXTRA_MESSAGE_ID = "message_id"; 100 /** If the intent is sent from the email app itself, it should have this boolean extra. */ 101 private static final String EXTRA_FROM_WITHIN_APP = "from_within_app"; 102 103 private static final String STATE_KEY_CC_SHOWN = 104 "com.android.email.activity.MessageCompose.ccShown"; 105 private static final String STATE_KEY_QUOTED_TEXT_SHOWN = 106 "com.android.email.activity.MessageCompose.quotedTextShown"; 107 private static final String STATE_KEY_SOURCE_MESSAGE_PROCED = 108 "com.android.email.activity.MessageCompose.stateKeySourceMessageProced"; 109 private static final String STATE_KEY_DRAFT_ID = 110 "com.android.email.activity.MessageCompose.draftId"; 111 112 private static final int ACTIVITY_REQUEST_PICK_ATTACHMENT = 1; 113 114 private static final String[] ATTACHMENT_META_SIZE_PROJECTION = { 115 OpenableColumns.SIZE 116 }; 117 private static final int ATTACHMENT_META_SIZE_COLUMN_SIZE = 0; 118 119 // Is set while the draft is saved by a background thread. 120 // Is static in order to be shared between the two activity instances 121 // on orientation change. 122 private static boolean sSaveInProgress = false; 123 // lock and condition for sSaveInProgress 124 private static final Object sSaveInProgressCondition = new Object(); 125 126 private Account mAccount; 127 128 // mDraft has mId > 0 after the first draft save. 129 private Message mDraft = new Message(); 130 131 // mSource is only set for REPLY, REPLY_ALL and FORWARD, and contains the source message. 132 private Message mSource; 133 134 // we use mAction instead of Intent.getAction() because sometimes we need to 135 // re-write the action to EDIT_DRAFT. 136 private String mAction; 137 138 /** 139 * Indicates that the source message has been processed at least once and should not 140 * be processed on any subsequent loads. This protects us from adding attachments that 141 * have already been added from the restore of the view state. 142 */ 143 private boolean mSourceMessageProcessed = false; 144 145 private TextView mFromView; 146 private MultiAutoCompleteTextView mToView; 147 private MultiAutoCompleteTextView mCcView; 148 private MultiAutoCompleteTextView mBccView; 149 private View mCcBccContainer; 150 private EditText mSubjectView; 151 private EditText mMessageContentView; 152 private View mAttachmentContainer; 153 private LinearLayout mAttachments; 154 private View mQuotedTextBar; 155 private CheckBox mIncludeQuotedTextCheckBox; 156 private WebView mQuotedText; 157 158 private Controller mController; 159 private boolean mDraftNeedsSaving; 160 private boolean mMessageLoaded; 161 private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker(); 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 // Use the fields found in the Intent to prefill as much of the message as possible 330 initFromIntent(intent); 331 setDraftNeedsSaving(true); 332 mMessageLoaded = true; 333 mSourceMessageProcessed = true; 334 } else { 335 // Otherwise, handle the internal cases (Message Composer invoked from within app) 336 long messageId = draftId != -1 ? draftId : intent.getLongExtra(EXTRA_MESSAGE_ID, -1); 337 if (messageId != -1) { 338 new LoadMessageTask(messageId).executeParallel(); 339 } else { 340 setAccount(intent); 341 // Since this is a new message, we don't need to call LoadMessageTask. 342 // But we DO need to set mMessageLoaded to indicate the message can be sent 343 mMessageLoaded = true; 344 mSourceMessageProcessed = true; 345 } 346 setInitialComposeText(null, getAccountSignature(mAccount)); 347 } 348 349 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction) || 350 ACTION_FORWARD.equals(mAction) || ACTION_EDIT_DRAFT.equals(mAction)) { 351 /* 352 * If we need to load the message we add ourself as a message listener here 353 * so we can kick it off. Normally we add in onResume but we don't 354 * want to reload the message every time the activity is resumed. 355 * There is no harm in adding twice. 356 */ 357 // TODO: signal the controller to load the message 358 } 359 } 360 361 // needed for unit tests 362 @Override 363 public void setIntent(Intent intent) { 364 super.setIntent(intent); 365 mAction = intent.getAction(); 366 } 367 368 @Override 369 public void onResume() { 370 super.onResume(); 371 372 // Exit immediately if the accounts list has changed (e.g. externally deleted) 373 if (Email.getNotifyUiAccountsChanged()) { 374 Welcome.actionStart(this); 375 finish(); 376 return; 377 } 378 } 379 380 @Override 381 public void onPause() { 382 super.onPause(); 383 saveIfNeeded(); 384 } 385 386 /** 387 * We override onDestroy to make sure that the WebView gets explicitly destroyed. 388 * Otherwise it can leak native references. 389 */ 390 @Override 391 public void onDestroy() { 392 super.onDestroy(); 393 mQuotedText.destroy(); 394 mQuotedText = null; 395 396 mTaskTracker.cancellAllInterrupt(); 397 398 if (mAddressAdapterTo != null) { 399 mAddressAdapterTo.close(); 400 } 401 if (mAddressAdapterCc != null) { 402 mAddressAdapterCc.close(); 403 } 404 if (mAddressAdapterBcc != null) { 405 mAddressAdapterBcc.close(); 406 } 407 } 408 409 /** 410 * The framework handles most of the fields, but we need to handle stuff that we 411 * dynamically show and hide: 412 * Cc field, 413 * Bcc field, 414 * Quoted text, 415 */ 416 @Override 417 protected void onSaveInstanceState(Bundle outState) { 418 super.onSaveInstanceState(outState); 419 long draftId = getOrCreateDraftId(); 420 if (draftId != -1) { 421 outState.putLong(STATE_KEY_DRAFT_ID, draftId); 422 } 423 outState.putBoolean(STATE_KEY_CC_SHOWN, mCcBccContainer.getVisibility() == View.VISIBLE); 424 outState.putBoolean(STATE_KEY_QUOTED_TEXT_SHOWN, 425 mQuotedTextBar.getVisibility() == View.VISIBLE); 426 outState.putBoolean(STATE_KEY_SOURCE_MESSAGE_PROCED, mSourceMessageProcessed); 427 } 428 429 @Override 430 protected void onRestoreInstanceState(Bundle savedInstanceState) { 431 super.onRestoreInstanceState(savedInstanceState); 432 if (savedInstanceState.getBoolean(STATE_KEY_CC_SHOWN)) { 433 showCcBccFields(); 434 } 435 mQuotedTextBar.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 436 View.VISIBLE : View.GONE); 437 mQuotedText.setVisibility(savedInstanceState.getBoolean(STATE_KEY_QUOTED_TEXT_SHOWN) ? 438 View.VISIBLE : View.GONE); 439 setDraftNeedsSaving(false); 440 } 441 442 /** 443 * @return true if the activity was opened by the email app itself. 444 */ 445 private boolean isOpenedFromWithinApp() { 446 Intent i = getIntent(); 447 return (i != null && i.getBooleanExtra(EXTRA_FROM_WITHIN_APP, false)); 448 } 449 450 private void setDraftNeedsSaving(boolean needsSaving) { 451 mDraftNeedsSaving = needsSaving; 452 mSaveEnabled = needsSaving; 453 invalidateOptionsMenu(); 454 } 455 456 public void setFocusShifter(int fromViewId, final int targetViewId) { 457 View label = findViewById(fromViewId); // xlarge only 458 if (label != null) { 459 final View target = UiUtilities.getView(this, targetViewId); 460 label.setOnClickListener(new View.OnClickListener() { 461 @Override 462 public void onClick(View v) { 463 target.requestFocus(); 464 } 465 }); 466 } 467 } 468 469 private void initViews() { 470 mFromView = (TextView) UiUtilities.getView(this, R.id.from); 471 mToView = (MultiAutoCompleteTextView) UiUtilities.getView(this, R.id.to); 472 mCcView = (MultiAutoCompleteTextView) UiUtilities.getView(this, R.id.cc); 473 mBccView = (MultiAutoCompleteTextView) UiUtilities.getView(this, R.id.bcc); 474 mCcBccContainer = UiUtilities.getView(this, R.id.cc_bcc_container); 475 mSubjectView = (EditText) UiUtilities.getView(this, R.id.subject); 476 mMessageContentView = (EditText) UiUtilities.getView(this, R.id.message_content); 477 mAttachments = (LinearLayout) UiUtilities.getView(this, R.id.attachments); 478 mAttachmentContainer = UiUtilities.getView(this, R.id.attachment_container); 479 mQuotedTextBar = UiUtilities.getView(this, R.id.quoted_text_bar); 480 mIncludeQuotedTextCheckBox = (CheckBox) UiUtilities.getView(this, R.id.include_quoted_text); 481 mQuotedText = (WebView) UiUtilities.getView(this, R.id.quoted_text); 482 483 TextWatcher watcher = new TextWatcher() { 484 public void beforeTextChanged(CharSequence s, int start, 485 int before, int after) { } 486 487 public void onTextChanged(CharSequence s, int start, 488 int before, int count) { 489 setDraftNeedsSaving(true); 490 } 491 492 public void afterTextChanged(android.text.Editable s) { } 493 }; 494 495 /** 496 * Implements special address cleanup rules: 497 * The first space key entry following an "@" symbol that is followed by any combination 498 * of letters and symbols, including one+ dots and zero commas, should insert an extra 499 * comma (followed by the space). 500 */ 501 InputFilter recipientFilter = new InputFilter() { 502 503 public CharSequence filter(CharSequence source, int start, int end, Spanned dest, 504 int dstart, int dend) { 505 506 // quick check - did they enter a single space? 507 if (end-start != 1 || source.charAt(start) != ' ') { 508 return null; 509 } 510 511 // determine if the characters before the new space fit the pattern 512 // follow backwards and see if we find a comma, dot, or @ 513 int scanBack = dstart; 514 boolean dotFound = false; 515 while (scanBack > 0) { 516 char c = dest.charAt(--scanBack); 517 switch (c) { 518 case '.': 519 dotFound = true; // one or more dots are req'd 520 break; 521 case ',': 522 return null; 523 case '@': 524 if (!dotFound) { 525 return null; 526 } 527 528 // we have found a comma-insert case. now just do it 529 // in the least expensive way we can. 530 if (source instanceof Spanned) { 531 SpannableStringBuilder sb = new SpannableStringBuilder(","); 532 sb.append(source); 533 return sb; 534 } else { 535 return ", "; 536 } 537 default: 538 // just keep going 539 } 540 } 541 542 // no termination cases were found, so don't edit the input 543 return null; 544 } 545 }; 546 InputFilter[] recipientFilters = new InputFilter[] { recipientFilter }; 547 548 mToView.addTextChangedListener(watcher); 549 mCcView.addTextChangedListener(watcher); 550 mBccView.addTextChangedListener(watcher); 551 mSubjectView.addTextChangedListener(watcher); 552 mMessageContentView.addTextChangedListener(watcher); 553 554 // NOTE: assumes no other filters are set 555 mToView.setFilters(recipientFilters); 556 mCcView.setFilters(recipientFilters); 557 mBccView.setFilters(recipientFilters); 558 559 /* 560 * We set this to invisible by default. Other methods will turn it back on if it's 561 * needed. 562 */ 563 mQuotedTextBar.setVisibility(View.GONE); 564 setIncludeQuotedText(false, false); 565 566 mIncludeQuotedTextCheckBox.setOnClickListener(this); 567 568 EmailAddressValidator addressValidator = new EmailAddressValidator(); 569 570 setupAddressAdapters(); 571 mToView.setAdapter(mAddressAdapterTo); 572 mToView.setTokenizer(new Rfc822Tokenizer()); 573 mToView.setValidator(addressValidator); 574 575 mCcView.setAdapter(mAddressAdapterCc); 576 mCcView.setTokenizer(new Rfc822Tokenizer()); 577 mCcView.setValidator(addressValidator); 578 579 mBccView.setAdapter(mAddressAdapterBcc); 580 mBccView.setTokenizer(new Rfc822Tokenizer()); 581 mBccView.setValidator(addressValidator); 582 583 final View addCcBccView = UiUtilities.getView(this, R.id.add_cc_bcc); 584 addCcBccView.setOnClickListener(this); 585 586 final View addAttachmentView = UiUtilities.getView(this, R.id.add_attachment); 587 addAttachmentView.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 mMessageContentView.setOnFocusChangeListener(this); 596 597 updateAttachmentContainer(); 598 mToView.requestFocus(); 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 EmailAsyncTask<Void, Void, Object[]> { 611 private final long mMessageId; 612 613 public LoadMessageTask(long messageId) { 614 super(mTaskTracker); 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 new EmailAsyncTask<Long, Void, Attachment[]>(mTaskTracker) { 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 }.executeParallel(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 @Override 733 public void onFocusChange(View view, boolean focused) { 734 if (focused) { 735 switch (view.getId()) { 736 case R.id.message_content: 737 // When focusing on the message content via tabbing to it, or other means of 738 // auto focusing, move the cursor to the end of the body (before the signature). 739 if (mMessageContentView.getSelectionStart() == 0 740 && mMessageContentView.getSelectionEnd() == 0) { 741 // There is no way to determine if the focus change was programmatic or due 742 // to keyboard event, or if it was due to a tap/restore. Use a best-guess 743 // by using the fact that auto-focus/keyboard tabs set the selection to 0. 744 setMessageContentSelection(getAccountSignature(mAccount)); 745 } 746 } 747 } 748 } 749 750 private void addAddresses(MultiAutoCompleteTextView view, Address[] addresses) { 751 if (addresses == null) { 752 return; 753 } 754 for (Address address : addresses) { 755 addAddress(view, address.toString()); 756 } 757 } 758 759 private void addAddresses(MultiAutoCompleteTextView view, String[] addresses) { 760 if (addresses == null) { 761 return; 762 } 763 for (String oneAddress : addresses) { 764 addAddress(view, oneAddress); 765 } 766 } 767 768 private void addAddress(MultiAutoCompleteTextView view, String address) { 769 view.append(address + ", "); 770 } 771 772 private String getPackedAddresses(TextView view) { 773 Address[] addresses = Address.parse(view.getText().toString().trim()); 774 return Address.pack(addresses); 775 } 776 777 private Address[] getAddresses(TextView view) { 778 Address[] addresses = Address.parse(view.getText().toString().trim()); 779 return addresses; 780 } 781 782 /* 783 * Computes a short string indicating the destination of the message based on To, Cc, Bcc. 784 * If only one address appears, returns the friendly form of that address. 785 * Otherwise returns the friendly form of the first address appended with "and N others". 786 */ 787 private String makeDisplayName(String packedTo, String packedCc, String packedBcc) { 788 Address first = null; 789 int nRecipients = 0; 790 for (String packed: new String[] {packedTo, packedCc, packedBcc}) { 791 Address[] addresses = Address.unpack(packed); 792 nRecipients += addresses.length; 793 if (first == null && addresses.length > 0) { 794 first = addresses[0]; 795 } 796 } 797 if (nRecipients == 0) { 798 return ""; 799 } 800 String friendly = first.toFriendly(); 801 if (nRecipients == 1) { 802 return friendly; 803 } 804 return this.getString(R.string.message_compose_display_name, friendly, nRecipients - 1); 805 } 806 807 private ContentValues getUpdateContentValues(Message message) { 808 ContentValues values = new ContentValues(); 809 values.put(MessageColumns.TIMESTAMP, message.mTimeStamp); 810 values.put(MessageColumns.FROM_LIST, message.mFrom); 811 values.put(MessageColumns.TO_LIST, message.mTo); 812 values.put(MessageColumns.CC_LIST, message.mCc); 813 values.put(MessageColumns.BCC_LIST, message.mBcc); 814 values.put(MessageColumns.SUBJECT, message.mSubject); 815 values.put(MessageColumns.DISPLAY_NAME, message.mDisplayName); 816 values.put(MessageColumns.FLAG_READ, message.mFlagRead); 817 values.put(MessageColumns.FLAG_LOADED, message.mFlagLoaded); 818 values.put(MessageColumns.FLAG_ATTACHMENT, message.mFlagAttachment); 819 values.put(MessageColumns.FLAGS, message.mFlags); 820 return values; 821 } 822 823 /** 824 * Updates the given message using values from the compose UI. 825 * 826 * @param message The message to be updated. 827 * @param account the account (used to obtain From: address). 828 * @param hasAttachments true if it has one or more attachment. 829 * @param sending set true if the message is about to sent, in which case we perform final 830 * clean up; 831 */ 832 private void updateMessage(Message message, Account account, boolean hasAttachments, 833 boolean sending) { 834 if (message.mMessageId == null || message.mMessageId.length() == 0) { 835 message.mMessageId = Utility.generateMessageId(); 836 } 837 message.mTimeStamp = System.currentTimeMillis(); 838 message.mFrom = new Address(account.getEmailAddress(), account.getSenderName()).pack(); 839 message.mTo = getPackedAddresses(mToView); 840 message.mCc = getPackedAddresses(mCcView); 841 message.mBcc = getPackedAddresses(mBccView); 842 message.mSubject = mSubjectView.getText().toString(); 843 message.mText = mMessageContentView.getText().toString(); 844 message.mAccountKey = account.mId; 845 message.mDisplayName = makeDisplayName(message.mTo, message.mCc, message.mBcc); 846 message.mFlagRead = true; 847 message.mFlagLoaded = Message.FLAG_LOADED_COMPLETE; 848 message.mFlagAttachment = hasAttachments; 849 // Use the Intent to set flags saying this message is a reply or a forward and save the 850 // unique id of the source message 851 if (mSource != null && mQuotedTextBar.getVisibility() == View.VISIBLE) { 852 // If the quote bar is visible; this must either be a reply or forward 853 message.mSourceKey = mSource.mId; 854 // Get the body of the source message here 855 message.mHtmlReply = mSource.mHtml; 856 message.mTextReply = mSource.mText; 857 String fromAsString = Address.unpackToString(mSource.mFrom); 858 if (ACTION_FORWARD.equals(mAction)) { 859 message.mFlags |= Message.FLAG_TYPE_FORWARD; 860 String subject = mSource.mSubject; 861 String to = Address.unpackToString(mSource.mTo); 862 String cc = Address.unpackToString(mSource.mCc); 863 message.mIntroText = 864 getString(R.string.message_compose_fwd_header_fmt, subject, fromAsString, 865 to != null ? to : "", cc != null ? cc : ""); 866 } else { 867 message.mFlags |= Message.FLAG_TYPE_REPLY; 868 message.mIntroText = 869 getString(R.string.message_compose_reply_header_fmt, fromAsString); 870 } 871 } 872 873 if (includeQuotedText()) { 874 message.mFlags &= ~Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 875 } else { 876 message.mFlags |= Message.FLAG_NOT_INCLUDE_QUOTED_TEXT; 877 if (sending) { 878 // If we are about to send a message, and not including the original message, 879 // clear the related field. 880 // We can't do this until the last minutes, so that the user can change their 881 // mind later and want to include it again. 882 mDraft.mIntroText = null; 883 mDraft.mTextReply = null; 884 mDraft.mHtmlReply = null; 885 mDraft.mSourceKey = 0; 886 mDraft.mFlags &= ~Message.FLAG_TYPE_MASK; 887 } 888 } 889 } 890 891 private Attachment[] getAttachmentsFromUI() { 892 int count = mAttachments.getChildCount(); 893 Attachment[] attachments = new Attachment[count]; 894 for (int i = 0; i < count; ++i) { 895 attachments[i] = (Attachment) mAttachments.getChildAt(i).getTag(); 896 } 897 return attachments; 898 } 899 900 /* This method does DB operations in UI thread because 901 the draftId is needed by onSaveInstanceState() which can't wait for it 902 to be saved in the background. 903 TODO: This will cause ANRs, so we need to find a better solution. 904 */ 905 private long getOrCreateDraftId() { 906 synchronized (mDraft) { 907 if (mDraft.mId > 0) { 908 return mDraft.mId; 909 } 910 // don't save draft if the source message did not load yet 911 if (!mMessageLoaded) { 912 return -1; 913 } 914 final Attachment[] attachments = getAttachmentsFromUI(); 915 updateMessage(mDraft, mAccount, attachments.length > 0, false); 916 mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); 917 return mDraft.mId; 918 } 919 } 920 921 private class SendOrSaveMessageTask extends EmailAsyncTask<Void, Void, Void> { 922 private final boolean mSend; 923 924 public SendOrSaveMessageTask(boolean send) { 925 super(null /* DO NOT cancel in onDestroy */); 926 if (send && ActivityManager.isUserAMonkey()) { 927 Log.d(Logging.LOG_TAG, "Inhibiting send while monkey is in charge."); 928 send = false; 929 } 930 mSend = send; 931 } 932 933 @Override 934 protected Void doInBackground(Void... params) { 935 synchronized (mDraft) { 936 final Attachment[] attachments = getAttachmentsFromUI(); 937 updateMessage(mDraft, mAccount, attachments.length > 0, mSend); 938 ContentResolver resolver = getContentResolver(); 939 if (mDraft.isSaved()) { 940 // Update the message 941 Uri draftUri = 942 ContentUris.withAppendedId(Message.SYNCED_CONTENT_URI, mDraft.mId); 943 resolver.update(draftUri, getUpdateContentValues(mDraft), null, null); 944 // Update the body 945 ContentValues values = new ContentValues(); 946 values.put(BodyColumns.TEXT_CONTENT, mDraft.mText); 947 values.put(BodyColumns.TEXT_REPLY, mDraft.mTextReply); 948 values.put(BodyColumns.HTML_REPLY, mDraft.mHtmlReply); 949 values.put(BodyColumns.INTRO_TEXT, mDraft.mIntroText); 950 values.put(BodyColumns.SOURCE_MESSAGE_KEY, mDraft.mSourceKey); 951 Body.updateBodyWithMessageId(MessageCompose.this, mDraft.mId, values); 952 } else { 953 // mDraft.mId is set upon return of saveToMailbox() 954 mController.saveToMailbox(mDraft, EmailContent.Mailbox.TYPE_DRAFTS); 955 } 956 // For any unloaded attachment, set the flag saying we need it loaded 957 boolean hasUnloadedAttachments = false; 958 for (Attachment attachment : attachments) { 959 if (attachment.mContentUri == null && 960 ((attachment.mFlags & Attachment.FLAG_SMART_FORWARD) == 0)) { 961 attachment.mFlags |= Attachment.FLAG_DOWNLOAD_FORWARD; 962 hasUnloadedAttachments = true; 963 if (Email.DEBUG){ 964 Log.d("MessageCompose", 965 "Requesting download of attachment #" + attachment.mId); 966 } 967 } 968 // Make sure the UI version of the attachment has the now-correct id; we will 969 // use the id again when coming back from picking new attachments 970 if (!attachment.isSaved()) { 971 // this attachment is new so save it to DB. 972 attachment.mMessageKey = mDraft.mId; 973 attachment.save(MessageCompose.this); 974 } else if (attachment.mMessageKey != mDraft.mId) { 975 // We clone the attachment and save it again; otherwise, it will 976 // continue to point to the source message. From this point forward, 977 // the attachments will be independent of the original message in the 978 // database; however, we still need the message on the server in order 979 // to retrieve unloaded attachments 980 attachment.mMessageKey = mDraft.mId; 981 ContentValues cv = attachment.toContentValues(); 982 cv.put(Attachment.FLAGS, attachment.mFlags); 983 cv.put(Attachment.MESSAGE_KEY, mDraft.mId); 984 getContentResolver().insert(Attachment.CONTENT_URI, cv); 985 } 986 } 987 988 if (mSend) { 989 // Let the user know if message sending might be delayed by background 990 // downlading of unloaded attachments 991 if (hasUnloadedAttachments) { 992 Utility.showToast(MessageCompose.this, 993 R.string.message_view_attachment_background_load); 994 } 995 mController.sendMessage(mDraft.mId, mDraft.mAccountKey); 996 } 997 return null; 998 } 999 } 1000 1001 @Override 1002 protected void onPostExecute(Void param) { 1003 synchronized (sSaveInProgressCondition) { 1004 sSaveInProgress = false; 1005 sSaveInProgressCondition.notify(); 1006 } 1007 // Don't display the toast if the user is just changing the orientation 1008 if (!mSend && (getChangingConfigurations() & ActivityInfo.CONFIG_ORIENTATION) == 0) { 1009 Toast.makeText(MessageCompose.this, R.string.message_saved_toast, 1010 Toast.LENGTH_LONG).show(); 1011 } 1012 } 1013 } 1014 1015 /** 1016 * Send or save a message: 1017 * - out of the UI thread 1018 * - write to Drafts 1019 * - if send, invoke Controller.sendMessage() 1020 * - when operation is complete, display toast 1021 */ 1022 private void sendOrSaveMessage(boolean send) { 1023 if (!mMessageLoaded) { 1024 // early save, before the message was loaded: do nothing 1025 return; 1026 } 1027 synchronized (sSaveInProgressCondition) { 1028 sSaveInProgress = true; 1029 } 1030 new SendOrSaveMessageTask(send).executeParallel(); 1031 } 1032 1033 private void saveIfNeeded() { 1034 if (!mDraftNeedsSaving) { 1035 return; 1036 } 1037 setDraftNeedsSaving(false); 1038 sendOrSaveMessage(false); 1039 } 1040 1041 /** 1042 * Checks whether all the email addresses listed in TO, CC, BCC are valid. 1043 */ 1044 @VisibleForTesting 1045 boolean isAddressAllValid() { 1046 for (TextView view : new TextView[]{mToView, mCcView, mBccView}) { 1047 String addresses = view.getText().toString().trim(); 1048 if (!Address.isAllValid(addresses)) { 1049 view.setError(getString(R.string.message_compose_error_invalid_email)); 1050 return false; 1051 } 1052 } 1053 return true; 1054 } 1055 1056 private void onSend() { 1057 if (!isAddressAllValid()) { 1058 Toast.makeText(this, getString(R.string.message_compose_error_invalid_email), 1059 Toast.LENGTH_LONG).show(); 1060 } else if (getAddresses(mToView).length == 0 && 1061 getAddresses(mCcView).length == 0 && 1062 getAddresses(mBccView).length == 0) { 1063 mToView.setError(getString(R.string.message_compose_error_no_recipients)); 1064 Toast.makeText(this, getString(R.string.message_compose_error_no_recipients), 1065 Toast.LENGTH_LONG).show(); 1066 } else { 1067 sendOrSaveMessage(true); 1068 setDraftNeedsSaving(false); 1069 finish(); 1070 } 1071 } 1072 1073 private void onDiscard() { 1074 DeleteMessageConfirmationDialog.newInstance(1, null).show(getFragmentManager(), "dialog"); 1075 } 1076 1077 /** 1078 * Called when ok on the "discard draft" dialog is pressed. Actually delete the draft. 1079 */ 1080 @Override 1081 public void onDeleteMessageConfirmationDialogOkPressed() { 1082 if (mDraft.mId > 0) { 1083 // By the way, we can't pass the message ID from onDiscard() to here (using a 1084 // dialog argument or whatever), because you can rotate the screen when the dialog is 1085 // shown, and during rotation we save & restore the draft. If it's the 1086 // first save, we give it an ID at this point for the first time (and last time). 1087 // Which means it's possible for a draft to not have an ID in onDiscard(), 1088 // but here. 1089 mController.deleteMessage(mDraft.mId, mDraft.mAccountKey); 1090 } 1091 Utility.showToast(MessageCompose.this, R.string.message_discarded_toast); 1092 setDraftNeedsSaving(false); 1093 finish(); 1094 } 1095 1096 private void onSave() { 1097 saveIfNeeded(); 1098 finish(); 1099 } 1100 1101 private void showCcBccFieldsIfFilled() { 1102 if ((mCcView.length() > 0) || (mBccView.length() > 0)) { 1103 showCcBccFields(); 1104 } 1105 } 1106 1107 private void showCcBccFields() { 1108 mCcBccContainer.setVisibility(View.VISIBLE); 1109 UiUtilities.setVisibilitySafe(this, R.id.add_cc_bcc, View.INVISIBLE); 1110 } 1111 1112 /** 1113 * Kick off a picker for whatever kind of MIME types we'll accept and let Android take over. 1114 */ 1115 private void onAddAttachment() { 1116 Intent i = new Intent(Intent.ACTION_GET_CONTENT); 1117 i.addCategory(Intent.CATEGORY_OPENABLE); 1118 i.setType(AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_UI_TYPES[0]); 1119 startActivityForResult( 1120 Intent.createChooser(i, getString(R.string.choose_attachment_dialog_title)), 1121 ACTIVITY_REQUEST_PICK_ATTACHMENT); 1122 } 1123 1124 private Attachment loadAttachmentInfo(Uri uri) { 1125 long size = -1; 1126 ContentResolver contentResolver = getContentResolver(); 1127 1128 // Load name & size independently, because not all providers support both 1129 final String name = Utility.getContentFileName(this, uri); 1130 1131 Cursor metadataCursor = contentResolver.query(uri, ATTACHMENT_META_SIZE_PROJECTION, 1132 null, null, null); 1133 if (metadataCursor != null) { 1134 try { 1135 if (metadataCursor.moveToFirst()) { 1136 size = metadataCursor.getLong(ATTACHMENT_META_SIZE_COLUMN_SIZE); 1137 } 1138 } finally { 1139 metadataCursor.close(); 1140 } 1141 } 1142 1143 // When the size is not provided, we need to determine it locally. 1144 if (size < 0) { 1145 // if the URI is a file: URI, ask file system for its size 1146 if ("file".equalsIgnoreCase(uri.getScheme())) { 1147 String path = uri.getPath(); 1148 if (path != null) { 1149 File file = new File(path); 1150 size = file.length(); // Returns 0 for file not found 1151 } 1152 } 1153 1154 if (size <= 0) { 1155 // The size was not measurable; This attachment is not safe to use. 1156 // Quick hack to force a relevant error into the UI 1157 // TODO: A proper announcement of the problem 1158 size = AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE + 1; 1159 } 1160 } 1161 1162 Attachment attachment = new Attachment(); 1163 attachment.mFileName = name; 1164 attachment.mContentUri = uri.toString(); 1165 attachment.mSize = size; 1166 attachment.mMimeType = AttachmentUtilities.inferMimeTypeForUri(this, uri); 1167 return attachment; 1168 } 1169 1170 private void addAttachment(Attachment attachment, boolean allowDelete) { 1171 // Before attaching the attachment, make sure it meets any other pre-attach criteria 1172 if (attachment.mSize > AttachmentUtilities.MAX_ATTACHMENT_UPLOAD_SIZE) { 1173 Toast.makeText(this, R.string.message_compose_attachment_size, Toast.LENGTH_LONG) 1174 .show(); 1175 return; 1176 } 1177 1178 View view = getLayoutInflater().inflate(R.layout.message_compose_attachment, 1179 mAttachments, false); 1180 TextView nameView = (TextView) UiUtilities.getView(view, R.id.attachment_name); 1181 ImageButton delete = (ImageButton) UiUtilities.getView(view, R.id.attachment_delete); 1182 TextView sizeView = (TextView) UiUtilities.getView(view, R.id.attachment_size); 1183 1184 nameView.setText(attachment.mFileName); 1185 sizeView.setText(UiUtilities.formatSize(this, attachment.mSize)); 1186 if (allowDelete) { 1187 delete.setOnClickListener(this); 1188 delete.setTag(view); 1189 } else { 1190 delete.setVisibility(View.INVISIBLE); 1191 } 1192 view.setTag(attachment); 1193 mAttachments.addView(view); 1194 updateAttachmentContainer(); 1195 } 1196 1197 private void updateAttachmentContainer() { 1198 mAttachmentContainer.setVisibility(mAttachments.getChildCount() == 0 1199 ? View.GONE : View.VISIBLE); 1200 } 1201 1202 private void addAttachmentFromUri(Uri uri) { 1203 addAttachment(loadAttachmentInfo(uri), true); 1204 } 1205 1206 /** 1207 * Same as {@link #addAttachmentFromUri}, but does the mime-type check against 1208 * {@link AttachmentUtilities#ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES}. 1209 */ 1210 private void addAttachmentFromSendIntent(Uri uri) { 1211 final Attachment attachment = loadAttachmentInfo(uri); 1212 final String mimeType = attachment.mMimeType; 1213 if (!TextUtils.isEmpty(mimeType) && MimeUtility.mimeTypeMatches(mimeType, 1214 AttachmentUtilities.ACCEPTABLE_ATTACHMENT_SEND_INTENT_TYPES)) { 1215 addAttachment(attachment, true); 1216 } 1217 } 1218 1219 @Override 1220 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 1221 if (data == null) { 1222 return; 1223 } 1224 addAttachmentFromUri(data.getData()); 1225 setDraftNeedsSaving(true); 1226 } 1227 1228 private boolean includeQuotedText() { 1229 return mIncludeQuotedTextCheckBox.isChecked(); 1230 } 1231 1232 public void onClick(View view) { 1233 if (handleCommand(view.getId())) { 1234 return; 1235 } 1236 switch (view.getId()) { 1237 case R.id.attachment_delete: 1238 onDeleteAttachment(view); // Needs a view; can't be a menu item 1239 break; 1240 } 1241 } 1242 1243 private void setIncludeQuotedText(boolean include, boolean updateNeedsSaving) { 1244 mIncludeQuotedTextCheckBox.setChecked(include); 1245 mQuotedText.setVisibility(mIncludeQuotedTextCheckBox.isChecked() 1246 ? View.VISIBLE : View.GONE); 1247 if (updateNeedsSaving) { 1248 setDraftNeedsSaving(true); 1249 } 1250 } 1251 1252 private void onDeleteAttachment(View delButtonView) { 1253 /* 1254 * The view is the delete button, and we have previously set the tag of 1255 * the delete button to the view that owns it. We don't use parent because the 1256 * view is very complex and could change in the future. 1257 */ 1258 View attachmentView = (View) delButtonView.getTag(); 1259 Attachment attachment = (Attachment) attachmentView.getTag(); 1260 mAttachments.removeView(attachmentView); 1261 updateAttachmentContainer(); 1262 if (attachment.mMessageKey == mDraft.mId && attachment.isSaved()) { 1263 final long attachmentId = attachment.mId; 1264 EmailAsyncTask.runAsyncParallel(new Runnable() { 1265 @Override 1266 public void run() { 1267 mController.deleteAttachment(attachmentId); 1268 } 1269 }); 1270 } 1271 setDraftNeedsSaving(true); 1272 } 1273 1274 @Override 1275 public boolean onOptionsItemSelected(MenuItem item) { 1276 if (handleCommand(item.getItemId())) { 1277 return true; 1278 } 1279 return super.onOptionsItemSelected(item); 1280 } 1281 1282 private boolean handleCommand(int viewId) { 1283 switch (viewId) { 1284 case android.R.id.home: 1285 onActionBarHomePressed(); 1286 return true; 1287 case R.id.send: 1288 onSend(); 1289 return true; 1290 case R.id.save: 1291 onSave(); 1292 return true; 1293 case R.id.discard: 1294 onDiscard(); 1295 return true; 1296 case R.id.include_quoted_text: 1297 // The checkbox is already toggled at this point. 1298 setIncludeQuotedText(mIncludeQuotedTextCheckBox.isChecked(), true); 1299 return true; 1300 case R.id.add_cc_bcc: 1301 showCcBccFields(); 1302 return true; 1303 case R.id.add_attachment: 1304 onAddAttachment(); 1305 return true; 1306 } 1307 return false; 1308 } 1309 1310 private void onActionBarHomePressed() { 1311 finish(); 1312 if (isOpenedFromWithinApp()) { 1313 // If opend from within the app, we just close it. 1314 } else { 1315 // Otherwise, need to open the main screen. Let Welcome do that. 1316 Welcome.actionStart(this); 1317 } 1318 } 1319 1320 @Override 1321 public boolean onCreateOptionsMenu(Menu menu) { 1322 super.onCreateOptionsMenu(menu); 1323 getMenuInflater().inflate(R.menu.message_compose_option, menu); 1324 return true; 1325 } 1326 1327 @Override 1328 public boolean onPrepareOptionsMenu(Menu menu) { 1329 menu.findItem(R.id.save).setEnabled(mSaveEnabled); 1330 return true; 1331 } 1332 1333 /** 1334 * Set a message body and a signature when the Activity is launched. 1335 * 1336 * @param text the message body 1337 */ 1338 @VisibleForTesting 1339 void setInitialComposeText(CharSequence text, String signature) { 1340 mMessageContentView.setText(""); 1341 int textLength = 0; 1342 if (text != null) { 1343 mMessageContentView.append(text); 1344 textLength = text.length(); 1345 } 1346 if (!TextUtils.isEmpty(signature)) { 1347 if (textLength == 0 || text.charAt(textLength - 1) != '\n') { 1348 mMessageContentView.append("\n"); 1349 } 1350 mMessageContentView.append(signature); 1351 1352 // Reset cursor to right before the signature. 1353 mMessageContentView.setSelection(textLength); 1354 } 1355 } 1356 1357 /** 1358 * Fill all the widgets with the content found in the Intent Extra, if any. 1359 * 1360 * Note that we don't actually check the intent action (typically VIEW, SENDTO, or SEND). 1361 * There is enough overlap in the definitions that it makes more sense to simply check for 1362 * all available data and use as much of it as possible. 1363 * 1364 * With one exception: EXTRA_STREAM is defined as only valid for ACTION_SEND. 1365 * 1366 * @param intent the launch intent 1367 */ 1368 @VisibleForTesting 1369 void initFromIntent(Intent intent) { 1370 1371 setAccount(intent); 1372 1373 // First, add values stored in top-level extras 1374 String[] extraStrings = intent.getStringArrayExtra(Intent.EXTRA_EMAIL); 1375 if (extraStrings != null) { 1376 addAddresses(mToView, extraStrings); 1377 } 1378 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_CC); 1379 if (extraStrings != null) { 1380 addAddresses(mCcView, extraStrings); 1381 } 1382 extraStrings = intent.getStringArrayExtra(Intent.EXTRA_BCC); 1383 if (extraStrings != null) { 1384 addAddresses(mBccView, extraStrings); 1385 } 1386 String extraString = intent.getStringExtra(Intent.EXTRA_SUBJECT); 1387 if (extraString != null) { 1388 mSubjectView.setText(extraString); 1389 } 1390 1391 // Next, if we were invoked with a URI, try to interpret it 1392 // We'll take two courses here. If it's mailto:, there is a specific set of rules 1393 // that define various optional fields. However, for any other scheme, we'll simply 1394 // take the entire scheme-specific part and interpret it as a possible list of addresses. 1395 final Uri dataUri = intent.getData(); 1396 if (dataUri != null) { 1397 if ("mailto".equals(dataUri.getScheme())) { 1398 initializeFromMailTo(dataUri.toString()); 1399 } else { 1400 String toText = dataUri.getSchemeSpecificPart(); 1401 if (toText != null) { 1402 addAddresses(mToView, toText.split(",")); 1403 } 1404 } 1405 } 1406 1407 // Next, fill in the plaintext (note, this will override mailto:?body=) 1408 CharSequence text = intent.getCharSequenceExtra(Intent.EXTRA_TEXT); 1409 setInitialComposeText(text, getAccountSignature(mAccount)); 1410 1411 // Next, convert EXTRA_STREAM into an attachment 1412 if (Intent.ACTION_SEND.equals(mAction) && intent.hasExtra(Intent.EXTRA_STREAM)) { 1413 Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); 1414 if (uri != null) { 1415 addAttachmentFromSendIntent(uri); 1416 } 1417 } 1418 1419 if (Intent.ACTION_SEND_MULTIPLE.equals(mAction) 1420 && intent.hasExtra(Intent.EXTRA_STREAM)) { 1421 ArrayList<Parcelable> list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); 1422 if (list != null) { 1423 for (Parcelable parcelable : list) { 1424 Uri uri = (Uri) parcelable; 1425 if (uri != null) { 1426 addAttachmentFromSendIntent(uri); 1427 } 1428 } 1429 } 1430 } 1431 1432 // Finally - expose fields that were filled in but are normally hidden, and set focus 1433 showCcBccFieldsIfFilled(); 1434 setNewMessageFocus(); 1435 setDraftNeedsSaving(false); 1436 } 1437 1438 /** 1439 * When we are launched with an intent that includes a mailto: URI, we can actually 1440 * gather quite a few of our message fields from it. 1441 * 1442 * @mailToString the href (which must start with "mailto:"). 1443 */ 1444 private void initializeFromMailTo(String mailToString) { 1445 1446 // Chop up everything between mailto: and ? to find recipients 1447 int index = mailToString.indexOf("?"); 1448 int length = "mailto".length() + 1; 1449 String to; 1450 try { 1451 // Extract the recipient after mailto: 1452 if (index == -1) { 1453 to = decode(mailToString.substring(length)); 1454 } else { 1455 to = decode(mailToString.substring(length, index)); 1456 } 1457 addAddresses(mToView, to.split(" ,")); 1458 } catch (UnsupportedEncodingException e) { 1459 Log.e(Logging.LOG_TAG, e.getMessage() + " while decoding '" + mailToString + "'"); 1460 } 1461 1462 // Extract the other parameters 1463 1464 // We need to disguise this string as a URI in order to parse it 1465 Uri uri = Uri.parse("foo://" + mailToString); 1466 1467 List<String> cc = uri.getQueryParameters("cc"); 1468 addAddresses(mCcView, cc.toArray(new String[cc.size()])); 1469 1470 List<String> otherTo = uri.getQueryParameters("to"); 1471 addAddresses(mCcView, otherTo.toArray(new String[otherTo.size()])); 1472 1473 List<String> bcc = uri.getQueryParameters("bcc"); 1474 addAddresses(mBccView, bcc.toArray(new String[bcc.size()])); 1475 1476 List<String> subject = uri.getQueryParameters("subject"); 1477 if (subject.size() > 0) { 1478 mSubjectView.setText(subject.get(0)); 1479 } 1480 1481 List<String> body = uri.getQueryParameters("body"); 1482 if (body.size() > 0) { 1483 setInitialComposeText(body.get(0), getAccountSignature(mAccount)); 1484 } 1485 } 1486 1487 private String decode(String s) throws UnsupportedEncodingException { 1488 return URLDecoder.decode(s, "UTF-8"); 1489 } 1490 1491 /** 1492 * Displays quoted text from the original email 1493 */ 1494 private void displayQuotedText(String textBody, String htmlBody) { 1495 // Only use plain text if there is no HTML body 1496 boolean plainTextFlag = TextUtils.isEmpty(htmlBody); 1497 String text = plainTextFlag ? textBody : htmlBody; 1498 if (text != null) { 1499 text = plainTextFlag ? EmailHtmlUtil.escapeCharacterToDisplay(text) : text; 1500 // TODO: re-enable EmailHtmlUtil.resolveInlineImage() for HTML 1501 // EmailHtmlUtil.resolveInlineImage(getContentResolver(), mAccount, 1502 // text, message, 0); 1503 mQuotedTextBar.setVisibility(View.VISIBLE); 1504 if (mQuotedText != null) { 1505 mQuotedText.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); 1506 } 1507 } 1508 } 1509 1510 /** 1511 * Given a packed address String, the address of our sending account, a view, and a list of 1512 * addressees already added to other addressing views, adds unique addressees that don't 1513 * match our address to the passed in view 1514 */ 1515 private boolean safeAddAddresses(String addrs, String ourAddress, 1516 MultiAutoCompleteTextView view, ArrayList<Address> addrList) { 1517 boolean added = false; 1518 for (Address address : Address.unpack(addrs)) { 1519 // Don't send to ourselves or already-included addresses 1520 if (!address.getAddress().equalsIgnoreCase(ourAddress) && !addrList.contains(address)) { 1521 addrList.add(address); 1522 addAddress(view, address.toString()); 1523 added = true; 1524 } 1525 } 1526 return added; 1527 } 1528 1529 /** 1530 * Set up the to and cc views properly for the "reply" and "replyAll" cases. What's important 1531 * is that we not 1) send to ourselves, and 2) duplicate addressees. 1532 * @param message the message we're replying to 1533 * @param account the account we're sending from 1534 * @param toView the "To" view 1535 * @param ccView the "Cc" view 1536 * @param replyAll whether this is a replyAll (vs a reply) 1537 */ 1538 @VisibleForTesting 1539 void setupAddressViews(Message message, Account account, 1540 MultiAutoCompleteTextView toView, MultiAutoCompleteTextView ccView, boolean replyAll) { 1541 /* 1542 * If a reply-to was included with the message use that, otherwise use the from 1543 * or sender address. 1544 */ 1545 Address[] replyToAddresses = Address.unpack(message.mReplyTo); 1546 if (replyToAddresses.length == 0) { 1547 replyToAddresses = Address.unpack(message.mFrom); 1548 } 1549 addAddresses(mToView, replyToAddresses); 1550 1551 if (replyAll) { 1552 // Keep a running list of addresses we're sending to 1553 ArrayList<Address> allAddresses = new ArrayList<Address>(); 1554 String ourAddress = account.mEmailAddress; 1555 1556 for (Address address: replyToAddresses) { 1557 allAddresses.add(address); 1558 } 1559 1560 safeAddAddresses(message.mTo, ourAddress, mToView, allAddresses); 1561 safeAddAddresses(message.mCc, ourAddress, mCcView, allAddresses); 1562 } 1563 showCcBccFieldsIfFilled(); 1564 } 1565 1566 void processSourceMessageGuarded(Message message, Account account) { 1567 // Make sure we only do this once (otherwise we'll duplicate addresses!) 1568 if (!mSourceMessageProcessed) { 1569 processSourceMessage(message, account); 1570 mSourceMessageProcessed = true; 1571 } 1572 1573 /* The quoted text is displayed in a WebView whose content is not automatically 1574 * saved/restored by onRestoreInstanceState(), so we need to *always* restore it here, 1575 * regardless of the value of mSourceMessageProcessed. 1576 * This only concerns EDIT_DRAFT because after a configuration change we're always 1577 * in EDIT_DRAFT. 1578 */ 1579 if (ACTION_EDIT_DRAFT.equals(mAction)) { 1580 displayQuotedText(message.mTextReply, message.mHtmlReply); 1581 setIncludeQuotedText((mDraft.mFlags & Message.FLAG_NOT_INCLUDE_QUOTED_TEXT) == 0, 1582 false); 1583 } 1584 } 1585 1586 /** 1587 * Pull out the parts of the now loaded source message and apply them to the new message 1588 * depending on the type of message being composed. 1589 * @param message 1590 */ 1591 /* package */ 1592 void processSourceMessage(Message message, Account account) { 1593 setDraftNeedsSaving(true); 1594 final String subject = message.mSubject; 1595 if (ACTION_REPLY.equals(mAction) || ACTION_REPLY_ALL.equals(mAction)) { 1596 setupAddressViews(message, account, mToView, mCcView, 1597 ACTION_REPLY_ALL.equals(mAction)); 1598 if (subject != null && !subject.toLowerCase().startsWith("re:")) { 1599 mSubjectView.setText("Re: " + subject); 1600 } else { 1601 mSubjectView.setText(subject); 1602 } 1603 displayQuotedText(message.mText, message.mHtml); 1604 setIncludeQuotedText(true, false); 1605 setInitialComposeText(null, getAccountSignature(account)); 1606 } else if (ACTION_FORWARD.equals(mAction)) { 1607 mSubjectView.setText(subject != null && !subject.toLowerCase().startsWith("fwd:") ? 1608 "Fwd: " + subject : subject); 1609 displayQuotedText(message.mText, message.mHtml); 1610 setIncludeQuotedText(true, false); 1611 setInitialComposeText(null, getAccountSignature(account)); 1612 } else if (ACTION_EDIT_DRAFT.equals(mAction)) { 1613 mSubjectView.setText(subject); 1614 addAddresses(mToView, Address.unpack(message.mTo)); 1615 Address[] cc = Address.unpack(message.mCc); 1616 if (cc.length > 0) { 1617 addAddresses(mCcView, cc); 1618 } 1619 Address[] bcc = Address.unpack(message.mBcc); 1620 if (bcc.length > 0) { 1621 addAddresses(mBccView, bcc); 1622 } 1623 1624 mMessageContentView.setText(message.mText); 1625 // TODO: re-enable loadAttachments 1626 // loadAttachments(message, 0); 1627 setDraftNeedsSaving(false); 1628 } 1629 showCcBccFieldsIfFilled(); 1630 setNewMessageFocus(); 1631 } 1632 1633 /** 1634 * Set a cursor to the end of a body except a signature. 1635 */ 1636 @VisibleForTesting 1637 void setMessageContentSelection(String signature) { 1638 int selection = mMessageContentView.length(); 1639 if (!TextUtils.isEmpty(signature)) { 1640 int signatureLength = signature.length(); 1641 int estimatedSelection = selection - signatureLength; 1642 if (estimatedSelection >= 0) { 1643 CharSequence text = mMessageContentView.getText(); 1644 int i = 0; 1645 while (i < signatureLength 1646 && text.charAt(estimatedSelection + i) == signature.charAt(i)) { 1647 ++i; 1648 } 1649 if (i == signatureLength) { 1650 selection = estimatedSelection; 1651 while (selection > 0 && text.charAt(selection - 1) == '\n') { 1652 --selection; 1653 } 1654 } 1655 } 1656 } 1657 mMessageContentView.setSelection(selection, selection); 1658 } 1659 1660 /** 1661 * In order to accelerate typing, position the cursor in the first empty field, 1662 * or at the end of the body composition field if none are empty. Typically, this will 1663 * play out as follows: 1664 * Reply / Reply All - put cursor in the empty message body 1665 * Forward - put cursor in the empty To field 1666 * Edit Draft - put cursor in whatever field still needs entry 1667 */ 1668 private void setNewMessageFocus() { 1669 if (mToView.length() == 0) { 1670 mToView.requestFocus(); 1671 } else if (mSubjectView.length() == 0) { 1672 mSubjectView.requestFocus(); 1673 } else { 1674 mMessageContentView.requestFocus(); 1675 } 1676 } 1677 1678 /** 1679 * @return the signature for the specified account, if non-null. If the account specified is 1680 * null or has no signature, {@code null} is returned. 1681 */ 1682 private static String getAccountSignature(Account account) { 1683 return (account == null) ? null : account.mSignature; 1684 } 1685} 1686