MessageViewFragmentBase.java revision 9799b8fe4b35013b338da013cb5ee6d624500ffc
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.Preferences;
23import com.android.email.R;
24import com.android.email.Throttle;
25import com.android.email.Utility;
26import com.android.email.mail.Address;
27import com.android.email.mail.MessagingException;
28import com.android.email.mail.internet.EmailHtmlUtil;
29import com.android.email.mail.internet.MimeUtility;
30import com.android.email.provider.AttachmentProvider;
31import com.android.email.provider.EmailContent.Attachment;
32import com.android.email.provider.EmailContent.Body;
33import com.android.email.provider.EmailContent.Mailbox;
34import com.android.email.provider.EmailContent.Message;
35import com.android.email.service.AttachmentDownloadService;
36
37import org.apache.commons.io.IOUtils;
38
39import android.app.Activity;
40import android.app.Fragment;
41import android.app.LoaderManager.LoaderCallbacks;
42import android.content.ActivityNotFoundException;
43import android.content.ContentResolver;
44import android.content.ContentUris;
45import android.content.Context;
46import android.content.Intent;
47import android.content.Loader;
48import android.content.pm.PackageManager;
49import android.content.pm.ResolveInfo;
50import android.content.res.Resources;
51import android.database.ContentObserver;
52import android.graphics.Bitmap;
53import android.graphics.BitmapFactory;
54import android.net.ConnectivityManager;
55import android.net.NetworkInfo;
56import android.net.Uri;
57import android.os.AsyncTask;
58import android.os.Bundle;
59import android.os.Environment;
60import android.os.Handler;
61import android.provider.ContactsContract;
62import android.provider.ContactsContract.QuickContact;
63import android.text.SpannableStringBuilder;
64import android.text.TextUtils;
65import android.text.format.DateUtils;
66import android.util.Log;
67import android.util.Patterns;
68import android.view.LayoutInflater;
69import android.view.View;
70import android.view.ViewGroup;
71import android.webkit.WebSettings;
72import android.webkit.WebView;
73import android.webkit.WebViewClient;
74import android.widget.Button;
75import android.widget.ImageView;
76import android.widget.LinearLayout;
77import android.widget.ProgressBar;
78import android.widget.TextView;
79
80import java.io.File;
81import java.io.FileOutputStream;
82import java.io.IOException;
83import java.io.InputStream;
84import java.io.OutputStream;
85import java.util.Formatter;
86import java.util.List;
87import java.util.regex.Matcher;
88import java.util.regex.Pattern;
89
90// TODO Better handling of config changes.
91// - Restore "Show pictures" state, scroll position and current tab
92// - Retain the content; don't kick 3 async tasks every time
93
94/**
95 * Base class for {@link MessageViewFragment} and {@link MessageFileViewFragment}.
96 *
97 * See {@link MessageViewBase} for the class relation diagram.
98 */
99public abstract class MessageViewFragmentBase extends Fragment implements View.OnClickListener {
100    private static final int PHOTO_LOADER_ID = 1;
101    private Context mContext;
102
103    // Regex that matches start of img tag. '<(?i)img\s+'.
104    private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+");
105    // Regex that matches Web URL protocol part as case insensitive.
106    private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://");
107
108    private static int PREVIEW_ICON_WIDTH = 62;
109    private static int PREVIEW_ICON_HEIGHT = 62;
110
111    private TextView mSubjectView;
112    private TextView mFromNameView;
113    private TextView mFromAddressView;
114    private TextView mDateTimeView;
115    private TextView mAddressesView;
116    private WebView mMessageContentView;
117    private LinearLayout mAttachments;
118    private View mTabSection;
119    private ImageView mFromBadge;
120    private ImageView mSenderPresenceView;
121    private View mMainView;
122    private View mLoadingProgress;
123    private Button mShowDetailsButton;
124
125    private TextView mMessageTab;
126    private TextView mAttachmentTab;
127    private TextView mInviteTab;
128    // It is not really a tab, but looks like one of them.
129    private TextView mShowPicturesTab;
130
131    private View mAttachmentsScroll;
132    private View mInviteScroll;
133
134    private long mAccountId = -1;
135    private long mMessageId = -1;
136    private Message mMessage;
137
138    private LoadMessageTask mLoadMessageTask;
139    private ReloadMessageTask mReloadMessageTask;
140    private LoadBodyTask mLoadBodyTask;
141    private LoadAttachmentsTask mLoadAttachmentsTask;
142
143    private Controller mController;
144    private ControllerResultUiThreadWrapper<ControllerResults> mControllerCallback;
145
146    // contains the HTML body. Is used by LoadAttachmentTask to display inline images.
147    // is null most of the time, is used transiently to pass info to LoadAttachementTask
148    private String mHtmlTextRaw;
149
150    // contains the HTML content as set in WebView.
151    private String mHtmlTextWebView;
152
153    private boolean mResumed;
154    private boolean mLoadWhenResumed;
155
156    private boolean mIsMessageLoadedForTest;
157
158    private MessageObserver mMessageObserver;
159
160    private static final int CONTACT_STATUS_STATE_UNLOADED = 0;
161    private static final int CONTACT_STATUS_STATE_UNLOADED_TRIGGERED = 1;
162    private static final int CONTACT_STATUS_STATE_LOADED = 2;
163
164    private int mContactStatusState;
165    private Uri mQuickContactLookupUri;
166
167    /** Flag for {@link #mTabFlags}: Message has attachment(s) */
168    protected static final int TAB_FLAGS_HAS_ATTACHMENT = 1;
169
170    /**
171     * Flag for {@link #mTabFlags}: Message contains invite.  This flag is only set by
172     * {@link MessageViewFragment}.
173     */
174    protected static final int TAB_FLAGS_HAS_INVITE = 2;
175
176    /** Flag for {@link #mTabFlags}: Message contains pictures */
177    protected static final int TAB_FLAGS_HAS_PICTURES = 4;
178
179    /** Flag for {@link #mTabFlags}: "Show pictures" has already been pressed */
180    protected static final int TAB_FLAGS_PICTURE_LOADED = 8;
181
182    /**
183     * Flags to control the tabs.
184     * @see #updateTabFlags(int)
185     */
186    private int mTabFlags;
187
188    /** # of attachments in the current message */
189    private int mAttachmentCount;
190
191    // Use (random) large values, to avoid confusion with TAB_FLAGS_*
192    protected static final int TAB_MESSAGE = 101;
193    protected static final int TAB_INVITE = 102;
194    protected static final int TAB_ATTACHMENT = 103;
195
196    /**
197     * Currently visible tab.  Any of {@link #TAB_MESSAGE}, {@link #TAB_INVITE} or
198     * {@link #TAB_ATTACHMENT}.
199     *
200     * Note we don't retain this value through configuration changes, as restoring the current tab
201     * would be clumsy with the current implementation where we load Message/Body/Attachments
202     * separately.  (e.g. # of attachments can't be obtained quickly enough to update the UI
203     * after screen rotation.)
204     */
205    private int mCurrentTab;
206
207    /**
208     * Zoom scales for webview.  Values correspond to {@link Preferences#TEXT_ZOOM_TINY}..
209     * {@link Preferences#TEXT_ZOOM_HUGE}.
210     */
211    private static final float[] ZOOM_SCALE_ARRAY = new float[] {0.8f, 0.9f, 1.0f, 1.2f, 1.5f};
212
213    /**
214     * Encapsulates known information about a single attachment.
215     *
216     * TODO: This should have methods to encapsulate the entire state graph of loading, canceling,
217     * viewing, and saving.
218     */
219    private static class AttachmentInfo {
220        public String name;
221        public String contentType;
222        public long size;
223        public long attachmentId;
224        public Button viewButton;
225        public Button saveButton;
226        public Button loadButton;
227        public Button cancelButton;
228        public ImageView iconView;
229        public ProgressBar progressView;
230        public boolean allowView;
231        public boolean allowSave;
232        public boolean isLoaded;
233    }
234
235    public interface Callback {
236        /** Called when the fragment is about to show up, or show a different message. */
237        public void onMessageViewShown(int mailboxType);
238
239        /** Called when the fragment is about to be destroyed. */
240        public void onMessageViewGone();
241
242        /**
243         * Called when a link in a message is clicked.
244         *
245         * @param url link url that's clicked.
246         * @return true if handled, false otherwise.
247         */
248        public boolean onUrlInMessageClicked(String url);
249
250        /**
251         * Called when the message specified doesn't exist, or is deleted/moved.
252         */
253        public void onMessageNotExists();
254
255        /** Called when it starts loading a message. */
256        public void onLoadMessageStarted();
257
258        /** Called when it successfully finishes loading a message. */
259        public void onLoadMessageFinished();
260
261        /** Called when an error occurred during loading a message. */
262        public void onLoadMessageError(String errorMessage);
263    }
264
265    public static class EmptyCallback implements Callback {
266        public static final Callback INSTANCE = new EmptyCallback();
267        @Override public void onMessageViewShown(int mailboxType) {}
268        @Override public void onMessageViewGone() {}
269        @Override public void onLoadMessageError(String errorMessage) {}
270        @Override public void onLoadMessageFinished() {}
271        @Override public void onLoadMessageStarted() {}
272        @Override public void onMessageNotExists() {}
273        @Override
274        public boolean onUrlInMessageClicked(String url) {
275            return false;
276        }
277    }
278
279    private Callback mCallback = EmptyCallback.INSTANCE;
280
281    @Override
282    public void onCreate(Bundle savedInstanceState) {
283        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
284            Log.d(Email.LOG_TAG, "MessageViewFragment onCreate");
285        }
286        super.onCreate(savedInstanceState);
287
288        mContext = getActivity().getApplicationContext();
289
290        mControllerCallback = new ControllerResultUiThreadWrapper<ControllerResults>(
291                new Handler(), new ControllerResults());
292
293        mController = Controller.getInstance(mContext);
294        mMessageObserver = new MessageObserver(new Handler(), mContext);
295    }
296
297    @Override
298    public View onCreateView(
299            LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
300        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
301            Log.d(Email.LOG_TAG, "MessageViewFragment onCreateView");
302        }
303        final View view = inflater.inflate(R.layout.message_view_fragment, container, false);
304
305        mSubjectView = (TextView) view.findViewById(R.id.subject);
306        mFromNameView = (TextView) view.findViewById(R.id.from_name);
307        mFromAddressView = (TextView) view.findViewById(R.id.from_address);
308        mAddressesView = (TextView) view.findViewById(R.id.addresses);
309        mDateTimeView = (TextView) view.findViewById(R.id.datetime);
310        mMessageContentView = (WebView) view.findViewById(R.id.message_content);
311        mAttachments = (LinearLayout) view.findViewById(R.id.attachments);
312        mTabSection = view.findViewById(R.id.message_tabs_section);
313        mFromBadge = (ImageView) view.findViewById(R.id.badge);
314        mSenderPresenceView = (ImageView) view.findViewById(R.id.presence);
315        mMainView = view.findViewById(R.id.main_panel);
316        mLoadingProgress = view.findViewById(R.id.loading_progress);
317        mShowDetailsButton = (Button) view.findViewById(R.id.show_details);
318
319        mFromNameView.setOnClickListener(this);
320        mFromAddressView.setOnClickListener(this);
321        mFromBadge.setOnClickListener(this);
322        mSenderPresenceView.setOnClickListener(this);
323
324        mMessageTab = (TextView) view.findViewById(R.id.show_message);
325        mAttachmentTab = (TextView) view.findViewById(R.id.show_attachments);
326        mShowPicturesTab = (TextView) view.findViewById(R.id.show_pictures);
327        // Invite is only used in MessageViewFragment, but visibility is controlled here.
328        mInviteTab = (TextView) view.findViewById(R.id.show_invite);
329
330        mMessageTab.setOnClickListener(this);
331        mAttachmentTab.setOnClickListener(this);
332        mShowPicturesTab.setOnClickListener(this);
333        mInviteTab.setOnClickListener(this);
334        mShowDetailsButton.setOnClickListener(this);
335
336        mAttachmentsScroll = view.findViewById(R.id.attachments_scroll);
337        mInviteScroll = view.findViewById(R.id.invite_scroll);
338
339        WebSettings webSettings = mMessageContentView.getSettings();
340        webSettings.setBlockNetworkLoads(true);
341        webSettings.setSupportZoom(true);
342        webSettings.setBuiltInZoomControls(true);
343        mMessageContentView.setWebViewClient(new CustomWebViewClient());
344        return view;
345    }
346
347    @Override
348    public void onActivityCreated(Bundle savedInstanceState) {
349        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
350            Log.d(Email.LOG_TAG, "MessageViewFragment onActivityCreated");
351        }
352        super.onActivityCreated(savedInstanceState);
353        mController.addResultCallback(mControllerCallback);
354    }
355
356    @Override
357    public void onStart() {
358        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
359            Log.d(Email.LOG_TAG, "MessageViewFragment onStart");
360        }
361        super.onStart();
362    }
363
364    @Override
365    public void onResume() {
366        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
367            Log.d(Email.LOG_TAG, "MessageViewFragment onResume");
368        }
369        super.onResume();
370
371        mResumed = true;
372        if (isMessageSpecified()) {
373            if (mLoadWhenResumed) {
374                loadMessageIfResumed();
375            } else {
376                // This means, the user comes back from other (full-screen) activities.
377                // In this case we've already loaded the content, so don't load it again,
378                // which results in resetting all view state, including WebView zoom/pan
379                // and the current tab.
380            }
381        }
382    }
383
384    @Override
385    public void onPause() {
386        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
387            Log.d(Email.LOG_TAG, "MessageViewFragment onPause");
388        }
389        mResumed = false;
390        super.onPause();
391    }
392
393    @Override
394    public void onStop() {
395        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
396            Log.d(Email.LOG_TAG, "MessageViewFragment onStop");
397        }
398        super.onStop();
399    }
400
401    @Override
402    public void onDestroy() {
403        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
404            Log.d(Email.LOG_TAG, "MessageViewFragment onDestroy");
405        }
406        mCallback.onMessageViewGone();
407        mController.removeResultCallback(mControllerCallback);
408        clearContent();
409        mMessageContentView.destroy();
410        mMessageContentView = null;
411        super.onDestroy();
412    }
413
414    @Override
415    public void onSaveInstanceState(Bundle outState) {
416        if (Email.DEBUG_LIFECYCLE && Email.DEBUG) {
417            Log.d(Email.LOG_TAG, "MessageViewFragment onSaveInstanceState");
418        }
419        super.onSaveInstanceState(outState);
420    }
421
422    public void setCallback(Callback callback) {
423        mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback;
424    }
425
426    private void cancelAllTasks() {
427        mMessageObserver.unregister();
428        Utility.cancelTaskInterrupt(mLoadMessageTask);
429        mLoadMessageTask = null;
430        Utility.cancelTaskInterrupt(mReloadMessageTask);
431        mReloadMessageTask = null;
432        Utility.cancelTaskInterrupt(mLoadBodyTask);
433        mLoadBodyTask = null;
434        Utility.cancelTaskInterrupt(mLoadAttachmentsTask);
435        mLoadAttachmentsTask = null;
436    }
437
438    /**
439     * Subclass returns true if which message to open is already specified by the activity.
440     */
441    protected abstract boolean isMessageSpecified();
442
443    protected final Controller getController() {
444        return mController;
445    }
446
447    protected final Callback getCallback() {
448        return mCallback;
449    }
450
451    protected final Message getMessage() {
452        return mMessage;
453    }
454
455    protected final boolean isMessageOpen() {
456        return mMessage != null;
457    }
458
459    /**
460     * Returns the account id of the current message, or -1 if unknown (message not open yet, or
461     * viewing an EML message).
462     */
463    public long getAccountId() {
464        return mAccountId;
465    }
466
467    /**
468     * Clear all the content -- should be called when the fragment is hidden.
469     */
470    public void clearContent() {
471        cancelAllTasks();
472        resetView();
473    }
474
475    protected final void loadMessageIfResumed() {
476        if (!mResumed) {
477            mLoadWhenResumed = true;
478            return;
479        }
480        mLoadWhenResumed = false;
481        cancelAllTasks();
482        resetView();
483        mLoadMessageTask = new LoadMessageTask(true);
484        mLoadMessageTask.execute();
485    }
486
487    /**
488     * Show/hide the content.  We hide all the content (except for the bottom buttons) when loading,
489     * to avoid flicker.
490     */
491    private void showContent(boolean showContent, boolean showProgressWhenHidden) {
492        if (mLoadingProgress == null) {
493            // Phone UI doesn't have it yet.
494            // TODO Add loading_progress and main_panel to the phone layout too.
495        } else {
496            makeVisible(mMainView, showContent);
497            makeVisible(mLoadingProgress, !showContent && showProgressWhenHidden);
498        }
499    }
500
501    protected void resetView() {
502        showContent(false, false);
503        setCurrentTab(TAB_MESSAGE);
504        updateTabFlags(0);
505        if (mMessageContentView != null) {
506            mMessageContentView.getSettings().setBlockNetworkLoads(true);
507            mMessageContentView.scrollTo(0, 0);
508            mMessageContentView.clearView();
509
510            // Dynamic configuration of WebView
511            final WebSettings settings = mMessageContentView.getSettings();
512            settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
513            mMessageContentView.setInitialScale(getWebViewZoom());
514        }
515        mAttachmentsScroll.scrollTo(0, 0);
516        mInviteScroll.scrollTo(0, 0);
517        mAttachments.removeAllViews();
518        mAttachments.setVisibility(View.GONE);
519        initContactStatusViews();
520    }
521
522    /**
523     * Returns the zoom scale (in percent) which is a combination of the user setting
524     * (tiny, small, normal, large, huge) and the device density. The intention
525     * is for the text to be physically equal in size over different density
526     * screens.
527     */
528    private int getWebViewZoom() {
529        float density = mContext.getResources().getDisplayMetrics().density;
530        int zoom = Preferences.getPreferences(mContext).getTextZoom();
531        return (int) (ZOOM_SCALE_ARRAY[zoom] * density * 100);
532    }
533
534    private void initContactStatusViews() {
535        mContactStatusState = CONTACT_STATUS_STATE_UNLOADED;
536        mQuickContactLookupUri = null;
537        mSenderPresenceView.setImageResource(ContactStatusLoader.PRESENCE_UNKNOWN_RESOURCE_ID);
538        showDefaultQuickContactBadgeImage();
539    }
540
541    private void showDefaultQuickContactBadgeImage() {
542        mFromBadge.setImageResource(R.drawable.ic_contact_picture);
543    }
544
545    protected final void addTabFlags(int tabFlags) {
546        updateTabFlags(mTabFlags | tabFlags);
547    }
548
549    private final void clearTabFlags(int tabFlags) {
550        updateTabFlags(mTabFlags & ~tabFlags);
551    }
552
553    private void setAttachmentCount(int count) {
554        mAttachmentCount = count;
555        if (mAttachmentCount > 0) {
556            addTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
557        } else {
558            clearTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
559        }
560    }
561
562    private static void makeVisible(View v, boolean visible) {
563        v.setVisibility(visible ? View.VISIBLE : View.GONE);
564    }
565
566    /**
567     * Update the visual of the tabs.  (visibility, text, etc)
568     */
569    private void updateTabFlags(int tabFlags) {
570        mTabFlags = tabFlags;
571        mTabSection.setVisibility(tabFlags == 0 ? View.GONE : View.VISIBLE);
572        if (tabFlags == 0) {
573            return;
574        }
575        boolean messageTabVisible = (tabFlags & (TAB_FLAGS_HAS_INVITE | TAB_FLAGS_HAS_ATTACHMENT))
576                != 0;
577        makeVisible(mMessageTab, messageTabVisible);
578        makeVisible(mInviteTab, (tabFlags & TAB_FLAGS_HAS_INVITE) != 0);
579        makeVisible(mAttachmentTab, (tabFlags & TAB_FLAGS_HAS_ATTACHMENT) != 0);
580
581        final boolean hasPictures = (tabFlags & TAB_FLAGS_HAS_PICTURES) != 0;
582        final boolean pictureLoaded = (tabFlags & TAB_FLAGS_PICTURE_LOADED) != 0;
583        makeVisible(mShowPicturesTab, hasPictures && !pictureLoaded);
584
585        mAttachmentTab.setText(mContext.getResources().getQuantityString(
586                R.plurals.message_view_show_attachments_action,
587                mAttachmentCount, mAttachmentCount));
588    }
589
590    /**
591     * Set the current tab.
592     *
593     * @param tab any of {@link #TAB_MESSAGE}, {@link #TAB_ATTACHMENT} or {@link #TAB_INVITE}.
594     */
595    private void setCurrentTab(int tab) {
596        mCurrentTab = tab;
597        if (mMessageContentView != null) {
598            makeVisible(mMessageContentView, tab == TAB_MESSAGE);
599        }
600        mMessageTab.setSelected(tab == TAB_MESSAGE);
601
602        makeVisible(mAttachmentsScroll, tab == TAB_ATTACHMENT);
603        mAttachmentTab.setSelected(tab == TAB_ATTACHMENT);
604
605        makeVisible(mInviteScroll, tab == TAB_INVITE);
606        mInviteTab.setSelected(tab == TAB_INVITE);
607    }
608
609    /**
610     * Handle clicks on sender, which shows {@link QuickContact} or prompts to add
611     * the sender as a contact.
612     */
613    private void onClickSender() {
614        final Address senderEmail = Address.unpackFirst(mMessage.mFrom);
615        if (senderEmail == null) return;
616
617        if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED) {
618            // Status not loaded yet.
619            mContactStatusState = CONTACT_STATUS_STATE_UNLOADED_TRIGGERED;
620            return;
621        }
622        if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED) {
623            return; // Already clicked, and waiting for the data.
624        }
625
626        if (mQuickContactLookupUri != null) {
627            QuickContact.showQuickContact(mContext, mFromBadge, mQuickContactLookupUri,
628                        QuickContact.MODE_LARGE, null);
629        } else {
630            // No matching contact, ask user to create one
631            final Uri mailUri = Uri.fromParts("mailto", senderEmail.getAddress(), null);
632            final Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT,
633                    mailUri);
634
635            // Pass along full E-mail string for possible create dialog
636            intent.putExtra(ContactsContract.Intents.EXTRA_CREATE_DESCRIPTION,
637                    senderEmail.toString());
638
639            // Only provide personal name hint if we have one
640            final String senderPersonal = senderEmail.getPersonal();
641            if (!TextUtils.isEmpty(senderPersonal)) {
642                intent.putExtra(ContactsContract.Intents.Insert.NAME, senderPersonal);
643            }
644            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
645
646            startActivity(intent);
647        }
648    }
649
650    private static class ContactStatusLoaderCallbacks
651            implements LoaderCallbacks<ContactStatusLoader.Result> {
652        private static final String BUNDLE_EMAIL_ADDRESS = "email";
653        private final MessageViewFragmentBase mFragment;
654
655        public ContactStatusLoaderCallbacks(MessageViewFragmentBase fragment) {
656            mFragment = fragment;
657        }
658
659        public static Bundle createArguments(String emailAddress) {
660            Bundle b = new Bundle();
661            b.putString(BUNDLE_EMAIL_ADDRESS, emailAddress);
662            return b;
663        }
664
665        @Override
666        public Loader<ContactStatusLoader.Result> onCreateLoader(int id, Bundle args) {
667            return new ContactStatusLoader(mFragment.mContext,
668                    args.getString(BUNDLE_EMAIL_ADDRESS));
669        }
670
671        @Override
672        public void onLoadFinished(Loader<ContactStatusLoader.Result> loader,
673                ContactStatusLoader.Result result) {
674            boolean triggered =
675                    (mFragment.mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED);
676            mFragment.mContactStatusState = CONTACT_STATUS_STATE_LOADED;
677            mFragment.mQuickContactLookupUri = result.mLookupUri;
678            mFragment.mSenderPresenceView.setImageResource(result.mPresenceResId);
679            if (result.mPhoto != null) { // photo will be null if unknown.
680                mFragment.mFromBadge.setImageBitmap(result.mPhoto);
681            }
682            if (triggered) {
683                mFragment.onClickSender();
684            }
685        }
686
687        @Override
688        public void onLoaderReset(Loader<ContactStatusLoader.Result> loader) {
689        }
690    }
691
692    private void onSaveAttachment(AttachmentInfo info) {
693        if (!Utility.isExternalStorageMounted()) {
694            /*
695             * Abort early if there's no place to save the attachment. We don't want to spend
696             * the time downloading it and then abort.
697             */
698            Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
699            return;
700        }
701        Attachment attachment = Attachment.restoreAttachmentWithId(mContext, info.attachmentId);
702        Uri attachmentUri = AttachmentProvider.getAttachmentUri(mAccountId, attachment.mId);
703
704        try {
705            File downloads = Environment.getExternalStoragePublicDirectory(
706                    Environment.DIRECTORY_DOWNLOADS);
707            downloads.mkdirs();
708            File file = Utility.createUniqueFile(downloads, attachment.mFileName);
709            Uri contentUri = AttachmentProvider.resolveAttachmentIdToContentUri(
710                    mContext.getContentResolver(), attachmentUri);
711            InputStream in = mContext.getContentResolver().openInputStream(contentUri);
712            OutputStream out = new FileOutputStream(file);
713            IOUtils.copy(in, out);
714            out.flush();
715            out.close();
716            in.close();
717
718            Utility.showToast(getActivity(), String.format(
719                    mContext.getString(R.string.message_view_status_attachment_saved),
720                    file.getName()));
721            MediaOpener.scanAndOpen(getActivity(), file);
722        } catch (IOException ioe) {
723            Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
724        }
725    }
726
727    private void onViewAttachment(AttachmentInfo info) {
728        Intent intent = getAttachmentIntent(info);
729        try {
730            startActivity(intent);
731        } catch (ActivityNotFoundException e) {
732            Utility.showToast(getActivity(), R.string.message_view_display_attachment_toast);
733        }
734    }
735
736    /**
737     * Returns an <code>Intent</code> to load the given attachment.
738     */
739    private Intent getAttachmentIntent(AttachmentInfo info) {
740        Uri attachmentUri = AttachmentProvider.getAttachmentUri(mAccountId, info.attachmentId);
741        Uri contentUri = AttachmentProvider.resolveAttachmentIdToContentUri(
742                mContext.getContentResolver(), attachmentUri);
743        Intent intent = new Intent(Intent.ACTION_VIEW);
744        intent.setData(contentUri);
745        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
746                        | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
747        return intent;
748    }
749
750    private void onLoadAttachment(final AttachmentInfo attachment) {
751        attachment.loadButton.setVisibility(View.GONE);
752        // If there's nothing in the download queue, we'll probably start right away so wait a
753        // second before showing the cancel button
754        if (AttachmentDownloadService.getQueueSize() == 0) {
755            // Set to invisible; if the button is still in this state one second from now, we'll
756            // assume the download won't start right away, and we make the cancel button visible
757            attachment.cancelButton.setVisibility(View.GONE);
758            // Create the timed task that will change the button state
759            new AsyncTask<Void, Void, Void>() {
760                @Override
761                protected Void doInBackground(Void... params) {
762                    try {
763                        Thread.sleep(1000L);
764                    } catch (InterruptedException e) { }
765                    return null;
766                }
767                @Override
768                protected void onPostExecute(Void result) {
769                    // If the timeout completes and the attachment has not loaded, show cancel
770                    if (!attachment.isLoaded) {
771                        attachment.cancelButton.setVisibility(View.VISIBLE);
772                    }
773                }
774            }.execute();
775        } else {
776            attachment.cancelButton.setVisibility(View.VISIBLE);
777        }
778        ProgressBar bar = attachment.progressView;
779        bar.setVisibility(View.VISIBLE);
780        bar.setIndeterminate(true);
781        mController.loadAttachment(attachment.attachmentId, mMessageId, mAccountId);
782    }
783
784    private void onCancelAttachment(AttachmentInfo attachment) {
785        // Don't change button states if we couldn't cancel the download
786        if (AttachmentDownloadService.cancelQueuedAttachment(attachment.attachmentId)) {
787            attachment.loadButton.setVisibility(View.VISIBLE);
788            attachment.cancelButton.setVisibility(View.GONE);
789            ProgressBar bar = attachment.progressView;
790            bar.setVisibility(View.INVISIBLE);
791        }
792    }
793
794    /**
795     * Called by ControllerResults. Show the "View" and "Save" buttons; hide "Load" and "Stop"
796     *
797     * @param attachmentId the attachment that was just downloaded
798     */
799    private void doFinishLoadAttachment(long attachmentId) {
800        AttachmentInfo info = findAttachmentInfo(attachmentId);
801        if (info != null) {
802            info.isLoaded = true;
803
804            info.loadButton.setVisibility(View.GONE);
805            info.cancelButton.setVisibility(View.GONE);
806
807            boolean showSave = info.allowSave && !TextUtils.isEmpty(info.name);
808            boolean showView = info.allowView;
809            info.saveButton.setVisibility(showSave ? View.VISIBLE : View.GONE);
810            info.viewButton.setVisibility(showView ? View.VISIBLE : View.GONE);
811        }
812    }
813
814    private void onShowPicturesInHtml() {
815        if (mMessageContentView != null) {
816            mMessageContentView.getSettings().setBlockNetworkLoads(false);
817            if (mHtmlTextWebView != null) {
818                mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView,
819                                                        "text/html", "utf-8", null);
820            }
821            addTabFlags(TAB_FLAGS_PICTURE_LOADED);
822        }
823    }
824
825    private void onShowDetails() {
826        if (mMessage == null) {
827            return; // shouldn't happen
828        }
829        String subject = mMessage.mSubject;
830        String date = formatDate(mMessage.mTimeStamp, true);
831        String from = Address.toString(Address.unpack(mMessage.mFrom));
832        String to = Address.toString(Address.unpack(mMessage.mTo));
833        String cc = Address.toString(Address.unpack(mMessage.mCc));
834        String bcc = Address.toString(Address.unpack(mMessage.mBcc));
835        MessageViewMessageDetailsDialog dialog = MessageViewMessageDetailsDialog.newInstance(
836                getActivity(), subject, date, from, to, cc, bcc);
837        dialog.show(getActivity().getFragmentManager(), null);
838    }
839
840    @Override
841    public void onClick(View view) {
842        if (!isMessageOpen()) {
843            return; // Ignore.
844        }
845        switch (view.getId()) {
846            case R.id.from_name:
847            case R.id.from_address:
848            case R.id.badge:
849            case R.id.presence:
850                onClickSender();
851                break;
852            case R.id.load:
853                onLoadAttachment((AttachmentInfo) view.getTag());
854                break;
855            case R.id.save:
856                onSaveAttachment((AttachmentInfo) view.getTag());
857                break;
858            case R.id.view:
859                onViewAttachment((AttachmentInfo) view.getTag());
860                break;
861            case R.id.cancel:
862                onCancelAttachment((AttachmentInfo) view.getTag());
863                break;
864            case R.id.show_message:
865                setCurrentTab(TAB_MESSAGE);
866                break;
867            case R.id.show_invite:
868                setCurrentTab(TAB_INVITE);
869                break;
870            case R.id.show_attachments:
871                setCurrentTab(TAB_ATTACHMENT);
872                break;
873            case R.id.show_pictures:
874                onShowPicturesInHtml();
875                break;
876            case R.id.show_details:
877                onShowDetails();
878                break;
879        }
880    }
881
882    /**
883     * Start loading contact photo and presence.
884     */
885    private void queryContactStatus() {
886        initContactStatusViews(); // Initialize the state, just in case.
887
888        // Find the sender email address, and start presence check.
889        if (mMessage != null) {
890            Address sender = Address.unpackFirst(mMessage.mFrom);
891            if (sender != null) {
892                String email = sender.getAddress();
893                if (email != null) {
894                    getLoaderManager().restartLoader(PHOTO_LOADER_ID,
895                            ContactStatusLoaderCallbacks.createArguments(email),
896                            new ContactStatusLoaderCallbacks(this));
897                }
898            }
899        }
900    }
901
902    /**
903     * Called by {@link LoadMessageTask} and {@link ReloadMessageTask} to load a message in a
904     * subclass specific way.
905     *
906     * NOTE This method is called on a worker thread!  Implementations must properly synchronize
907     * when accessing members.  This method may be called after or even at the same time as
908     * {@link #clearContent()}.
909     *
910     * @param activity the parent activity.  Subclass use it as a context, and to show a toast.
911     */
912    protected abstract Message openMessageSync(Activity activity);
913
914    /**
915     * Async task for loading a single message outside of the UI thread
916     */
917    private class LoadMessageTask extends AsyncTask<Void, Void, Message> {
918
919        private final boolean mOkToFetch;
920        private int mMailboxType;
921
922        /**
923         * Special constructor to cache some local info
924         */
925        public LoadMessageTask(boolean okToFetch) {
926            mOkToFetch = okToFetch;
927        }
928
929        @Override
930        protected Message doInBackground(Void... params) {
931            Activity activity = getActivity();
932            Message message = null;
933            if (activity != null) {
934                message = openMessageSync(activity);
935            }
936            if (message != null) {
937                mMailboxType = Mailbox.getMailboxType(mContext, message.mMailboxKey);
938                if (mMailboxType == -1) {
939                    message = null; // mailbox removed??
940                }
941            }
942            return message;
943        }
944
945        @Override
946        protected void onPostExecute(Message message) {
947            if (isCancelled()) {
948                return;
949            }
950            if (message == null) {
951                resetView();
952                mCallback.onMessageNotExists();
953                return;
954            }
955            mMessageId = message.mId;
956
957            reloadUiFromMessage(message, mOkToFetch);
958            queryContactStatus();
959            onMessageShown(mMessageId, mMailboxType);
960        }
961    }
962
963    /**
964     * Kicked by {@link MessageObserver}.  Reload the message and update the views.
965     */
966    private class ReloadMessageTask extends AsyncTask<Void, Void, Message> {
967        @Override
968        protected Message doInBackground(Void... params) {
969            if (!isMessageSpecified()) { // just in case
970                return null;
971            }
972            Activity activity = getActivity();
973            if (activity == null) {
974                return null;
975            } else {
976                return openMessageSync(activity);
977            }
978        }
979
980        @Override
981        protected void onPostExecute(Message message) {
982            if (isCancelled()) {
983                return;
984            }
985            if (message == null || message.mMailboxKey != mMessage.mMailboxKey) {
986                // Message deleted or moved.
987                mCallback.onMessageNotExists();
988                return;
989            }
990            mMessage = message;
991            updateHeaderView(mMessage);
992        }
993    }
994
995    /**
996     * Called when a message is shown to the user.
997     */
998    protected void onMessageShown(long messageId, int mailboxType) {
999        mCallback.onMessageViewShown(mailboxType);
1000    }
1001
1002    /**
1003     * Called when the message body is loaded.
1004     */
1005    protected void onPostLoadBody() {
1006    }
1007
1008    /**
1009     * Async task for loading a single message body outside of the UI thread
1010     */
1011    private class LoadBodyTask extends AsyncTask<Void, Void, String[]> {
1012
1013        private long mId;
1014        private boolean mErrorLoadingMessageBody;
1015
1016        /**
1017         * Special constructor to cache some local info
1018         */
1019        public LoadBodyTask(long messageId) {
1020            mId = messageId;
1021        }
1022
1023        @Override
1024        protected String[] doInBackground(Void... params) {
1025            try {
1026                String text = null;
1027                String html = Body.restoreBodyHtmlWithMessageId(mContext, mId);
1028                if (html == null) {
1029                    text = Body.restoreBodyTextWithMessageId(mContext, mId);
1030                }
1031                return new String[] { text, html };
1032            } catch (RuntimeException re) {
1033                // This catches SQLiteException as well as other RTE's we've seen from the
1034                // database calls, such as IllegalStateException
1035                Log.d(Email.LOG_TAG, "Exception while loading message body", re);
1036                mErrorLoadingMessageBody = true;
1037                return null;
1038            }
1039        }
1040
1041        @Override
1042        protected void onPostExecute(String[] results) {
1043            if (results == null || isCancelled()) {
1044                if (mErrorLoadingMessageBody) {
1045                    Utility.showToast(getActivity(), R.string.error_loading_message_body);
1046                }
1047                resetView();
1048                return;
1049            }
1050            reloadUiFromBody(results[0], results[1]);    // text, html
1051            onPostLoadBody();
1052        }
1053    }
1054
1055    /**
1056     * Async task for loading attachments
1057     *
1058     * Note:  This really should only be called when the message load is complete - or, we should
1059     * leave open a listener so the attachments can fill in as they are discovered.  In either case,
1060     * this implementation is incomplete, as it will fail to refresh properly if the message is
1061     * partially loaded at this time.
1062     */
1063    private class LoadAttachmentsTask extends AsyncTask<Long, Void, Attachment[]> {
1064        @Override
1065        protected Attachment[] doInBackground(Long... messageIds) {
1066            return Attachment.restoreAttachmentsWithMessageId(mContext, messageIds[0]);
1067        }
1068
1069        @Override
1070        protected void onPostExecute(Attachment[] attachments) {
1071            try {
1072                if (isCancelled() || attachments == null) {
1073                    return;
1074                }
1075                boolean htmlChanged = false;
1076                int numDisplayedAttachments = 0;
1077                for (Attachment attachment : attachments) {
1078                    if (mHtmlTextRaw != null && attachment.mContentId != null
1079                            && attachment.mContentUri != null) {
1080                        // for html body, replace CID for inline images
1081                        // Regexp which matches ' src="cid:contentId"'.
1082                        String contentIdRe =
1083                            "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\"";
1084                        String srcContentUri = " src=\"" + attachment.mContentUri + "\"";
1085                        mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri);
1086                        htmlChanged = true;
1087                    } else {
1088                        addAttachment(attachment);
1089                        numDisplayedAttachments++;
1090                    }
1091                }
1092                setAttachmentCount(numDisplayedAttachments);
1093                mHtmlTextWebView = mHtmlTextRaw;
1094                mHtmlTextRaw = null;
1095                if (htmlChanged && mMessageContentView != null) {
1096                    mMessageContentView.loadDataWithBaseURL("email://", mHtmlTextWebView,
1097                                                            "text/html", "utf-8", null);
1098                }
1099            } finally {
1100                showContent(true, false);
1101            }
1102        }
1103    }
1104
1105    private Bitmap getPreviewIcon(AttachmentInfo attachment) {
1106        try {
1107            return BitmapFactory.decodeStream(
1108                    mContext.getContentResolver().openInputStream(
1109                            AttachmentProvider.getAttachmentThumbnailUri(
1110                                    mAccountId, attachment.attachmentId,
1111                                    PREVIEW_ICON_WIDTH,
1112                                    PREVIEW_ICON_HEIGHT)));
1113        } catch (Exception e) {
1114            Log.d(Email.LOG_TAG, "Attachment preview failed with exception " + e.getMessage());
1115            return null;
1116        }
1117    }
1118
1119    private void updateAttachmentThumbnail(long attachmentId) {
1120        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1121            AttachmentInfo attachment = (AttachmentInfo) mAttachments.getChildAt(i).getTag();
1122            if (attachment.attachmentId == attachmentId) {
1123                Bitmap previewIcon = getPreviewIcon(attachment);
1124                if (previewIcon != null) {
1125                    attachment.iconView.setImageBitmap(previewIcon);
1126                }
1127                return;
1128            }
1129        }
1130    }
1131
1132    /**
1133     * Copy data from a cursor-refreshed attachment into the UI.  Called from UI thread.
1134     *
1135     * @param attachment A single attachment loaded from the provider
1136     */
1137    private void addAttachment(Attachment attachment) {
1138        AttachmentInfo attachmentInfo = new AttachmentInfo();
1139        attachmentInfo.size = attachment.mSize;
1140        attachmentInfo.contentType =
1141                AttachmentProvider.inferMimeType(attachment.mFileName, attachment.mMimeType);
1142        attachmentInfo.name = attachment.mFileName;
1143        attachmentInfo.attachmentId = attachment.mId;
1144        attachmentInfo.allowView = true;
1145        attachmentInfo.allowSave = true;
1146        attachmentInfo.isLoaded = false;
1147
1148        LayoutInflater inflater = getActivity().getLayoutInflater();
1149        View view = inflater.inflate(R.layout.message_view_attachment, null);
1150
1151        TextView attachmentName = (TextView)view.findViewById(R.id.attachment_name);
1152        TextView attachmentInfoView = (TextView)view.findViewById(R.id.attachment_info);
1153        ImageView attachmentIcon = (ImageView)view.findViewById(R.id.attachment_icon);
1154        Button attachmentView = (Button)view.findViewById(R.id.view);
1155        Button attachmentSave = (Button)view.findViewById(R.id.save);
1156        Button attachmentLoad = (Button)view.findViewById(R.id.load);
1157        Button attachmentCancel = (Button)view.findViewById(R.id.cancel);
1158        ProgressBar attachmentProgress = (ProgressBar)view.findViewById(R.id.progress);
1159
1160        // Check for acceptable / unacceptable attachments by MIME-type
1161        String contentType = attachmentInfo.contentType;
1162        if ((!MimeUtility.mimeTypeMatches(contentType, Email.ACCEPTABLE_ATTACHMENT_VIEW_TYPES)) ||
1163            (MimeUtility.mimeTypeMatches(contentType, Email.UNACCEPTABLE_ATTACHMENT_VIEW_TYPES))) {
1164            attachmentInfo.allowView = false;
1165        }
1166
1167        // Check for unacceptable attachments by filename extension; hide both buttons
1168        String extension = AttachmentProvider.getFilenameExtension(attachmentInfo.name);
1169        if (!TextUtils.isEmpty(extension) &&
1170                Utility.arrayContains(Email.UNACCEPTABLE_ATTACHMENT_EXTENSIONS, extension)) {
1171            attachmentInfo.allowView = false;
1172            attachmentInfo.allowSave = false;
1173        }
1174
1175        // File size exceeded;  Hide both buttons
1176        // The size limit is overridden when on a wifi connection - any size is OK
1177        if (attachmentInfo.size > Email.MAX_ATTACHMENT_DOWNLOAD_SIZE) {
1178            ConnectivityManager cm = (ConnectivityManager)
1179                    mContext.getSystemService(Context.CONNECTIVITY_SERVICE);
1180            NetworkInfo network = cm.getActiveNetworkInfo();
1181            if (network == null || network.getType() != ConnectivityManager.TYPE_WIFI) {
1182                attachmentInfo.allowView = false;
1183                attachmentInfo.allowSave = false;
1184            }
1185        }
1186
1187        // No activity to view the attachment; Hide both buttons
1188        if (!isAttachmentViewerInstalled(attachmentInfo)) {
1189            attachmentInfo.allowView = false;
1190            attachmentInfo.allowSave = false;
1191        }
1192
1193        // Don't enable the "save" button if we've got no place to save the file
1194        if (!Utility.isExternalStorageMounted()) {
1195            attachmentInfo.allowSave = false;
1196        }
1197
1198        if (!attachmentInfo.allowView) {
1199            attachmentView.setVisibility(View.GONE);
1200        }
1201        if (!attachmentInfo.allowSave) {
1202            attachmentSave.setVisibility(View.GONE);
1203        }
1204
1205        attachmentInfo.viewButton = attachmentView;
1206        attachmentInfo.saveButton = attachmentSave;
1207        attachmentInfo.loadButton = attachmentLoad;
1208        attachmentInfo.cancelButton = attachmentCancel;
1209        attachmentInfo.iconView = attachmentIcon;
1210        attachmentInfo.progressView = attachmentProgress;
1211
1212        if (!attachmentInfo.allowView && !attachmentInfo.allowSave) {
1213            // This attachment may never be viewed or saved, so block everything
1214            attachmentProgress.setVisibility(View.GONE);
1215            attachmentView.setVisibility(View.GONE);
1216            attachmentSave.setVisibility(View.GONE);
1217            attachmentLoad.setVisibility(View.GONE);
1218            attachmentCancel.setVisibility(View.GONE);
1219            // TODO: Maybe show a little icon to denote blocked download
1220        } else if (Utility.attachmentExists(mContext, attachment)) {
1221            // If the attachment is loaded, show 100% progress
1222            // Note that for POP3 messages, the user will only see "Open" and "Save",
1223            // because the entire message is loaded before being shown.
1224            attachmentInfo.isLoaded = true;
1225
1226            // Hide "Load", show "View" and "Save"
1227            attachmentProgress.setVisibility(View.VISIBLE);
1228            attachmentProgress.setProgress(100);
1229            if (attachmentInfo.allowSave) {
1230                attachmentSave.setVisibility(View.VISIBLE);
1231            }
1232            if (attachmentInfo.allowView) {
1233                attachmentView.setVisibility(View.VISIBLE);
1234            }
1235            attachmentLoad.setVisibility(View.GONE);
1236            attachmentCancel.setVisibility(View.GONE);
1237
1238            Bitmap previewIcon = getPreviewIcon(attachmentInfo);
1239            if (previewIcon != null) {
1240                attachmentIcon.setImageBitmap(previewIcon);
1241            }
1242        } else {
1243            // The attachment is not loaded, so present UI to start downloading it
1244
1245            // Show "Load"; hide "View" and "Save"
1246            attachmentSave.setVisibility(View.GONE);
1247            attachmentView.setVisibility(View.GONE);
1248
1249            // If the attachment is queued, show the indeterminate progress bar.  From this point,.
1250            // any progress changes will cause this to be replaced by the normal progress bar
1251            if (AttachmentDownloadService.isAttachmentQueued(attachment.mId)){
1252                attachmentProgress.setVisibility(View.VISIBLE);
1253                attachmentProgress.setIndeterminate(true);
1254                attachmentLoad.setVisibility(View.GONE);
1255                attachmentCancel.setVisibility(View.VISIBLE);
1256            } else {
1257                attachmentLoad.setVisibility(View.VISIBLE);
1258                attachmentCancel.setVisibility(View.GONE);
1259            }
1260        }
1261
1262        view.setTag(attachmentInfo);
1263        attachmentView.setOnClickListener(this);
1264        attachmentView.setTag(attachmentInfo);
1265        attachmentSave.setOnClickListener(this);
1266        attachmentSave.setTag(attachmentInfo);
1267        attachmentLoad.setOnClickListener(this);
1268        attachmentLoad.setTag(attachmentInfo);
1269        attachmentCancel.setOnClickListener(this);
1270        attachmentCancel.setTag(attachmentInfo);
1271
1272        attachmentName.setText(attachmentInfo.name);
1273        attachmentInfoView.setText(Utility.formatSize(mContext, attachmentInfo.size));
1274
1275        mAttachments.addView(view);
1276        mAttachments.setVisibility(View.VISIBLE);
1277    }
1278
1279    /**
1280     * Returns whether or not there is an Activity installed that can handle the given attachment.
1281     */
1282    private boolean isAttachmentViewerInstalled(AttachmentInfo info) {
1283        Intent intent = getAttachmentIntent(info);
1284        PackageManager pm = mContext.getPackageManager();
1285        List<ResolveInfo> activityList = pm.queryIntentActivities(intent, 0);
1286        return (activityList.size() > 0);
1287    }
1288
1289    /**
1290     * Reload the UI from a provider cursor.  {@link LoadMessageTask#onPostExecute} calls it.
1291     *
1292     * Update the header views, and start loading the body.
1293     *
1294     * @param message A copy of the message loaded from the database
1295     * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from
1296     * the network.  Use false to prevent looping here.
1297     */
1298    protected void reloadUiFromMessage(Message message, boolean okToFetch) {
1299        mMessage = message;
1300        mAccountId = message.mAccountKey;
1301
1302        mMessageObserver.register(ContentUris.withAppendedId(Message.CONTENT_URI, mMessage.mId));
1303
1304        updateHeaderView(mMessage);
1305
1306        // Handle partially-loaded email, as follows:
1307        // 1. Check value of message.mFlagLoaded
1308        // 2. If != LOADED, ask controller to load it
1309        // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask
1310        // 4. Else start the loader tasks right away (message already loaded)
1311        if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) {
1312            mControllerCallback.getWrappee().setWaitForLoadMessageId(message.mId);
1313            mController.loadMessageForView(message.mId);
1314        } else {
1315            mControllerCallback.getWrappee().setWaitForLoadMessageId(-1);
1316            // Ask for body
1317            mLoadBodyTask = new LoadBodyTask(message.mId);
1318            mLoadBodyTask.execute();
1319        }
1320    }
1321
1322    protected void updateHeaderView(Message message) {
1323        mSubjectView.setText(message.mSubject);
1324        final Address from = Address.unpackFirst(message.mFrom);
1325
1326        // Set sender address/display name
1327        // Note we set " " for empty field, so TextView's won't get squashed.
1328        // Otherwise their height will be 0, which breaks the layout.
1329        if (from != null) {
1330            final String fromFriendly = from.toFriendly();
1331            final String fromAddress = from.getAddress();
1332            mFromNameView.setText(fromFriendly);
1333            mFromAddressView.setText(fromFriendly.equals(fromAddress) ? " " : fromAddress);
1334        } else {
1335            mFromNameView.setText(" ");
1336            mFromAddressView.setText(" ");
1337        }
1338        mDateTimeView.setText(formatDate(message.mTimeStamp, false));
1339
1340        // To/Cc/Bcc
1341        final Resources res = mContext.getResources();
1342        final SpannableStringBuilder ssb = new SpannableStringBuilder();
1343        final String friendlyTo = Address.toFriendly(Address.unpack(message.mTo));
1344        final String friendlyCc = Address.toFriendly(Address.unpack(message.mCc));
1345        final String friendlyBcc = Address.toFriendly(Address.unpack(message.mBcc));
1346
1347        if (!TextUtils.isEmpty(friendlyTo)) {
1348            Utility.appendBold(ssb, res.getString(R.string.message_view_to_label));
1349            ssb.append(" ");
1350            ssb.append(friendlyTo);
1351        }
1352        if (!TextUtils.isEmpty(friendlyCc)) {
1353            ssb.append("  ");
1354            Utility.appendBold(ssb, res.getString(R.string.message_view_cc_label));
1355            ssb.append(" ");
1356            ssb.append(friendlyCc);
1357        }
1358        if (!TextUtils.isEmpty(friendlyBcc)) {
1359            ssb.append("  ");
1360            Utility.appendBold(ssb, res.getString(R.string.message_view_bcc_label));
1361            ssb.append(" ");
1362            ssb.append(friendlyBcc);
1363        }
1364        mAddressesView.setText(ssb);
1365    }
1366
1367    private String formatDate(long millis, boolean withYear) {
1368        StringBuilder sb = new StringBuilder();
1369        Formatter formatter = new Formatter(sb);
1370        DateUtils.formatDateRange(mContext, formatter, millis, millis,
1371                DateUtils.FORMAT_SHOW_DATE
1372                | DateUtils.FORMAT_ABBREV_ALL
1373                | DateUtils.FORMAT_SHOW_TIME
1374                | (withYear ? DateUtils.FORMAT_SHOW_YEAR : DateUtils.FORMAT_NO_YEAR));
1375        return sb.toString();
1376    }
1377
1378    /**
1379     * Reload the body from the provider cursor.  This must only be called from the UI thread.
1380     *
1381     * @param bodyText text part
1382     * @param bodyHtml html part
1383     *
1384     * TODO deal with html vs text and many other issues <- WHAT DOES IT MEAN??
1385     */
1386    private void reloadUiFromBody(String bodyText, String bodyHtml) {
1387        String text = null;
1388        mHtmlTextRaw = null;
1389        boolean hasImages = false;
1390
1391        if (bodyHtml == null) {
1392            text = bodyText;
1393            /*
1394             * Convert the plain text to HTML
1395             */
1396            StringBuffer sb = new StringBuffer("<html><body>");
1397            if (text != null) {
1398                // Escape any inadvertent HTML in the text message
1399                text = EmailHtmlUtil.escapeCharacterToDisplay(text);
1400                // Find any embedded URL's and linkify
1401                Matcher m = Patterns.WEB_URL.matcher(text);
1402                while (m.find()) {
1403                    int start = m.start();
1404                    /*
1405                     * WEB_URL_PATTERN may match domain part of email address. To detect
1406                     * this false match, the character just before the matched string
1407                     * should not be '@'.
1408                     */
1409                    if (start == 0 || text.charAt(start - 1) != '@') {
1410                        String url = m.group();
1411                        Matcher proto = WEB_URL_PROTOCOL.matcher(url);
1412                        String link;
1413                        if (proto.find()) {
1414                            // This is work around to force URL protocol part be lower case,
1415                            // because WebView could follow only lower case protocol link.
1416                            link = proto.group().toLowerCase() + url.substring(proto.end());
1417                        } else {
1418                            // Patterns.WEB_URL matches URL without protocol part,
1419                            // so added default protocol to link.
1420                            link = "http://" + url;
1421                        }
1422                        String href = String.format("<a href=\"%s\">%s</a>", link, url);
1423                        m.appendReplacement(sb, href);
1424                    }
1425                    else {
1426                        m.appendReplacement(sb, "$0");
1427                    }
1428                }
1429                m.appendTail(sb);
1430            }
1431            sb.append("</body></html>");
1432            text = sb.toString();
1433        } else {
1434            text = bodyHtml;
1435            mHtmlTextRaw = bodyHtml;
1436            hasImages = IMG_TAG_START_REGEX.matcher(text).find();
1437        }
1438
1439        // TODO this is not really accurate.
1440        // - Images aren't the only network resources.  (e.g. CSS)
1441        // - If images are attached to the email and small enough, we download them at once,
1442        //   and won't need network access when they're shown.
1443        if (hasImages) {
1444            addTabFlags(TAB_FLAGS_HAS_PICTURES);
1445        }
1446        if (mMessageContentView != null) {
1447            mMessageContentView.loadDataWithBaseURL("email://", text, "text/html", "utf-8", null);
1448        }
1449
1450        // Ask for attachments after body
1451        mLoadAttachmentsTask = new LoadAttachmentsTask();
1452        mLoadAttachmentsTask.execute(mMessage.mId);
1453
1454        mIsMessageLoadedForTest = true;
1455    }
1456
1457    /**
1458     * Overrides for WebView behaviors.
1459     */
1460    private class CustomWebViewClient extends WebViewClient {
1461        @Override
1462        public boolean shouldOverrideUrlLoading(WebView view, String url) {
1463            return mCallback.onUrlInMessageClicked(url);
1464        }
1465    }
1466
1467    private View findAttachmentView(long attachmentId) {
1468        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
1469            View view = mAttachments.getChildAt(i);
1470            AttachmentInfo attachment = (AttachmentInfo) view.getTag();
1471            if (attachment.attachmentId == attachmentId) {
1472                return view;
1473            }
1474        }
1475        return null;
1476    }
1477
1478    private AttachmentInfo findAttachmentInfo(long attachmentId) {
1479        View view = findAttachmentView(attachmentId);
1480        if (view != null) {
1481            return (AttachmentInfo)view.getTag();
1482        }
1483        return null;
1484    }
1485
1486    /**
1487     * Controller results listener.  We wrap it with {@link ControllerResultUiThreadWrapper},
1488     * so all methods are called on the UI thread.
1489     */
1490    private class ControllerResults extends Controller.Result {
1491        private long mWaitForLoadMessageId;
1492
1493        public void setWaitForLoadMessageId(long messageId) {
1494            mWaitForLoadMessageId = messageId;
1495        }
1496
1497        @Override
1498        public void loadMessageForViewCallback(MessagingException result, long accountId,
1499                long messageId, int progress) {
1500            if (messageId != mWaitForLoadMessageId) {
1501                // We are not waiting for this message to load, so exit quickly
1502                return;
1503            }
1504            if (result == null) {
1505                switch (progress) {
1506                    case 0:
1507                        mCallback.onLoadMessageStarted();
1508                        // Loading from network -- show the progress icon.
1509                        showContent(false, true);
1510                        break;
1511                    case 100:
1512                        mWaitForLoadMessageId = -1;
1513                        mCallback.onLoadMessageFinished();
1514                        // reload UI and reload everything else too
1515                        // pass false to LoadMessageTask to prevent looping here
1516                        cancelAllTasks();
1517                        mLoadMessageTask = new LoadMessageTask(false);
1518                        mLoadMessageTask.execute();
1519                        break;
1520                    default:
1521                        // do nothing - we don't have a progress bar at this time
1522                        break;
1523                }
1524            } else {
1525                mWaitForLoadMessageId = -1;
1526                String error = mContext.getString(R.string.status_network_error);
1527                mCallback.onLoadMessageError(error);
1528                resetView();
1529            }
1530        }
1531
1532        @Override
1533        public void loadAttachmentCallback(MessagingException result, long accountId,
1534                long messageId, long attachmentId, int progress) {
1535            if (messageId == mMessageId) {
1536                if (result == null) {
1537                    showAttachmentProgress(attachmentId, progress);
1538                    switch (progress) {
1539                        case 100:
1540                            updateAttachmentThumbnail(attachmentId);
1541                            doFinishLoadAttachment(attachmentId);
1542                            break;
1543                        default:
1544                            // do nothing - we don't have a progress bar at this time
1545                            break;
1546                    }
1547                } else {
1548                    AttachmentInfo attachment = findAttachmentInfo(attachmentId);
1549                    if (attachment == null) {
1550                        // Called before LoadAttachmentsTask finishes.
1551                        // (Possible if you quickly close & re-open a message)
1552                        return;
1553                    }
1554                    attachment.cancelButton.setVisibility(View.GONE);
1555                    attachment.loadButton.setVisibility(View.VISIBLE);
1556                    attachment.progressView.setVisibility(View.INVISIBLE);
1557
1558                    final String error;
1559                    if (result.getCause() instanceof IOException) {
1560                        error = mContext.getString(R.string.status_network_error);
1561                    } else {
1562                        error = mContext.getString(
1563                                R.string.message_view_load_attachment_failed_toast,
1564                                attachment.name);
1565                    }
1566                    mCallback.onLoadMessageError(error);
1567                }
1568            }
1569        }
1570
1571        private void showAttachmentProgress(long attachmentId, int progress) {
1572            AttachmentInfo attachment = findAttachmentInfo(attachmentId);
1573            if (attachment != null) {
1574                ProgressBar bar = attachment.progressView;
1575                if (progress == 0) {
1576                    // When the download starts, we can get rid of the indeterminate bar
1577                    bar.setVisibility(View.VISIBLE);
1578                    bar.setIndeterminate(false);
1579                    // And we're not implementing stop of in-progress downloads
1580                    attachment.cancelButton.setVisibility(View.GONE);
1581                }
1582                bar.setProgress(progress);
1583            }
1584        }
1585    }
1586
1587    /**
1588     * Class to detect update on the current message (e.g. toggle star).  When it gets content
1589     * change notifications, it kicks {@link ReloadMessageTask}.
1590     *
1591     * TODO Use the new Throttle class.
1592     */
1593    private class MessageObserver extends ContentObserver implements Runnable {
1594        private final Throttle mThrottle;
1595        private final ContentResolver mContentResolver;
1596
1597        private boolean mRegistered;
1598
1599        public MessageObserver(Handler handler, Context context) {
1600            super(handler);
1601            mContentResolver = context.getContentResolver();
1602            mThrottle = new Throttle("MessageObserver", this, handler);
1603        }
1604
1605        public void unregister() {
1606            if (!mRegistered) {
1607                return;
1608            }
1609            mThrottle.cancelScheduledCallback();
1610            mContentResolver.unregisterContentObserver(this);
1611            mRegistered = false;
1612        }
1613
1614        public void register(Uri notifyUri) {
1615            unregister();
1616            mContentResolver.registerContentObserver(notifyUri, true, this);
1617            mRegistered = true;
1618        }
1619
1620        @Override
1621        public boolean deliverSelfNotifications() {
1622            return true;
1623        }
1624
1625        @Override
1626        public void onChange(boolean selfChange) {
1627            mThrottle.onEvent();
1628        }
1629
1630        /**
1631         * This method is delay-called by {@link Throttle} on the UI thread.  Need to make
1632         * sure if the fragment is still valid.  (i.e. don't reload if clearContent() has been
1633         * called.)
1634         */
1635        @Override
1636        public void run() {
1637            if (!isMessageSpecified()) {
1638                return;
1639            }
1640            Utility.cancelTaskInterrupt(mReloadMessageTask);
1641            mReloadMessageTask = new ReloadMessageTask();
1642            mReloadMessageTask.execute();
1643        }
1644    }
1645
1646    public boolean isMessageLoadedForTest() {
1647        return mIsMessageLoadedForTest;
1648    }
1649
1650    public void clearIsMessageLoadedForTest() {
1651        mIsMessageLoadedForTest = true;
1652    }
1653}
1654