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