MessageViewFragmentBase.java revision 11d37a7fa971d0a39ad42517db8a49f1fdd56a1c
1/* 2 * Copyright (C) 2010 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.ControllerResultUiThreadWrapper; 21import com.android.email.Email; 22import com.android.email.R; 23import com.android.email.Utility; 24import com.android.email.mail.Address; 25import com.android.email.mail.MessagingException; 26import com.android.email.mail.internet.EmailHtmlUtil; 27import com.android.email.mail.internet.MimeUtility; 28import com.android.email.provider.AttachmentProvider; 29import com.android.email.provider.EmailContent.Attachment; 30import com.android.email.provider.EmailContent.Body; 31import com.android.email.provider.EmailContent.Mailbox; 32import com.android.email.provider.EmailContent.Message; 33import com.android.email.service.AttachmentDownloadService; 34 35import org.apache.commons.io.IOUtils; 36 37import android.app.Fragment; 38import android.app.LoaderManager.LoaderCallbacks; 39import android.content.ActivityNotFoundException; 40import android.content.Context; 41import android.content.Intent; 42import android.content.Loader; 43import android.graphics.Bitmap; 44import android.graphics.BitmapFactory; 45import android.net.Uri; 46import android.os.AsyncTask; 47import android.os.Bundle; 48import android.os.Environment; 49import android.os.Handler; 50import android.provider.ContactsContract; 51import android.provider.ContactsContract.QuickContact; 52import android.text.TextUtils; 53import android.util.Log; 54import android.util.Patterns; 55import android.view.LayoutInflater; 56import android.view.View; 57import android.view.ViewGroup; 58import android.webkit.WebView; 59import android.webkit.WebViewClient; 60import android.widget.Button; 61import android.widget.ImageView; 62import android.widget.LinearLayout; 63import android.widget.ProgressBar; 64import android.widget.QuickContactBadge; 65import android.widget.TextView; 66 67import java.io.File; 68import java.io.FileOutputStream; 69import java.io.IOException; 70import java.io.InputStream; 71import java.io.OutputStream; 72import java.util.Date; 73import java.util.regex.Matcher; 74import java.util.regex.Pattern; 75 76// TODO Restore "Show pictures" state and scroll position on rotation. 77 78/** 79 * Base class for {@link MessageViewFragment} and {@link MessageFileViewFragment}. 80 * 81 * See {@link MessageViewBase} for the class relation diagram. 82 * 83 * NOTE "Move to mailbox" and "delete message" are asynchronous operations, which means message' 84 * mailbox can change any time. Don't use {@link Message#mMailboxKey} of {@link #mMessage} 85 * directly. If you need, always load the latest value. 86 */ 87public abstract class MessageViewFragmentBase extends Fragment implements View.OnClickListener { 88 private static final int PHOTO_LOADER_ID = 1; 89 private Context mContext; 90 91 // Regex that matches start of img tag. '<(?i)img\s+'. 92 private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+"); 93 // Regex that matches Web URL protocol part as case insensitive. 94 private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://"); 95 96 private static int PREVIEW_ICON_WIDTH = 62; 97 private static int PREVIEW_ICON_HEIGHT = 62; 98 99 private TextView mSubjectView; 100 private TextView mFromView; 101 private TextView mDateView; 102 private TextView mTimeView; 103 private TextView mToView; 104 private TextView mCcView; 105 private View mCcContainerView; 106 private WebView mMessageContentView; 107 private LinearLayout mAttachments; 108 private ImageView mAttachmentIcon; 109 private View mShowPicturesSection; 110 private QuickContactBadge mFromBadge; 111 private ImageView mSenderPresenceView; 112 private View mScrollView; 113 114 private long mAccountId = -1; 115 private long mMessageId = -1; 116 private Message mMessage; 117 118 private LoadMessageTask mLoadMessageTask; 119 private LoadBodyTask mLoadBodyTask; 120 private LoadAttachmentsTask mLoadAttachmentsTask; 121 122 private java.text.DateFormat mDateFormat; 123 private java.text.DateFormat mTimeFormat; 124 125 private Controller mController; 126 private ControllerResultUiThreadWrapper<ControllerResults> mControllerCallback; 127 128 // contains the HTML body. Is used by LoadAttachmentTask to display inline images. 129 // is null most of the time, is used transiently to pass info to LoadAttachementTask 130 private String mHtmlTextRaw; 131 132 // contains the HTML content as set in WebView. 133 private String mHtmlTextWebView; 134 135 private boolean mStarted; 136 137 private boolean mIsMessageLoadedForTest; 138 139 private static final int CONTACT_STATUS_STATE_UNLOADED = 0; 140 private static final int CONTACT_STATUS_STATE_UNLOADED_TRIGGERED = 1; 141 private static final int CONTACT_STATUS_STATE_LOADED = 2; 142 143 private int mContactStatusState; 144 private Uri mQuickContactLookupUri; 145 146 /** 147 * Encapsulates known information about a single attachment. 148 */ 149 private static class AttachmentInfo { 150 public String name; 151 public String contentType; 152 public long size; 153 public long attachmentId; 154 public Button viewButton; 155 public Button saveButton; 156 public Button loadButton; 157 public Button cancelButton; 158 public ImageView iconView; 159 public ProgressBar progressView; 160 } 161 162 public interface Callback { 163 /** Called when the fragment is about to show up, or show a different message. */ 164 public void onMessageViewShown(int mailboxType); 165 166 /** Called when the fragment is about to be destroyed. */ 167 public void onMessageViewGone(); 168 169 /** 170 * Called when a link in a message is clicked. 171 * 172 * @param url link url that's clicked. 173 * @return true if handled, false otherwise. 174 */ 175 public boolean onUrlInMessageClicked(String url); 176 177 /** Called when the message specified doesn't exist. */ 178 public void onMessageNotExists(); 179 180 /** Called when it starts loading a message. */ 181 public void onLoadMessageStarted(); 182 183 /** Called when it successfully finishes loading a message. */ 184 public void onLoadMessageFinished(); 185 186 /** Called when an error occurred during loading a message. */ 187 public void onLoadMessageError(); 188 } 189 190 public static class EmptyCallback implements Callback { 191 public static final Callback INSTANCE = new EmptyCallback(); 192 @Override public void onMessageViewShown(int mailboxType) {} 193 @Override public void onMessageViewGone() {} 194 @Override public void onLoadMessageError() {} 195 @Override public void onLoadMessageFinished() {} 196 @Override public void onLoadMessageStarted() {} 197 @Override public void onMessageNotExists() {} 198 @Override 199 public boolean onUrlInMessageClicked(String url) { 200 return false; 201 } 202 } 203 204 private Callback mCallback = EmptyCallback.INSTANCE; 205 206 @Override 207 public void onCreate(Bundle savedInstanceState) { 208 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 209 Log.d(Email.LOG_TAG, "MessageViewFragment onCreate"); 210 } 211 super.onCreate(savedInstanceState); 212 213 mContext = getActivity().getApplicationContext(); 214 215 mControllerCallback = new ControllerResultUiThreadWrapper<ControllerResults>( 216 new Handler(), new ControllerResults()); 217 218 mDateFormat = android.text.format.DateFormat.getDateFormat(mContext); // short format 219 mTimeFormat = android.text.format.DateFormat.getTimeFormat(mContext); // 12/24 date format 220 221 mController = Controller.getInstance(mContext); 222 } 223 224 @Override 225 public View onCreateView( 226 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 227 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 228 Log.d(Email.LOG_TAG, "MessageViewFragment onCreateView"); 229 } 230 final View view = inflater.inflate(R.layout.message_view_fragment, container, false); 231 232 mSubjectView = (TextView) view.findViewById(R.id.subject); 233 mFromView = (TextView) view.findViewById(R.id.from); 234 mToView = (TextView) view.findViewById(R.id.to); 235 mCcView = (TextView) view.findViewById(R.id.cc); 236 mCcContainerView = view.findViewById(R.id.cc_container); 237 mDateView = (TextView) view.findViewById(R.id.date); 238 mTimeView = (TextView) view.findViewById(R.id.time); 239 mMessageContentView = (WebView) view.findViewById(R.id.message_content); 240 mAttachments = (LinearLayout) view.findViewById(R.id.attachments); 241 mAttachmentIcon = (ImageView) view.findViewById(R.id.attachment); 242 mShowPicturesSection = view.findViewById(R.id.show_pictures_section); 243 mFromBadge = (QuickContactBadge) view.findViewById(R.id.badge); 244 mSenderPresenceView = (ImageView) view.findViewById(R.id.presence); 245 mScrollView = view.findViewById(R.id.scrollview); 246 247 mFromView.setOnClickListener(this); 248 mFromBadge.setOnClickListener(this); 249 mSenderPresenceView.setOnClickListener(this); 250 view.findViewById(R.id.show_pictures).setOnClickListener(this); 251 252 mMessageContentView.setVerticalScrollBarEnabled(false); 253 mMessageContentView.getSettings().setBlockNetworkLoads(true); 254 mMessageContentView.getSettings().setSupportZoom(false); 255 mMessageContentView.setWebViewClient(new CustomWebViewClient()); 256 return view; 257 } 258 259 @Override 260 public void onActivityCreated(Bundle savedInstanceState) { 261 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 262 Log.d(Email.LOG_TAG, "MessageViewFragment onActivityCreated"); 263 } 264 super.onActivityCreated(savedInstanceState); 265 mController.addResultCallback(mControllerCallback); 266 } 267 268 @Override 269 public void onStart() { 270 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 271 Log.d(Email.LOG_TAG, "MessageViewFragment onStart"); 272 } 273 super.onStart(); 274 mStarted = true; 275 if (isMessageSpecified()) { 276 openMessageIfStarted(); 277 } 278 } 279 280 @Override 281 public void onResume() { 282 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 283 Log.d(Email.LOG_TAG, "MessageViewFragment onResume"); 284 } 285 super.onResume(); 286 } 287 288 @Override 289 public void onPause() { 290 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 291 Log.d(Email.LOG_TAG, "MessageViewFragment onPause"); 292 } 293 super.onPause(); 294 } 295 296 @Override 297 public void onStop() { 298 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 299 Log.d(Email.LOG_TAG, "MessageViewFragment onStop"); 300 } 301 mStarted = false; 302 super.onStop(); 303 } 304 305 @Override 306 public void onDestroy() { 307 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 308 Log.d(Email.LOG_TAG, "MessageViewFragment onDestroy"); 309 } 310 mCallback.onMessageViewGone(); 311 mController.removeResultCallback(mControllerCallback); 312 cancelAllTasks(); 313 mMessageContentView.destroy(); 314 mMessageContentView = null; 315 super.onDestroy(); 316 } 317 318 @Override 319 public void onSaveInstanceState(Bundle outState) { 320 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 321 Log.d(Email.LOG_TAG, "MessageViewFragment onSaveInstanceState"); 322 } 323 super.onSaveInstanceState(outState); 324 } 325 326 public void setCallback(Callback callback) { 327 mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback; 328 } 329 330 private void cancelAllTasks() { 331 Utility.cancelTaskInterrupt(mLoadMessageTask); 332 mLoadMessageTask = null; 333 Utility.cancelTaskInterrupt(mLoadBodyTask); 334 mLoadBodyTask = null; 335 Utility.cancelTaskInterrupt(mLoadAttachmentsTask); 336 mLoadAttachmentsTask = null; 337 } 338 339 /** 340 * Subclass returns true if which message to open is already specified by the activity. 341 */ 342 protected abstract boolean isMessageSpecified(); 343 344 protected final Controller getController() { 345 return mController; 346 } 347 348 protected final Callback getCallback() { 349 return mCallback; 350 } 351 352 protected final Message getMessage() { 353 return mMessage; 354 } 355 356 protected final boolean isMessageOpen() { 357 return mMessage != null; 358 } 359 360 /** 361 * Returns the account id of the current message, or -1 if unknown (message not open yet, or 362 * viewing an EML message). 363 */ 364 public long getAccountId() { 365 return mAccountId; 366 } 367 368 protected final void openMessageIfStarted() { 369 if (!mStarted) { 370 return; 371 } 372 cancelAllTasks(); 373 resetView(); 374 } 375 376 protected void resetView() { 377 if (mMessageContentView != null) { 378 mMessageContentView.getSettings().setBlockNetworkLoads(true); 379 mMessageContentView.scrollTo(0, 0); 380 mMessageContentView.loadUrl("file:///android_asset/empty.html"); 381 } 382 mScrollView.scrollTo(0, 0); 383 mAttachments.removeAllViews(); 384 mAttachments.setVisibility(View.GONE); 385 mAttachmentIcon.setVisibility(View.GONE); 386 mLoadMessageTask = new LoadMessageTask(true); 387 mLoadMessageTask.execute(); 388 initContactStatusViews(); 389 } 390 391 private void initContactStatusViews() { 392 mContactStatusState = CONTACT_STATUS_STATE_UNLOADED; 393 mQuickContactLookupUri = null; 394 mSenderPresenceView.setImageResource(ContactStatusLoader.PRESENCE_UNKNOWN_RESOURCE_ID); 395 mFromBadge.setImageToDefault(); 396 mFromBadge.assignContactFromEmail("", false); 397 } 398 399 /** 400 * Handle clicks on sender, which shows {@link QuickContact} or prompts to add 401 * the sender as a contact. 402 */ 403 private void onClickSender() { 404 final Address senderEmail = Address.unpackFirst(mMessage.mFrom); 405 if (senderEmail == null) return; 406 407 if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED) { 408 // Status not loaded yet. 409 mContactStatusState = CONTACT_STATUS_STATE_UNLOADED_TRIGGERED; 410 return; 411 } 412 if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED) { 413 return; // Already clicked, and waiting for the data. 414 } 415 416 if (mQuickContactLookupUri != null) { 417 QuickContact.showQuickContact(mContext, mFromBadge, mQuickContactLookupUri, 418 QuickContact.MODE_LARGE, null); 419 } else { 420 // No matching contact, ask user to create one 421 final Uri mailUri = Uri.fromParts("mailto", senderEmail.getAddress(), null); 422 final Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, 423 mailUri); 424 425 // Pass along full E-mail string for possible create dialog 426 intent.putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION, 427 senderEmail.toString()); 428 429 // Only provide personal name hint if we have one 430 final String senderPersonal = senderEmail.getPersonal(); 431 if (!TextUtils.isEmpty(senderPersonal)) { 432 intent.putExtra(ContactsContract.Intents.Insert.NAME, senderPersonal); 433 } 434 intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 435 436 startActivity(intent); 437 } 438 } 439 440 private static class ContactStatusLoaderCallbacks 441 implements LoaderCallbacks<ContactStatusLoader.Result> { 442 private static final String BUNDLE_EMAIL_ADDRESS = "email"; 443 private final MessageViewFragmentBase mFragment; 444 445 public ContactStatusLoaderCallbacks(MessageViewFragmentBase fragment) { 446 mFragment = fragment; 447 } 448 449 public static Bundle createArguments(String emailAddress) { 450 Bundle b = new Bundle(); 451 b.putString(BUNDLE_EMAIL_ADDRESS, emailAddress); 452 return b; 453 } 454 455 @Override 456 public Loader<ContactStatusLoader.Result> onCreateLoader(int id, Bundle args) { 457 return new ContactStatusLoader(mFragment.mContext, 458 args.getString(BUNDLE_EMAIL_ADDRESS)); 459 } 460 461 @Override 462 public void onLoadFinished(Loader<ContactStatusLoader.Result> loader, 463 ContactStatusLoader.Result result) { 464 boolean triggered = 465 (mFragment.mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED); 466 mFragment.mContactStatusState = CONTACT_STATUS_STATE_LOADED; 467 mFragment.mQuickContactLookupUri = result.mLookupUri; 468 mFragment.mSenderPresenceView.setImageResource(result.mPresenceResId); 469 if (result.mPhoto != null) { // photo will be null if unknown. 470 mFragment.mFromBadge.setImageBitmap(result.mPhoto); 471 } 472 if (triggered) { 473 mFragment.onClickSender(); 474 } 475 } 476 } 477 478 private void onSaveAttachment(AttachmentInfo info) { 479 if (!Utility.isExternalStorageMounted()) { 480 /* 481 * Abort early if there's no place to save the attachment. We don't want to spend 482 * the time downloading it and then abort. 483 */ 484 Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved); 485 return; 486 } 487 Attachment attachment = Attachment.restoreAttachmentWithId(mContext, info.attachmentId); 488 Uri attachmentUri = AttachmentProvider.getAttachmentUri(mAccountId, attachment.mId); 489 490 try { 491 File file = Utility.createUniqueFile(Environment.getExternalStorageDirectory(), 492 attachment.mFileName); 493 Uri contentUri = AttachmentProvider.resolveAttachmentIdToContentUri( 494 mContext.getContentResolver(), attachmentUri); 495 InputStream in = mContext.getContentResolver().openInputStream(contentUri); 496 OutputStream out = new FileOutputStream(file); 497 IOUtils.copy(in, out); 498 out.flush(); 499 out.close(); 500 in.close(); 501 502 Utility.showToast(getActivity(), String.format( 503 mContext.getString(R.string.message_view_status_attachment_saved), 504 file.getName())); 505 MediaOpener.scanAndOpen(getActivity(), file); 506 } catch (IOException ioe) { 507 Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved); 508 } 509 } 510 511 private void onViewAttachment(AttachmentInfo info) { 512 Uri attachmentUri = AttachmentProvider.getAttachmentUri(mAccountId, info.attachmentId); 513 Uri contentUri = AttachmentProvider.resolveAttachmentIdToContentUri( 514 mContext.getContentResolver(), attachmentUri); 515 try { 516 Intent intent = new Intent(Intent.ACTION_VIEW); 517 intent.setData(contentUri); 518 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION 519 | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); 520 startActivity(intent); 521 } catch (ActivityNotFoundException e) { 522 Utility.showToast(getActivity(), R.string.message_view_display_attachment_toast); 523 // TODO: Add a proper warning message (and lots of upstream cleanup to prevent 524 // it from happening) in the next release. 525 } 526 } 527 528 private void onLoadAttachment(AttachmentInfo attachment) { 529 attachment.loadButton.setVisibility(View.GONE); 530 attachment.cancelButton.setVisibility(View.VISIBLE); 531 ProgressBar bar = attachment.progressView; 532 bar.setVisibility(View.VISIBLE); 533 bar.setIndeterminate(true); 534 mController.loadAttachment(attachment.attachmentId, mMessageId, mAccountId); 535 } 536 537 private void onCancelAttachment(AttachmentInfo attachment) { 538 // Don't change button states if we couldn't cancel the download 539 if (AttachmentDownloadService.cancelQueuedAttachment(attachment.attachmentId)) { 540 attachment.loadButton.setVisibility(View.VISIBLE); 541 attachment.cancelButton.setVisibility(View.GONE); 542 ProgressBar bar = attachment.progressView; 543 bar.setVisibility(View.GONE); 544 } 545 } 546 547 /** 548 * Called by ControllerResults. Show the "View" and "Save" buttons; hide "Load" 549 * 550 * @param attachmentId the attachment that was just downloaded 551 */ 552 private void doFinishLoadAttachment(long attachmentId) { 553 AttachmentInfo info = findAttachmentInfo(attachmentId); 554 if (info != null) { 555 info.loadButton.setVisibility(View.INVISIBLE); 556 info.loadButton.setVisibility(View.GONE); 557 if (!TextUtils.isEmpty(info.name)) { 558 info.saveButton.setVisibility(View.VISIBLE); 559 } 560 info.viewButton.setVisibility(View.VISIBLE); 561 } 562 } 563 564 private void onShowPicturesInHtml() { 565 if (mMessageContentView != null) { 566 mMessageContentView.getSettings().setBlockNetworkLoads(false); 567 if (mHtmlTextWebView != null) { 568 mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView, 569 "text/html", "utf-8", null); 570 } 571 } 572 mShowPicturesSection.setVisibility(View.GONE); 573 } 574 575 @Override 576 public void onClick(View view) { 577 if (!isMessageOpen()) { 578 return; // Ignore. 579 } 580 switch (view.getId()) { 581 case R.id.from: 582 case R.id.badge: 583 case R.id.presence: 584 onClickSender(); 585 break; 586 case R.id.load: 587 onLoadAttachment((AttachmentInfo) view.getTag()); 588 break; 589 case R.id.save: 590 onSaveAttachment((AttachmentInfo) view.getTag()); 591 break; 592 case R.id.view: 593 onViewAttachment((AttachmentInfo) view.getTag()); 594 break; 595 case R.id.cancel: 596 onCancelAttachment((AttachmentInfo) view.getTag()); 597 break; 598 case R.id.show_pictures: 599 onShowPicturesInHtml(); 600 break; 601 } 602 } 603 604 /** 605 * Start loading contact photo and presence. 606 */ 607 private void queryContactStatus() { 608 initContactStatusViews(); // Initialize the state, just in case. 609 610 // Find the sender email address, and start presence check. 611 if (mMessage != null) { 612 Address sender = Address.unpackFirst(mMessage.mFrom); 613 if (sender != null) { 614 String email = sender.getAddress(); 615 if (email != null) { 616 mFromBadge.assignContactFromEmail(email, false); 617 getLoaderManager().restartLoader(PHOTO_LOADER_ID, 618 ContactStatusLoaderCallbacks.createArguments(email), 619 new ContactStatusLoaderCallbacks(this)); 620 } 621 } 622 } 623 } 624 625 /** 626 * Called on a worker thread by {@link LoadMessageTask} to load a message in a subclass specific 627 * way. 628 */ 629 protected abstract Message openMessageSync(); 630 631 /** 632 * Async task for loading a single message outside of the UI thread 633 */ 634 private class LoadMessageTask extends AsyncTask<Void, Void, Message> { 635 636 private final boolean mOkToFetch; 637 private int mMailboxType; 638 639 /** 640 * Special constructor to cache some local info 641 */ 642 public LoadMessageTask(boolean okToFetch) { 643 mOkToFetch = okToFetch; 644 } 645 646 @Override 647 protected Message doInBackground(Void... params) { 648 Message message = openMessageSync(); 649 if (message != null) { 650 mMailboxType = Mailbox.getMailboxType(mContext, message.mMailboxKey); 651 if (mMailboxType == -1) { 652 message = null; // mailbox removed?? 653 } 654 } 655 return message; 656 } 657 658 @Override 659 protected void onPostExecute(Message message) { 660 /* doInBackground() may return null result (due to restoreMessageWithId()) 661 * and in that situation we want to Activity.finish(). 662 * 663 * OTOH we don't want to Activity.finish() for isCancelled() because this 664 * would introduce a surprise side-effect to task cancellation: every task 665 * cancelation would also result in finish(). 666 * 667 * Right now LoadMesageTask is cancelled not only from onDestroy(), 668 * and it would be a bug to also finish() the activity in that situation. 669 */ 670 if (isCancelled()) { 671 return; 672 } 673 if (message == null) { 674 mCallback.onMessageNotExists(); 675 return; 676 } 677 mMessageId = message.mId; 678 679 reloadUiFromMessage(message, mOkToFetch); 680 queryContactStatus(); 681 mCallback.onMessageViewShown(mMailboxType); 682 } 683 } 684 685 /** 686 * Called when the message body is loaded. 687 */ 688 protected void onPostLoadBody() { 689 } 690 691 /** 692 * Async task for loading a single message body outside of the UI thread 693 */ 694 private class LoadBodyTask extends AsyncTask<Void, Void, String[]> { 695 696 private long mId; 697 private boolean mErrorLoadingMessageBody; 698 699 /** 700 * Special constructor to cache some local info 701 */ 702 public LoadBodyTask(long messageId) { 703 mId = messageId; 704 } 705 706 @Override 707 protected String[] doInBackground(Void... params) { 708 try { 709 String text = null; 710 String html = Body.restoreBodyHtmlWithMessageId(mContext, mId); 711 if (html == null) { 712 text = Body.restoreBodyTextWithMessageId(mContext, mId); 713 } 714 return new String[] { text, html }; 715 } catch (RuntimeException re) { 716 // This catches SQLiteException as well as other RTE's we've seen from the 717 // database calls, such as IllegalStateException 718 Log.d(Email.LOG_TAG, "Exception while loading message body: " + re.toString()); 719 mErrorLoadingMessageBody = true; 720 return null; 721 } 722 } 723 724 @Override 725 protected void onPostExecute(String[] results) { 726 if (results == null || isCancelled()) { 727 if (mErrorLoadingMessageBody) { 728 Utility.showToast(getActivity(), R.string.error_loading_message_body); 729 } 730 return; 731 } 732 reloadUiFromBody(results[0], results[1]); // text, html 733 onPostLoadBody(); 734 } 735 } 736 737 /** 738 * Async task for loading attachments 739 * 740 * Note: This really should only be called when the message load is complete - or, we should 741 * leave open a listener so the attachments can fill in as they are discovered. In either case, 742 * this implementation is incomplete, as it will fail to refresh properly if the message is 743 * partially loaded at this time. 744 */ 745 private class LoadAttachmentsTask extends AsyncTask<Long, Void, Attachment[]> { 746 @Override 747 protected Attachment[] doInBackground(Long... messageIds) { 748 return Attachment.restoreAttachmentsWithMessageId(mContext, messageIds[0]); 749 } 750 751 @Override 752 protected void onPostExecute(Attachment[] attachments) { 753 if (attachments == null) { 754 return; 755 } 756 boolean htmlChanged = false; 757 for (Attachment attachment : attachments) { 758 if (mHtmlTextRaw != null && attachment.mContentId != null 759 && attachment.mContentUri != null) { 760 // for html body, replace CID for inline images 761 // Regexp which matches ' src="cid:contentId"'. 762 String contentIdRe = 763 "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\""; 764 String srcContentUri = " src=\"" + attachment.mContentUri + "\""; 765 mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri); 766 htmlChanged = true; 767 } else { 768 addAttachment(attachment); 769 } 770 } 771 mHtmlTextWebView = mHtmlTextRaw; 772 mHtmlTextRaw = null; 773 if (htmlChanged && mMessageContentView != null) { 774 mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView, 775 "text/html", "utf-8", null); 776 } 777 } 778 } 779 780 private Bitmap getPreviewIcon(AttachmentInfo attachment) { 781 try { 782 return BitmapFactory.decodeStream( 783 mContext.getContentResolver().openInputStream( 784 AttachmentProvider.getAttachmentThumbnailUri( 785 mAccountId, attachment.attachmentId, 786 PREVIEW_ICON_WIDTH, 787 PREVIEW_ICON_HEIGHT))); 788 } catch (Exception e) { 789 // We don't care what happened, we just return null for the preview icon. 790 return null; 791 } 792 } 793 794 private void updateAttachmentThumbnail(long attachmentId) { 795 for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { 796 AttachmentInfo attachment = (AttachmentInfo) mAttachments.getChildAt(i).getTag(); 797 if (attachment.attachmentId == attachmentId) { 798 Bitmap previewIcon = getPreviewIcon(attachment); 799 if (previewIcon != null) { 800 attachment.iconView.setImageBitmap(previewIcon); 801 } 802 return; 803 } 804 } 805 } 806 807 /** 808 * Copy data from a cursor-refreshed attachment into the UI. Called from UI thread. 809 * 810 * @param attachment A single attachment loaded from the provider 811 */ 812 private void addAttachment(Attachment attachment) { 813 AttachmentInfo attachmentInfo = new AttachmentInfo(); 814 attachmentInfo.size = attachment.mSize; 815 attachmentInfo.contentType = 816 AttachmentProvider.inferMimeType(attachment.mFileName, attachment.mMimeType); 817 attachmentInfo.name = attachment.mFileName; 818 attachmentInfo.attachmentId = attachment.mId; 819 820 LayoutInflater inflater = getActivity().getLayoutInflater(); 821 View view = inflater.inflate(R.layout.message_view_attachment, null); 822 823 TextView attachmentName = (TextView)view.findViewById(R.id.attachment_name); 824 TextView attachmentInfoView = (TextView)view.findViewById(R.id.attachment_info); 825 ImageView attachmentIcon = (ImageView)view.findViewById(R.id.attachment_icon); 826 Button attachmentView = (Button)view.findViewById(R.id.view); 827 Button attachmentSave = (Button)view.findViewById(R.id.save); 828 Button attachmentLoad = (Button)view.findViewById(R.id.load); 829 Button attachmentCancel = (Button)view.findViewById(R.id.cancel); 830 ProgressBar attachmentProgress = (ProgressBar)view.findViewById(R.id.progress); 831 832 // TODO: Remove this test (acceptable types = everything; unacceptable = nothing) 833 if ((!MimeUtility.mimeTypeMatches(attachmentInfo.contentType, 834 Email.ACCEPTABLE_ATTACHMENT_VIEW_TYPES)) 835 || (MimeUtility.mimeTypeMatches(attachmentInfo.contentType, 836 Email.UNACCEPTABLE_ATTACHMENT_VIEW_TYPES))) { 837 attachmentView.setVisibility(View.GONE); 838 } 839 840 if (attachmentInfo.size > Email.MAX_ATTACHMENT_DOWNLOAD_SIZE) { 841 attachmentView.setVisibility(View.GONE); 842 attachmentSave.setVisibility(View.GONE); 843 } 844 845 attachmentInfo.viewButton = attachmentView; 846 attachmentInfo.saveButton = attachmentSave; 847 attachmentInfo.loadButton = attachmentLoad; 848 attachmentInfo.cancelButton = attachmentCancel; 849 attachmentInfo.iconView = attachmentIcon; 850 attachmentInfo.progressView = attachmentProgress; 851 852 // If the attachment is loaded, show 100% progress 853 // Note that for POP3 messages, the user will only see "Open" and "Save" since the entire 854 // message is loaded before being shown. 855 if (Utility.attachmentExists(mContext, attachment)) { 856 // Hide "Load", show "View" and "Save" 857 attachmentProgress.setVisibility(View.VISIBLE); 858 attachmentProgress.setProgress(100); 859 attachmentSave.setVisibility(View.VISIBLE); 860 attachmentView.setVisibility(View.VISIBLE); 861 attachmentLoad.setVisibility(View.INVISIBLE); 862 attachmentCancel.setVisibility(View.GONE); 863 } else { 864 // Show "Load"; hide "View" and "Save" 865 attachmentSave.setVisibility(View.INVISIBLE); 866 attachmentView.setVisibility(View.INVISIBLE); 867 // If the attachment is queued, show the indeterminate progress bar. From this point,. 868 // any progress changes will cause this to be replaced by the normal progress bar 869 if (AttachmentDownloadService.isAttachmentQueued(attachment.mId)){ 870 attachmentProgress.setVisibility(View.VISIBLE); 871 attachmentProgress.setIndeterminate(true); 872 attachmentLoad.setVisibility(View.GONE); 873 attachmentCancel.setVisibility(View.VISIBLE); 874 } else { 875 attachmentLoad.setVisibility(View.VISIBLE); 876 attachmentCancel.setVisibility(View.GONE); 877 } 878 } 879 880 // Don't enable the "save" button if we've got no place to save the file 881 if (!Utility.isExternalStorageMounted()) { 882 attachmentSave.setEnabled(false); 883 } 884 885 view.setTag(attachmentInfo); 886 attachmentView.setOnClickListener(this); 887 attachmentView.setTag(attachmentInfo); 888 attachmentSave.setOnClickListener(this); 889 attachmentSave.setTag(attachmentInfo); 890 attachmentLoad.setOnClickListener(this); 891 attachmentLoad.setTag(attachmentInfo); 892 attachmentCancel.setOnClickListener(this); 893 attachmentCancel.setTag(attachmentInfo); 894 895 attachmentName.setText(attachmentInfo.name); 896 attachmentInfoView.setText(Utility.formatSize(mContext, attachmentInfo.size)); 897 898 Bitmap previewIcon = getPreviewIcon(attachmentInfo); 899 if (previewIcon != null) { 900 attachmentIcon.setImageBitmap(previewIcon); 901 } 902 903 mAttachments.addView(view); 904 mAttachments.setVisibility(View.VISIBLE); 905 } 906 907 /** 908 * Reload the UI from a provider cursor. This must only be called from the UI thread. 909 * 910 * @param message A copy of the message loaded from the database 911 * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from 912 * the network. Use false to prevent looping here. 913 */ 914 protected void reloadUiFromMessage(Message message, boolean okToFetch) { 915 mMessage = message; 916 mAccountId = message.mAccountKey; 917 918 mSubjectView.setText(message.mSubject); 919 mFromView.setText(Address.toFriendly(Address.unpack(message.mFrom))); 920 Date date = new Date(message.mTimeStamp); 921 mTimeView.setText(mTimeFormat.format(date)); 922 mDateView.setText(Utility.isDateToday(date) ? null : mDateFormat.format(date)); 923 mToView.setText(Address.toFriendly(Address.unpack(message.mTo))); 924 String friendlyCc = Address.toFriendly(Address.unpack(message.mCc)); 925 mCcView.setText(friendlyCc); 926 mCcContainerView.setVisibility((friendlyCc != null) ? View.VISIBLE : View.GONE); 927 mAttachmentIcon.setVisibility(message.mAttachments != null ? View.VISIBLE : View.GONE); 928 929 // Handle partially-loaded email, as follows: 930 // 1. Check value of message.mFlagLoaded 931 // 2. If != LOADED, ask controller to load it 932 // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask 933 // 4. Else start the loader tasks right away (message already loaded) 934 if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) { 935 mControllerCallback.getWrappee().setWaitForLoadMessageId(message.mId); 936 mController.loadMessageForView(message.mId); 937 } else { 938 mControllerCallback.getWrappee().setWaitForLoadMessageId(-1); 939 // Ask for body 940 mLoadBodyTask = new LoadBodyTask(message.mId); 941 mLoadBodyTask.execute(); 942 } 943 } 944 945 /** 946 * Reload the body from the provider cursor. This must only be called from the UI thread. 947 * 948 * @param bodyText text part 949 * @param bodyHtml html part 950 * 951 * TODO deal with html vs text and many other issues <- WHAT DOES IT MEAN?? 952 */ 953 private void reloadUiFromBody(String bodyText, String bodyHtml) { 954 String text = null; 955 mHtmlTextRaw = null; 956 boolean hasImages = false; 957 958 if (bodyHtml == null) { 959 text = bodyText; 960 /* 961 * Convert the plain text to HTML 962 */ 963 StringBuffer sb = new StringBuffer("<html><body>"); 964 if (text != null) { 965 // Escape any inadvertent HTML in the text message 966 text = EmailHtmlUtil.escapeCharacterToDisplay(text); 967 // Find any embedded URL's and linkify 968 Matcher m = Patterns.WEB_URL.matcher(text); 969 while (m.find()) { 970 int start = m.start(); 971 /* 972 * WEB_URL_PATTERN may match domain part of email address. To detect 973 * this false match, the character just before the matched string 974 * should not be '@'. 975 */ 976 if (start == 0 || text.charAt(start - 1) != '@') { 977 String url = m.group(); 978 Matcher proto = WEB_URL_PROTOCOL.matcher(url); 979 String link; 980 if (proto.find()) { 981 // This is work around to force URL protocol part be lower case, 982 // because WebView could follow only lower case protocol link. 983 link = proto.group().toLowerCase() + url.substring(proto.end()); 984 } else { 985 // Patterns.WEB_URL matches URL without protocol part, 986 // so added default protocol to link. 987 link = "http://" + url; 988 } 989 String href = String.format("<a href=\"%s\">%s</a>", link, url); 990 m.appendReplacement(sb, href); 991 } 992 else { 993 m.appendReplacement(sb, "$0"); 994 } 995 } 996 m.appendTail(sb); 997 } 998 sb.append("</body></html>"); 999 text = sb.toString(); 1000 } else { 1001 text = bodyHtml; 1002 mHtmlTextRaw = bodyHtml; 1003 hasImages = IMG_TAG_START_REGEX.matcher(text).find(); 1004 } 1005 1006 // TODO this is not really accurate. 1007 // - Images aren't the only network resources. (e.g. CSS) 1008 // - If images are attached to the email and small enough, we download them at once, 1009 // and won't need network access when they're shown. 1010 mShowPicturesSection.setVisibility(hasImages ? View.VISIBLE : View.GONE); 1011 if (mMessageContentView != null) { 1012 mMessageContentView.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); 1013 } 1014 1015 // Ask for attachments after body 1016 mLoadAttachmentsTask = new LoadAttachmentsTask(); 1017 mLoadAttachmentsTask.execute(mMessage.mId); 1018 1019 mIsMessageLoadedForTest = true; 1020 } 1021 1022 /** 1023 * Overrides for WebView behaviors. 1024 */ 1025 private class CustomWebViewClient extends WebViewClient { 1026 @Override 1027 public boolean shouldOverrideUrlLoading(WebView view, String url) { 1028 return mCallback.onUrlInMessageClicked(url); 1029 } 1030 } 1031 1032 private View findAttachmentView(long attachmentId) { 1033 for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { 1034 View view = mAttachments.getChildAt(i); 1035 AttachmentInfo attachment = (AttachmentInfo) view.getTag(); 1036 if (attachment.attachmentId == attachmentId) { 1037 return view; 1038 } 1039 } 1040 return null; 1041 } 1042 1043 private AttachmentInfo findAttachmentInfo(long attachmentId) { 1044 View view = findAttachmentView(attachmentId); 1045 if (view != null) { 1046 return (AttachmentInfo)view.getTag(); 1047 } 1048 return null; 1049 } 1050 1051 /** 1052 * Controller results listener. We wrap it with {@link ControllerResultUiThreadWrapper}, 1053 * so all methods are called on the UI thread. 1054 */ 1055 private class ControllerResults extends Controller.Result { 1056 private long mWaitForLoadMessageId; 1057 1058 public void setWaitForLoadMessageId(long messageId) { 1059 mWaitForLoadMessageId = messageId; 1060 } 1061 1062 @Override 1063 public void loadMessageForViewCallback(MessagingException result, long messageId, 1064 int progress) { 1065 if (messageId != mWaitForLoadMessageId) { 1066 // We are not waiting for this message to load, so exit quickly 1067 return; 1068 } 1069 if (result == null) { 1070 switch (progress) { 1071 case 0: 1072 mCallback.onLoadMessageStarted(); 1073 loadBodyContent("file:///android_asset/loading.html"); 1074 break; 1075 case 100: 1076 mWaitForLoadMessageId = -1; 1077 mCallback.onLoadMessageFinished(); 1078 // reload UI and reload everything else too 1079 // pass false to LoadMessageTask to prevent looping here 1080 cancelAllTasks(); 1081 mLoadMessageTask = new LoadMessageTask(false); 1082 mLoadMessageTask.execute(); 1083 break; 1084 default: 1085 // do nothing - we don't have a progress bar at this time 1086 break; 1087 } 1088 } else { 1089 mWaitForLoadMessageId = -1; 1090 mCallback.onLoadMessageError(); 1091 Utility.showToast(getActivity(), R.string.status_network_error); 1092 loadBodyContent("file:///android_asset/empty.html"); 1093 } 1094 } 1095 1096 private void loadBodyContent(String uri) { 1097 if (mMessageContentView != null) { 1098 mMessageContentView.loadUrl(uri); 1099 } 1100 } 1101 1102 @Override 1103 public void loadAttachmentCallback(MessagingException result, long messageId, 1104 long attachmentId, int progress) { 1105 if (messageId == mMessageId) { 1106 if (result == null) { 1107 showAttachmentProgress(attachmentId, progress); 1108 switch (progress) { 1109 case 100: 1110 updateAttachmentThumbnail(attachmentId); 1111 doFinishLoadAttachment(attachmentId); 1112 break; 1113 default: 1114 // do nothing - we don't have a progress bar at this time 1115 break; 1116 } 1117 } else { 1118 AttachmentInfo attachment = findAttachmentInfo(attachmentId); 1119 attachment.cancelButton.setVisibility(View.GONE); 1120 attachment.loadButton.setVisibility(View.VISIBLE); 1121 attachment.progressView.setVisibility(View.INVISIBLE); 1122 if (result.getCause() instanceof IOException) { 1123 Utility.showToast(getActivity(), R.string.status_network_error); 1124 } else { 1125 Utility.showToast(getActivity(), String.format( 1126 mContext.getString( 1127 R.string.message_view_load_attachment_failed_toast), 1128 attachment.name)); 1129 } 1130 } 1131 } 1132 } 1133 1134 private void showAttachmentProgress(long attachmentId, int progress) { 1135 AttachmentInfo attachment = findAttachmentInfo(attachmentId); 1136 if (attachment != null) { 1137 ProgressBar bar = attachment.progressView; 1138 if (progress == 0) { 1139 // When the download starts, we can get rid of the indeterminate bar 1140 bar.setVisibility(View.VISIBLE); 1141 bar.setIndeterminate(false); 1142 // And we're not implementing stop of in-progress downloads 1143 attachment.cancelButton.setVisibility(View.GONE); 1144 } 1145 bar.setProgress(progress); 1146 } 1147 } 1148 } 1149 1150 public boolean isMessageLoadedForTest() { 1151 return mIsMessageLoadedForTest; 1152 } 1153 1154 public void clearIsMessageLoadedForTest() { 1155 mIsMessageLoadedForTest = true; 1156 } 1157} 1158