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