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