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