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