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