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