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