/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.email.activity; import com.android.email.Controller; import com.android.email.ControllerResultUiThreadWrapper; import com.android.email.Email; import com.android.email.R; import com.android.email.Utility; import com.android.email.mail.Address; import com.android.email.mail.MeetingInfo; import com.android.email.mail.MessagingException; import com.android.email.mail.PackedString; import com.android.email.mail.internet.EmailHtmlUtil; import com.android.email.mail.internet.MimeUtility; import com.android.email.provider.AttachmentProvider; import com.android.email.provider.EmailContent.Attachment; import com.android.email.provider.EmailContent.Body; import com.android.email.provider.EmailContent.Message; import com.android.email.service.EmailServiceConstants; import org.apache.commons.io.IOUtils; import android.app.Activity; import android.app.Fragment; import android.content.ActivityNotFoundException; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.Environment; import android.os.Handler; import android.provider.ContactsContract; import android.provider.ContactsContract.CommonDataKinds; import android.provider.ContactsContract.QuickContact; import android.text.TextUtils; import android.util.Log; import android.util.Patterns; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.Date; import java.util.regex.Matcher; import java.util.regex.Pattern; // TODO Restore "Show pictures" state and scroll position on rotation. public class MessageViewFragment extends Fragment implements View.OnClickListener { private Context mContext; // Regex that matches start of img tag. '<(?i)img\s+'. private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+"); // Regex that matches Web URL protocol part as case insensitive. private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://"); private static int PREVIEW_ICON_WIDTH = 62; private static int PREVIEW_ICON_HEIGHT = 62; private TextView mSubjectView; private TextView mFromView; private TextView mDateView; private TextView mTimeView; private TextView mToView; private TextView mCcView; private View mCcContainerView; private WebView mMessageContentView; private LinearLayout mAttachments; private ImageView mAttachmentIcon; private ImageView mFavoriteIcon; private View mShowPicturesSection; private View mInviteSection; private ImageView mSenderPresenceView; private View mScrollView; // calendar meeting invite answers private TextView mMeetingYes; private TextView mMeetingMaybe; private TextView mMeetingNo; private int mPreviousMeetingResponse = -1; /** * If set, URI to the email (i.e. *.eml files, and possibly *.msg files) file that's being * viewed. * * Use {@link #isViewingEmailFile()} to see if the activity is created for opening an EML file. * * TODO: We probably should split it into two different MessageViews, one for regular messages * and the other for for EML files (these two will share the same base MessageView class) to * eliminate the bunch of 'if {@link #isViewingEmailFile()}'s. * Do this after making it into a fragment. */ private Uri mFileEmailUri; private long mAccountId = -1; private long mMessageId = -1; private Message mMessage; private LoadMessageTask mLoadMessageTask; private LoadBodyTask mLoadBodyTask; private LoadAttachmentsTask mLoadAttachmentsTask; private PresenceUpdater mPresenceUpdater; private long mLoadAttachmentId; // the attachment being saved/viewed private boolean mLoadAttachmentSave; // if true, saving - if false, viewing private String mLoadAttachmentName; // the display name private java.text.DateFormat mDateFormat; private java.text.DateFormat mTimeFormat; private Drawable mFavoriteIconOn; private Drawable mFavoriteIconOff; private Controller mController; private ControllerResultUiThreadWrapper mControllerCallback; // contains the HTML body. Is used by LoadAttachmentTask to display inline images. // is null most of the time, is used transiently to pass info to LoadAttachementTask private String mHtmlTextRaw; // contains the HTML content as set in WebView. private String mHtmlTextWebView; private boolean mStarted; /** * Encapsulates known information about a single attachment. */ private static class AttachmentInfo { public String name; public String contentType; public long size; public long attachmentId; public Button viewButton; public Button downloadButton; public ImageView iconView; } public interface Callback { /** * Called when a link in a message is clicked. * * @param url link url that's clicked. * @return true if handled, false otherwise. */ public boolean onUrlInMessageClicked(String url); /** Called when the message specified doesn't exist. */ public void onMessageNotExists(); /** Called when the "view in calendar" link is clicked. */ public void onCalendarLinkClicked(long epochEventStartTime); /** Called when it starts loading a message. */ public void onLoadMessageStarted(); /** Called when it successfully finishes loading a message. */ public void onLoadMessageFinished(); /** Called when an error occurred during loading a message. */ public void onLoadMessageError(); /** Called when the current message is set unread. */ public void onMessageSetUnread(); /** * Called when a calender response button is clicked. * * @param response one of {@link EmailServiceConstants#MEETING_REQUEST_ACCEPTED}, * {@link EmailServiceConstants#MEETING_REQUEST_DECLINED}, or * {@link EmailServiceConstants#MEETING_REQUEST_TENTATIVE}. */ public void onRespondedToInvite(int response); /** Called when it starts loading an attachment. */ public void onFetchAttachmentStarted(String attachmentName); /** Called when it successfully finishes loading an attachment. */ public void onFetchAttachmentFinished(); /** Called when an error occurred during loading an attachment. */ public void onFetchAttachmentError(); } private static final class EmptyCallback implements Callback { public static final Callback INSTANCE = new EmptyCallback(); @Override public void onCalendarLinkClicked(long epochEventStartTime) { } @Override public void onFetchAttachmentError() { } @Override public void onFetchAttachmentFinished() { } @Override public void onFetchAttachmentStarted(String attachmentName) { } @Override public void onLoadMessageError() { } @Override public void onLoadMessageFinished() { } @Override public void onLoadMessageStarted() { } @Override public void onMessageNotExists() { } @Override public void onMessageSetUnread() { } @Override public void onRespondedToInvite(int response) { } @Override public boolean onUrlInMessageClicked(String url) { return false; } } private Callback mCallback = EmptyCallback.INSTANCE; @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mContext = getActivity().getApplicationContext(); mControllerCallback = new ControllerResultUiThreadWrapper( new Handler(), new ControllerResults()); mPresenceUpdater = new PresenceUpdater(mContext); mDateFormat = android.text.format.DateFormat.getDateFormat(mContext); // short format mTimeFormat = android.text.format.DateFormat.getTimeFormat(mContext); // 12/24 date format final Resources res = mContext.getResources(); mFavoriteIconOn = res.getDrawable(R.drawable.btn_star_big_buttonless_on); mFavoriteIconOff = res.getDrawable(R.drawable.btn_star_big_buttonless_off); mController = Controller.getInstance(mContext); } @Override public View onCreateView( LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { final View view = inflater.inflate(R.layout.message_view_header, container, false); mSubjectView = (TextView) view.findViewById(R.id.subject); mFromView = (TextView) view.findViewById(R.id.from); mToView = (TextView) view.findViewById(R.id.to); mCcView = (TextView) view.findViewById(R.id.cc); mCcContainerView = view.findViewById(R.id.cc_container); mDateView = (TextView) view.findViewById(R.id.date); mTimeView = (TextView) view.findViewById(R.id.time); mMessageContentView = (WebView) view.findViewById(R.id.message_content); mAttachments = (LinearLayout) view.findViewById(R.id.attachments); mAttachmentIcon = (ImageView) view.findViewById(R.id.attachment); mFavoriteIcon = (ImageView) view.findViewById(R.id.favorite); mShowPicturesSection = view.findViewById(R.id.show_pictures_section); mInviteSection = view.findViewById(R.id.invite_section); mSenderPresenceView = (ImageView) view.findViewById(R.id.presence); mScrollView = view.findViewById(R.id.scrollview); mFromView.setOnClickListener(this); mSenderPresenceView.setOnClickListener(this); mFavoriteIcon.setOnClickListener(this); view.findViewById(R.id.show_pictures).setOnClickListener(this); mMeetingYes = (TextView) view.findViewById(R.id.accept); mMeetingMaybe = (TextView) view.findViewById(R.id.maybe); mMeetingNo = (TextView) view.findViewById(R.id.decline); mMeetingYes.setOnClickListener(this); mMeetingMaybe.setOnClickListener(this); mMeetingNo.setOnClickListener(this); view.findViewById(R.id.invite_link).setOnClickListener(this); mMessageContentView.setVerticalScrollBarEnabled(false); mMessageContentView.getSettings().setBlockNetworkLoads(true); mMessageContentView.getSettings().setSupportZoom(false); mMessageContentView.setWebViewClient(new CustomWebViewClient()); return view; } @Override public void onActivityCreated(Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); mController.addResultCallback(mControllerCallback); } @Override public void onStart() { super.onStart(); mStarted = true; if (mMessageId != -1 || mFileEmailUri != null) { openMessageInternal(); } } @Override public void onResume() { super.onResume(); } @Override public void onPause() { super.onPause(); } @Override public void onStop() { mStarted = false; super.onStop(); } @Override public void onDestroy() { mController.removeResultCallback(mControllerCallback); cancelAllTasks(); mMessageContentView.destroy(); mMessageContentView = null; // If we're leaving a non-attachment message, delete any/all attachment messages // TODO It's probably wronn. We can show an EML in other app's stack, in which case // we can task-switch between the main app and the activity showing an EML. // We probably have to keep track of the number of EMLs currently open in a static field. if (!isViewingEmailFile()) { mController.deleteAttachmentMessages(); } super.onDestroy(); } public void setCallback(Callback callback) { mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback; } private void cancelAllTasks() { Utility.cancelTaskInterrupt(mLoadMessageTask); mLoadMessageTask = null; Utility.cancelTaskInterrupt(mLoadBodyTask); mLoadBodyTask = null; Utility.cancelTaskInterrupt(mLoadAttachmentsTask); mLoadAttachmentsTask = null; if (mPresenceUpdater != null) { mPresenceUpdater.cancelAll(); } } /** * @return true if viewing an email file. (i.e. *.eml files) */ private boolean isViewingEmailFile() { return mFileEmailUri != null; } /** * Returns the account id of the current message, or -1 if unknown. * Probably doesn't make sense if {@link #isViewingEmailFile()}. */ public long getAccountId() { return mAccountId; } /** Called by activities to set an id of a message to open. */ public void openMessage(long messageId) { mFileEmailUri = null; mMessageId = messageId; mAccountId = -1; if (mStarted) { openMessageInternal(); } } /** Called by activities to a URI to an EML file to open. */ public void openMessage(Uri fileEmailUri) { mFileEmailUri = fileEmailUri; mMessageId = -1; mAccountId = -1; if (mStarted) { openMessageInternal(); } } private void openMessageInternal() { cancelAllTasks(); if (mMessageContentView != null) { mMessageContentView.getSettings().setBlockNetworkLoads(true); mMessageContentView.scrollTo(0, 0); mMessageContentView.loadUrl("file:///android_asset/empty.html"); } mScrollView.scrollTo(0, 0); mAttachments.removeAllViews(); mAttachments.setVisibility(View.GONE); mAttachmentIcon.setVisibility(View.GONE); mLoadMessageTask = new LoadMessageTask(mFileEmailUri, mMessageId, true); mLoadMessageTask.execute(); } /** * Handle clicks on sender, which shows {@link QuickContact} or prompts to add * the sender as a contact. * * TODO Move DB lookup to a worker thread. */ private void onClickSender() { // Bail early if message or sender not present if (mMessage == null) return; final Address senderEmail = Address.unpackFirst(mMessage.mFrom); if (senderEmail == null) return; // First perform lookup query to find existing contact final ContentResolver resolver = mContext.getContentResolver(); final String address = senderEmail.getAddress(); final Uri dataUri = Uri.withAppendedPath(CommonDataKinds.Email.CONTENT_FILTER_URI, Uri.encode(address)); final Uri lookupUri = ContactsContract.Data.getContactLookupUri(resolver, dataUri); if (lookupUri != null) { // Found matching contact, trigger QuickContact QuickContact.showQuickContact(mContext, mSenderPresenceView, lookupUri, QuickContact.MODE_LARGE, null); } else { // No matching contact, ask user to create one final Uri mailUri = Uri.fromParts("mailto", address, null); final Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, mailUri); // Pass along full E-mail string for possible create dialog intent.putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION, senderEmail.toString()); // Only provide personal name hint if we have one final String senderPersonal = senderEmail.getPersonal(); if (!TextUtils.isEmpty(senderPersonal)) { intent.putExtra(ContactsContract.Intents.Insert.NAME, senderPersonal); } intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); startActivity(intent); } } /** * Toggle favorite status and write back to provider */ private void onClickFavorite() { if (isViewingEmailFile()) { return; } if (mMessage != null) { // Update UI boolean newFavorite = ! mMessage.mFlagFavorite; mFavoriteIcon.setImageDrawable(newFavorite ? mFavoriteIconOn : mFavoriteIconOff); // Update provider mMessage.mFlagFavorite = newFavorite; mController.setMessageFavorite(mMessageId, newFavorite); } } /** * Set message read/unread. */ public void onMarkMessageAsRead(boolean isRead) { if (isViewingEmailFile()) { return; } if (mMessage != null && mMessage.mFlagRead != isRead) { mMessage.mFlagRead = isRead; mController.setMessageRead(mMessageId, isRead); if (!isRead) { // Became unread. We need to close the message. mCallback.onMessageSetUnread(); } } } /** * Send a service message indicating that a meeting invite button has been clicked. */ private void onRespondToInvite(int response, int toastResId) { if (isViewingEmailFile()) { return; } // do not send twice in a row the same response if (mPreviousMeetingResponse != response) { mController.sendMeetingResponse(mMessageId, response); mPreviousMeetingResponse = response; } Utility.showToast(getActivity(), toastResId); mCallback.onRespondedToInvite(response); } private void onDownloadAttachment(AttachmentInfo attachment) { if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) { /* * Abort early if there's no place to save the attachment. We don't want to spend * the time downloading it and then abort. */ Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved); return; } startLoadAttachment(attachment, true); } private void onViewAttachment(AttachmentInfo attachment) { startLoadAttachment(attachment, false); } private void startLoadAttachment(AttachmentInfo attachment, boolean save) { mLoadAttachmentId = attachment.attachmentId; mLoadAttachmentSave = save; mLoadAttachmentName = attachment.name; mController.loadAttachment(attachment.attachmentId, mMessageId, mMessage.mMailboxKey, mAccountId); } /** * Called by ControllerResults. Handle the final steps of downloading an attachment * (view or save). * * @param attachmentId the attachment that was just downloaded */ private void doFinishLoadAttachment(long attachmentId) { // If the result does't line up, just skip it - we handle one at a time. if (attachmentId != mLoadAttachmentId) { return; } Attachment attachment = Attachment.restoreAttachmentWithId(mContext, attachmentId); Uri attachmentUri = AttachmentProvider.getAttachmentUri(mAccountId, attachment.mId); Uri contentUri = AttachmentProvider.resolveAttachmentIdToContentUri( mContext.getContentResolver(), attachmentUri); if (mLoadAttachmentSave) { try { File file = Utility.createUniqueFile(Environment.getExternalStorageDirectory(), attachment.mFileName); InputStream in = mContext.getContentResolver().openInputStream(contentUri); OutputStream out = new FileOutputStream(file); IOUtils.copy(in, out); out.flush(); out.close(); in.close(); Utility.showToast(getActivity(), String.format( mContext.getString(R.string.message_view_status_attachment_saved), file.getName())); MediaOpener.scanAndOpen(getActivity(), file); } catch (IOException ioe) { Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved); } } else { try { Intent intent = new Intent(Intent.ACTION_VIEW); intent.setData(contentUri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET); startActivity(intent); } catch (ActivityNotFoundException e) { Utility.showToast(getActivity(), R.string.message_view_display_attachment_toast); // TODO: Add a proper warning message (and lots of upstream cleanup to prevent // it from happening) in the next release. } } } private void onShowPicturesInHtml() { if (mMessage != null) { if (mMessageContentView != null) { mMessageContentView.getSettings().setBlockNetworkLoads(false); if (mHtmlTextWebView != null) { mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView, "text/html", "utf-8", null); } } mShowPicturesSection.setVisibility(View.GONE); } } private void onInviteLinkClicked() { if (isViewingEmailFile()) { return; } String startTime = new PackedString(mMessage.mMeetingInfo).get(MeetingInfo.MEETING_DTSTART); if (startTime != null) { long epochTimeMillis = Utility.parseEmailDateTimeToMillis(startTime); mCallback.onCalendarLinkClicked(epochTimeMillis); } else { Email.log("meetingInfo without DTSTART " + mMessage.mMeetingInfo); } } @Override public void onClick(View view) { switch (view.getId()) { case R.id.from: case R.id.presence: onClickSender(); break; case R.id.favorite: onClickFavorite(); break; case R.id.download: onDownloadAttachment((AttachmentInfo) view.getTag()); break; case R.id.view: onViewAttachment((AttachmentInfo) view.getTag()); break; case R.id.show_pictures: onShowPicturesInHtml(); break; case R.id.accept: onRespondToInvite(EmailServiceConstants.MEETING_REQUEST_ACCEPTED, R.string.message_view_invite_toast_yes); break; case R.id.maybe: onRespondToInvite(EmailServiceConstants.MEETING_REQUEST_TENTATIVE, R.string.message_view_invite_toast_maybe); break; case R.id.decline: onRespondToInvite(EmailServiceConstants.MEETING_REQUEST_DECLINED, R.string.message_view_invite_toast_no); break; case R.id.invite_link: onInviteLinkClicked(); break; } } /** * Start checking presence of the sender of the message. * * Note: This is just a polling operation. A more advanced solution would be to keep the * cursor open and respond to presence status updates (in the form of content change * notifications). However, because presence changes fairly slowly compared to the duration * of viewing a single message, a simple poll at message load (and onResume) should be * sufficient. */ private void startPresenceCheck() { // Set "unknown" presence icon. mSenderPresenceView.setImageResource(PresenceUpdater.getPresenceIconResourceId(null)); if (mMessage != null) { Address sender = Address.unpackFirst(mMessage.mFrom); if (sender != null) { String email = sender.getAddress(); if (email != null) { mPresenceUpdater.checkPresence(email, new PresenceUpdater.Callback() { @Override public void onPresenceResult(String emailAddress, Integer presenceStatus) { mSenderPresenceView.setImageResource( PresenceUpdater.getPresenceIconResourceId(presenceStatus)); } }); } } } } /** * Async task for loading a single message outside of the UI thread */ private class LoadMessageTask extends AsyncTask { private final long mId; private final boolean mOkToFetch; private final Uri mFileEmailUri; /** * Special constructor to cache some local info */ public LoadMessageTask(Uri fileEmailUri, long messageId, boolean okToFetch) { mFileEmailUri = fileEmailUri; mId = messageId; mOkToFetch = okToFetch; } /** * There will either be a Uri in the Intent (i.e. whose content is the Message to be * loaded), or mId will be holding the id of the Message as stored in the provider. * If we're loading via Uri, the Controller does the actual message parsing and storage, * and we setup the message id and mailbox id based on the result; forward and reply are * disabled for messages loaded via Uri */ @Override protected Message doInBackground(Void... params) { // If we have a URI, then we were opened via an intent filter (e.g. an attachment that // has a mime type that we can handle (e.g. message/rfc822). if (mFileEmailUri != null) { final Activity activity = getActivity(); // Put up a toast; this can take a little while... Utility.showToast(activity, R.string.message_view_parse_message_toast); Message msg = mController.loadMessageFromUri(mFileEmailUri); if (msg == null) { // Indicate that the attachment couldn't be loaded Utility.showToast(activity, R.string.message_view_display_attachment_toast); return null; } return msg; } return Message.restoreMessageWithId(mContext, mId); } @Override protected void onPostExecute(Message message) { /* doInBackground() may return null result (due to restoreMessageWithId()) * and in that situation we want to Activity.finish(). * * OTOH we don't want to Activity.finish() for isCancelled() because this * would introduce a surprise side-effect to task cancellation: every task * cancelation would also result in finish(). * * Right now LoadMesageTask is cancelled not only from onDestroy(), * and it would be a bug to also finish() the activity in that situation. */ if (isCancelled()) { return; } if (message == null) { mCallback.onMessageNotExists(); return; } mMessageId = message.mId; reloadUiFromMessage(message, mOkToFetch); startPresenceCheck(); } } /** * Async task for loading a single message body outside of the UI thread */ private class LoadBodyTask extends AsyncTask { private long mId; private boolean mErrorLoadingMessageBody; /** * Special constructor to cache some local info */ public LoadBodyTask(long messageId) { mId = messageId; } @Override protected String[] doInBackground(Void... params) { try { String text = null; String html = Body.restoreBodyHtmlWithMessageId(mContext, mId); if (html == null) { text = Body.restoreBodyTextWithMessageId(mContext, mId); } return new String[] { text, html }; } catch (RuntimeException re) { // This catches SQLiteException as well as other RTE's we've seen from the // database calls, such as IllegalStateException Log.d(Email.LOG_TAG, "Exception while loading message body: " + re.toString()); mErrorLoadingMessageBody = true; return new String[] { null, null }; } } @Override protected void onPostExecute(String[] results) { if (results == null) { if (mErrorLoadingMessageBody) { Utility.showToast(getActivity(), R.string.error_loading_message_body); } return; } reloadUiFromBody(results[0], results[1]); // text, html onMarkMessageAsRead(true); } } /** * Async task for loading attachments * * Note: This really should only be called when the message load is complete - or, we should * leave open a listener so the attachments can fill in as they are discovered. In either case, * this implementation is incomplete, as it will fail to refresh properly if the message is * partially loaded at this time. */ private class LoadAttachmentsTask extends AsyncTask { @Override protected Attachment[] doInBackground(Long... messageIds) { return Attachment.restoreAttachmentsWithMessageId(mContext, messageIds[0]); } @Override protected void onPostExecute(Attachment[] attachments) { if (attachments == null) { return; } boolean htmlChanged = false; for (Attachment attachment : attachments) { if (mHtmlTextRaw != null && attachment.mContentId != null && attachment.mContentUri != null) { // for html body, replace CID for inline images // Regexp which matches ' src="cid:contentId"'. String contentIdRe = "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\""; String srcContentUri = " src=\"" + attachment.mContentUri + "\""; mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri); htmlChanged = true; } else { addAttachment(attachment); } } mHtmlTextWebView = mHtmlTextRaw; mHtmlTextRaw = null; if (htmlChanged && mMessageContentView != null) { mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView, "text/html", "utf-8", null); } } } private Bitmap getPreviewIcon(AttachmentInfo attachment) { try { return BitmapFactory.decodeStream( mContext.getContentResolver().openInputStream( AttachmentProvider.getAttachmentThumbnailUri( mAccountId, attachment.attachmentId, PREVIEW_ICON_WIDTH, PREVIEW_ICON_HEIGHT))); } catch (Exception e) { // We don't care what happened, we just return null for the preview icon. return null; } } private void updateAttachmentThumbnail(long attachmentId) { for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { AttachmentInfo attachment = (AttachmentInfo) mAttachments.getChildAt(i).getTag(); if (attachment.attachmentId == attachmentId) { Bitmap previewIcon = getPreviewIcon(attachment); if (previewIcon != null) { attachment.iconView.setImageBitmap(previewIcon); } return; } } } /** * Copy data from a cursor-refreshed attachment into the UI. Called from UI thread. * * @param attachment A single attachment loaded from the provider */ private void addAttachment(Attachment attachment) { AttachmentInfo attachmentInfo = new AttachmentInfo(); attachmentInfo.size = attachment.mSize; attachmentInfo.contentType = AttachmentProvider.inferMimeType(attachment.mFileName, attachment.mMimeType); attachmentInfo.name = attachment.mFileName; attachmentInfo.attachmentId = attachment.mId; LayoutInflater inflater = getActivity().getLayoutInflater(); View view = inflater.inflate(R.layout.message_view_attachment, null); TextView attachmentName = (TextView)view.findViewById(R.id.attachment_name); TextView attachmentInfoView = (TextView)view.findViewById(R.id.attachment_info); ImageView attachmentIcon = (ImageView)view.findViewById(R.id.attachment_icon); Button attachmentView = (Button)view.findViewById(R.id.view); Button attachmentDownload = (Button)view.findViewById(R.id.download); if ((!MimeUtility.mimeTypeMatches(attachmentInfo.contentType, Email.ACCEPTABLE_ATTACHMENT_VIEW_TYPES)) || (MimeUtility.mimeTypeMatches(attachmentInfo.contentType, Email.UNACCEPTABLE_ATTACHMENT_VIEW_TYPES))) { attachmentView.setVisibility(View.GONE); } if ((!MimeUtility.mimeTypeMatches(attachmentInfo.contentType, Email.ACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES)) || (MimeUtility.mimeTypeMatches(attachmentInfo.contentType, Email.UNACCEPTABLE_ATTACHMENT_DOWNLOAD_TYPES))) { attachmentDownload.setVisibility(View.GONE); } if (attachmentInfo.size > Email.MAX_ATTACHMENT_DOWNLOAD_SIZE) { attachmentView.setVisibility(View.GONE); attachmentDownload.setVisibility(View.GONE); } attachmentInfo.viewButton = attachmentView; attachmentInfo.downloadButton = attachmentDownload; attachmentInfo.iconView = attachmentIcon; view.setTag(attachmentInfo); attachmentView.setOnClickListener(this); attachmentView.setTag(attachmentInfo); attachmentDownload.setOnClickListener(this); attachmentDownload.setTag(attachmentInfo); attachmentName.setText(attachmentInfo.name); attachmentInfoView.setText(Utility.formatSize(mContext, attachmentInfo.size)); Bitmap previewIcon = getPreviewIcon(attachmentInfo); if (previewIcon != null) { attachmentIcon.setImageBitmap(previewIcon); } mAttachments.addView(view); mAttachments.setVisibility(View.VISIBLE); } /** * Reload the UI from a provider cursor. This must only be called from the UI thread. * * @param message A copy of the message loaded from the database * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from * the network. Use false to prevent looping here. * * TODO: trigger presence check */ private void reloadUiFromMessage(Message message, boolean okToFetch) { mMessage = message; mAccountId = message.mAccountKey; mSubjectView.setText(message.mSubject); mFromView.setText(Address.toFriendly(Address.unpack(message.mFrom))); Date date = new Date(message.mTimeStamp); mTimeView.setText(mTimeFormat.format(date)); mDateView.setText(Utility.isDateToday(date) ? null : mDateFormat.format(date)); mToView.setText(Address.toFriendly(Address.unpack(message.mTo))); String friendlyCc = Address.toFriendly(Address.unpack(message.mCc)); mCcView.setText(friendlyCc); mCcContainerView.setVisibility((friendlyCc != null) ? View.VISIBLE : View.GONE); mAttachmentIcon.setVisibility(message.mAttachments != null ? View.VISIBLE : View.GONE); mFavoriteIcon.setImageDrawable(message.mFlagFavorite ? mFavoriteIconOn : mFavoriteIconOff); // Show the message invite section if we're an incoming meeting invitation only mInviteSection.setVisibility((message.mFlags & Message.FLAG_INCOMING_MEETING_INVITE) != 0 ? View.VISIBLE : View.GONE); // Handle partially-loaded email, as follows: // 1. Check value of message.mFlagLoaded // 2. If != LOADED, ask controller to load it // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask // 4. Else start the loader tasks right away (message already loaded) if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) { mControllerCallback.getWrappee().setWaitForLoadMessageId(message.mId); mController.loadMessageForView(message.mId); } else { mControllerCallback.getWrappee().setWaitForLoadMessageId(-1); // Ask for body mLoadBodyTask = new LoadBodyTask(message.mId); mLoadBodyTask.execute(); } } /** * Reload the body from the provider cursor. This must only be called from the UI thread. * * @param bodyText text part * @param bodyHtml html part * * TODO deal with html vs text and many other issues <- WHAT DOES IT MEAN?? */ private void reloadUiFromBody(String bodyText, String bodyHtml) { String text = null; mHtmlTextRaw = null; boolean hasImages = false; if (bodyHtml == null) { text = bodyText; /* * Convert the plain text to HTML */ StringBuffer sb = new StringBuffer(""); if (text != null) { // Escape any inadvertent HTML in the text message text = EmailHtmlUtil.escapeCharacterToDisplay(text); // Find any embedded URL's and linkify Matcher m = Patterns.WEB_URL.matcher(text); while (m.find()) { int start = m.start(); /* * WEB_URL_PATTERN may match domain part of email address. To detect * this false match, the character just before the matched string * should not be '@'. */ if (start == 0 || text.charAt(start - 1) != '@') { String url = m.group(); Matcher proto = WEB_URL_PROTOCOL.matcher(url); String link; if (proto.find()) { // This is work around to force URL protocol part be lower case, // because WebView could follow only lower case protocol link. link = proto.group().toLowerCase() + url.substring(proto.end()); } else { // Patterns.WEB_URL matches URL without protocol part, // so added default protocol to link. link = "http://" + url; } String href = String.format("%s", link, url); m.appendReplacement(sb, href); } else { m.appendReplacement(sb, "$0"); } } m.appendTail(sb); } sb.append(""); text = sb.toString(); } else { text = bodyHtml; mHtmlTextRaw = bodyHtml; hasImages = IMG_TAG_START_REGEX.matcher(text).find(); } // TODO this is not really accurate. // - Images aren't the only network resources. (e.g. CSS) // - If images are attached to the email and small enough, we download them at once, // and won't need network access when they're shown. mShowPicturesSection.setVisibility(hasImages ? View.VISIBLE : View.GONE); if (mMessageContentView != null) { mMessageContentView.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null); } // Ask for attachments after body mLoadAttachmentsTask = new LoadAttachmentsTask(); mLoadAttachmentsTask.execute(mMessage.mId); } /** * Overrides for WebView behaviors. */ private class CustomWebViewClient extends WebViewClient { @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { return mCallback.onUrlInMessageClicked(url); } } /** * Controller results listener. We wrap it with {@link ControllerResultUiThreadWrapper}, * so all methods are called on the UI thread. */ private class ControllerResults extends Controller.Result { private long mWaitForLoadMessageId; public void setWaitForLoadMessageId(long messageId) { mWaitForLoadMessageId = messageId; } @Override public void loadMessageForViewCallback(MessagingException result, long messageId, int progress) { if (messageId != mWaitForLoadMessageId) { // We are not waiting for this message to load, so exit quickly return; } if (result == null) { switch (progress) { case 0: mCallback.onLoadMessageStarted(); loadBodyContent("file:///android_asset/loading.html"); break; case 100: mWaitForLoadMessageId = -1; mCallback.onLoadMessageFinished(); // reload UI and reload everything else too // pass false to LoadMessageTask to prevent looping here cancelAllTasks(); mLoadMessageTask = new LoadMessageTask(mFileEmailUri, mMessageId, false); mLoadMessageTask.execute(); break; default: // do nothing - we don't have a progress bar at this time break; } } else { mWaitForLoadMessageId = -1; mCallback.onLoadMessageError(); Utility.showToast(getActivity(), R.string.status_network_error); loadBodyContent("file:///android_asset/empty.html"); } } private void loadBodyContent(String uri) { if (mMessageContentView != null) { mMessageContentView.loadUrl(uri); } } @Override public void loadAttachmentCallback(MessagingException result, long messageId, long attachmentId, int progress) { if (messageId == mMessageId) { if (result == null) { switch (progress) { case 0: enableAttachments(false); mCallback.onFetchAttachmentStarted(mLoadAttachmentName); Utility.showToast(getActivity(), R.string.message_view_fetching_attachment_toast); break; case 100: enableAttachments(true); mCallback.onFetchAttachmentFinished(); updateAttachmentThumbnail(attachmentId); doFinishLoadAttachment(attachmentId); break; default: // do nothing - we don't have a progress bar at this time break; } } else { enableAttachments(true); mCallback.onFetchAttachmentError(); Utility.showToast(getActivity(), R.string.status_network_error); } } } private void enableAttachments(boolean enable) { for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) { AttachmentInfo attachment = (AttachmentInfo) mAttachments.getChildAt(i).getTag(); attachment.viewButton.setEnabled(enable); attachment.downloadButton.setEnabled(enable); } } @Override public void updateMailboxCallback(MessagingException result, long accountId, long mailboxId, int progress, int numNewMessages) { if (result != null || progress == 100) { Email.updateMailboxRefreshTime(mailboxId); } } } }