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