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