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