com.appaholics.email.activity.MessageViewFragmentBase.java Source code

Java tutorial

Introduction

Here is the source code for com.appaholics.email.activity.MessageViewFragmentBase.java

Source

/*
 * Copyright (C) 2010 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.appaholics.email.activity;

import android.app.Activity;
import android.app.DownloadManager;
import android.app.Fragment;
import android.app.LoaderManager.LoaderCallbacks;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.Loader;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.database.ContentObserver;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.provider.ContactsContract;
import android.provider.ContactsContract.QuickContact;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;
import android.util.Patterns;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
import android.widget.TextView;

import com.android.emailcommon.Logging;
import com.android.emailcommon.mail.Address;
import com.android.emailcommon.mail.MessagingException;
import com.android.emailcommon.provider.Account;
import com.android.emailcommon.provider.EmailContent.Attachment;
import com.android.emailcommon.provider.EmailContent.Body;
import com.android.emailcommon.provider.EmailContent.Message;
import com.android.emailcommon.provider.Mailbox;
import com.android.emailcommon.utility.AttachmentUtilities;
import com.android.emailcommon.utility.EmailAsyncTask;
import com.android.emailcommon.utility.Utility;
import com.appaholics.email.AttachmentInfo;
import com.appaholics.email.Controller;
import com.appaholics.email.ControllerResultUiThreadWrapper;
import com.appaholics.email.Email;
import com.appaholics.email.Preferences;
import com.appaholics.email.R;
import com.appaholics.email.Throttle;
import com.appaholics.email.mail.internet.EmailHtmlUtil;
import com.appaholics.email.service.AttachmentDownloadService;
import com.google.common.collect.Maps;

import org.apache.commons.io.IOUtils;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Formatter;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

// TODO Better handling of config changes.
// - Retain the content; don't kick 3 async tasks every time

/**
 * Base class for {@link MessageViewFragment} and {@link MessageFileViewFragment}.
 */
public abstract class MessageViewFragmentBase extends Fragment implements View.OnClickListener {
    private static final String BUNDLE_KEY_CURRENT_TAB = "MessageViewFragmentBase.currentTab";
    private static final String BUNDLE_KEY_PICTURE_LOADED = "MessageViewFragmentBase.pictureLoaded";
    private static final int PHOTO_LOADER_ID = 1;
    protected Context mContext;

    // Regex that matches start of img tag. '<(?i)img\s+'.
    private static final Pattern IMG_TAG_START_REGEX = Pattern.compile("<(?i)img\\s+");
    // Regex that matches Web URL protocol part as case insensitive.
    private static final Pattern WEB_URL_PROTOCOL = Pattern.compile("(?i)http|https://");

    private static int PREVIEW_ICON_WIDTH = 62;
    private static int PREVIEW_ICON_HEIGHT = 62;

    // The different levels of zoom: read from the Preferences.
    private static String[] sZoomSizes = null;

    private TextView mSubjectView;
    private TextView mFromNameView;
    private TextView mFromAddressView;
    private TextView mDateTimeView;
    private TextView mAddressesView;
    private WebView mMessageContentView;
    private LinearLayout mAttachments;
    private View mTabSection;
    private ImageView mFromBadge;
    private ImageView mSenderPresenceView;
    private View mMainView;
    private View mLoadingProgress;
    private View mDetailsCollapsed;
    private View mDetailsExpanded;
    private boolean mDetailsFilled;

    private TextView mMessageTab;
    private TextView mAttachmentTab;
    private TextView mInviteTab;
    // It is not really a tab, but looks like one of them.
    private TextView mShowPicturesTab;
    private View mAlwaysShowPicturesButton;

    private View mAttachmentsScroll;
    private View mInviteScroll;

    private long mAccountId = Account.NO_ACCOUNT;
    private long mMessageId = Message.NO_MESSAGE;
    private Message mMessage;

    private Controller mController;
    private ControllerResultUiThreadWrapper<ControllerResults> mControllerCallback;

    // contains the HTML body. Is used by LoadAttachmentTask to display inline images.
    // is null most of the time, is used transiently to pass info to LoadAttachementTask
    private String mHtmlTextRaw;

    // contains the HTML content as set in WebView.
    private String mHtmlTextWebView;

    private boolean mIsMessageLoadedForTest;

    private MessageObserver mMessageObserver;

    private static final int CONTACT_STATUS_STATE_UNLOADED = 0;
    private static final int CONTACT_STATUS_STATE_UNLOADED_TRIGGERED = 1;
    private static final int CONTACT_STATUS_STATE_LOADED = 2;

    private int mContactStatusState;
    private Uri mQuickContactLookupUri;

    /** Flag for {@link #mTabFlags}: Message has attachment(s) */
    protected static final int TAB_FLAGS_HAS_ATTACHMENT = 1;

    /**
     * Flag for {@link #mTabFlags}: Message contains invite.  This flag is only set by
     * {@link MessageViewFragment}.
     */
    protected static final int TAB_FLAGS_HAS_INVITE = 2;

    /** Flag for {@link #mTabFlags}: Message contains pictures */
    protected static final int TAB_FLAGS_HAS_PICTURES = 4;

    /** Flag for {@link #mTabFlags}: "Show pictures" has already been pressed */
    protected static final int TAB_FLAGS_PICTURE_LOADED = 8;

    /**
     * Flags to control the tabs.
     * @see #updateTabs(int)
     */
    private int mTabFlags;

    /** # of attachments in the current message */
    private int mAttachmentCount;

    // Use (random) large values, to avoid confusion with TAB_FLAGS_*
    protected static final int TAB_MESSAGE = 101;
    protected static final int TAB_INVITE = 102;
    protected static final int TAB_ATTACHMENT = 103;
    private static final int TAB_NONE = 0;

    /** Current tab */
    private int mCurrentTab = TAB_NONE;
    /**
     * Tab that was selected in the previous activity instance.
     * Used to restore the current tab after screen rotation.
     */
    private int mRestoredTab = TAB_NONE;

    private boolean mRestoredPictureLoaded;

    private final EmailAsyncTask.Tracker mTaskTracker = new EmailAsyncTask.Tracker();

    public interface Callback {
        /**
         * Called when a link in a message is clicked.
         *
         * @param url link url that's clicked.
         * @return true if handled, false otherwise.
         */
        public boolean onUrlInMessageClicked(String url);

        /**
         * Called when the message specified doesn't exist, or is deleted/moved.
         */
        public void onMessageNotExists();

        /** Called when it starts loading a message. */
        public void onLoadMessageStarted();

        /** Called when it successfully finishes loading a message. */
        public void onLoadMessageFinished();

        /** Called when an error occurred during loading a message. */
        public void onLoadMessageError(String errorMessage);
    }

    public static class EmptyCallback implements Callback {
        public static final Callback INSTANCE = new EmptyCallback();

        @Override
        public void onLoadMessageError(String errorMessage) {
        }

        @Override
        public void onLoadMessageFinished() {
        }

        @Override
        public void onLoadMessageStarted() {
        }

        @Override
        public void onMessageNotExists() {
        }

        @Override
        public boolean onUrlInMessageClicked(String url) {
            return false;
        }
    }

    private Callback mCallback = EmptyCallback.INSTANCE;

    @Override
    public void onAttach(Activity activity) {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onAttach");
        }
        super.onAttach(activity);
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onCreate");
        }
        super.onCreate(savedInstanceState);

        mContext = getActivity().getApplicationContext();

        // Initialize components, but don't "start" them.  Registering the controller callbacks
        // and starting MessageObserver, should be done in onActivityCreated or later and be stopped
        // in onDestroyView to prevent from getting callbacks when the fragment is in the back
        // stack, but they'll start again when it's back from the back stack.
        mController = Controller.getInstance(mContext);
        mControllerCallback = new ControllerResultUiThreadWrapper<ControllerResults>(new Handler(),
                new ControllerResults());
        mMessageObserver = new MessageObserver(new Handler(), mContext);

        if (savedInstanceState != null) {
            restoreInstanceState(savedInstanceState);
        }
    }

    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onCreateView");
        }
        final View view = inflater.inflate(R.layout.message_view_fragment, container, false);

        cleanupDetachedViews();

        mSubjectView = (TextView) UiUtilities.getView(view, R.id.subject);
        mFromNameView = (TextView) UiUtilities.getView(view, R.id.from_name);
        mFromAddressView = (TextView) UiUtilities.getView(view, R.id.from_address);
        mAddressesView = (TextView) UiUtilities.getView(view, R.id.addresses);
        mDateTimeView = (TextView) UiUtilities.getView(view, R.id.datetime);
        mMessageContentView = (WebView) UiUtilities.getView(view, R.id.message_content);
        mAttachments = (LinearLayout) UiUtilities.getView(view, R.id.attachments);
        mTabSection = UiUtilities.getView(view, R.id.message_tabs_section);
        mFromBadge = (ImageView) UiUtilities.getView(view, R.id.badge);
        mSenderPresenceView = (ImageView) UiUtilities.getView(view, R.id.presence);
        mMainView = UiUtilities.getView(view, R.id.main_panel);
        mLoadingProgress = UiUtilities.getView(view, R.id.loading_progress);
        mDetailsCollapsed = UiUtilities.getView(view, R.id.sub_header_contents_collapsed);
        mDetailsExpanded = UiUtilities.getView(view, R.id.sub_header_contents_expanded);

        mFromNameView.setOnClickListener(this);
        mFromAddressView.setOnClickListener(this);
        mFromBadge.setOnClickListener(this);
        mSenderPresenceView.setOnClickListener(this);

        mMessageTab = UiUtilities.getView(view, R.id.show_message);
        mAttachmentTab = UiUtilities.getView(view, R.id.show_attachments);
        mShowPicturesTab = UiUtilities.getView(view, R.id.show_pictures);
        mAlwaysShowPicturesButton = UiUtilities.getView(view, R.id.always_show_pictures_button);
        // Invite is only used in MessageViewFragment, but visibility is controlled here.
        mInviteTab = UiUtilities.getView(view, R.id.show_invite);

        mMessageTab.setOnClickListener(this);
        mAttachmentTab.setOnClickListener(this);
        mShowPicturesTab.setOnClickListener(this);
        mAlwaysShowPicturesButton.setOnClickListener(this);
        mInviteTab.setOnClickListener(this);
        mDetailsCollapsed.setOnClickListener(this);
        mDetailsExpanded.setOnClickListener(this);

        mAttachmentsScroll = UiUtilities.getView(view, R.id.attachments_scroll);
        mInviteScroll = UiUtilities.getView(view, R.id.invite_scroll);

        WebSettings webSettings = mMessageContentView.getSettings();
        boolean supportMultiTouch = mContext.getPackageManager()
                .hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH);
        webSettings.setDisplayZoomControls(!supportMultiTouch);
        webSettings.setSupportZoom(true);
        webSettings.setBuiltInZoomControls(true);
        mMessageContentView.setWebViewClient(new CustomWebViewClient());
        return view;
    }

    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onActivityCreated");
        }
        super.onActivityCreated(savedInstanceState);
        mController.addResultCallback(mControllerCallback);

        resetView();
        new LoadMessageTask(true).executeParallel();

        UiUtilities.installFragment(this);
    }

    @Override
    public void onStart() {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onStart");
        }
        super.onStart();
    }

    @Override
    public void onResume() {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onResume");
        }
        super.onResume();

        // We might have comes back from other full-screen activities.  If so, we need to update
        // the attachment tab as system settings may have been updated that affect which
        // options are available to the user.
        updateAttachmentTab();
    }

    @Override
    public void onPause() {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onPause");
        }
        super.onPause();
    }

    @Override
    public void onStop() {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onStop");
        }
        super.onStop();
    }

    @Override
    public void onDestroyView() {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onDestroyView");
        }
        UiUtilities.uninstallFragment(this);
        mController.removeResultCallback(mControllerCallback);
        cancelAllTasks();

        // We should clean up the Webview here, but it can't release resources until it is
        // actually removed from the view tree.

        super.onDestroyView();
    }

    private void cleanupDetachedViews() {
        // WebView cleanup must be done after it leaves the rendering tree, according to
        // its contract
        if (mMessageContentView != null) {
            mMessageContentView.destroy();
            mMessageContentView = null;
        }
    }

    @Override
    public void onDestroy() {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onDestroy");
        }

        cleanupDetachedViews();
        super.onDestroy();
    }

    @Override
    public void onDetach() {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onDetach");
        }
        super.onDetach();
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " onSaveInstanceState");
        }
        super.onSaveInstanceState(outState);
        outState.putInt(BUNDLE_KEY_CURRENT_TAB, mCurrentTab);
        outState.putBoolean(BUNDLE_KEY_PICTURE_LOADED, (mTabFlags & TAB_FLAGS_PICTURE_LOADED) != 0);
    }

    private void restoreInstanceState(Bundle state) {
        if (Logging.DEBUG_LIFECYCLE && Email.DEBUG) {
            Log.d(Logging.LOG_TAG, this + " restoreInstanceState");
        }
        // At this point (in onCreate) no tabs are visible (because we don't know if the message has
        // an attachment or invite before loading it).  We just remember the tab here.
        // We'll make it current when the tab first becomes visible in updateTabs().
        mRestoredTab = state.getInt(BUNDLE_KEY_CURRENT_TAB);
        mRestoredPictureLoaded = state.getBoolean(BUNDLE_KEY_PICTURE_LOADED);
    }

    public void setCallback(Callback callback) {
        mCallback = (callback == null) ? EmptyCallback.INSTANCE : callback;
    }

    private void cancelAllTasks() {
        mMessageObserver.unregister();
        mTaskTracker.cancellAllInterrupt();
    }

    protected final Controller getController() {
        return mController;
    }

    protected final Callback getCallback() {
        return mCallback;
    }

    public final Message getMessage() {
        return mMessage;
    }

    protected final boolean isMessageOpen() {
        return mMessage != null;
    }

    /**
     * Returns the account id of the current message, or -1 if unknown (message not open yet, or
     * viewing an EML message).
     */
    public long getAccountId() {
        return mAccountId;
    }

    /**
     * Show/hide the content.  We hide all the content (except for the bottom buttons) when loading,
     * to avoid flicker.
     */
    private void showContent(boolean showContent, boolean showProgressWhenHidden) {
        makeVisible(mMainView, showContent);
        makeVisible(mLoadingProgress, !showContent && showProgressWhenHidden);
    }

    // TODO: clean this up - most of this is not needed since the WebView and Fragment is not
    // reused for multiple messages.
    protected void resetView() {
        showContent(false, false);
        updateTabs(0);
        setCurrentTab(TAB_MESSAGE);
        if (mMessageContentView != null) {
            blockNetworkLoads(true);
            mMessageContentView.scrollTo(0, 0);

            // Dynamic configuration of WebView
            final WebSettings settings = mMessageContentView.getSettings();
            settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
            mMessageContentView.setInitialScale(getWebViewZoom());
        }
        mAttachmentsScroll.scrollTo(0, 0);
        mInviteScroll.scrollTo(0, 0);
        mAttachments.removeAllViews();
        mAttachments.setVisibility(View.GONE);
        initContactStatusViews();
    }

    /**
     * Returns the zoom scale (in percent) which is a combination of the user setting
     * (tiny, small, normal, large, huge) and the device density. The intention
     * is for the text to be physically equal in size over different density
     * screens.
     */
    private int getWebViewZoom() {
        float density = mContext.getResources().getDisplayMetrics().density;
        int zoom = Preferences.getPreferences(mContext).getTextZoom();
        if (sZoomSizes == null) {
            sZoomSizes = mContext.getResources().getStringArray(R.array.general_preference_text_zoom_size);
        }
        return (int) (Float.valueOf(sZoomSizes[zoom]) * density * 100);
    }

    private void initContactStatusViews() {
        mContactStatusState = CONTACT_STATUS_STATE_UNLOADED;
        mQuickContactLookupUri = null;
        showDefaultQuickContactBadgeImage();
    }

    private void showDefaultQuickContactBadgeImage() {
        mFromBadge.setImageResource(R.drawable.ic_contact_picture);
    }

    protected final void addTabFlags(int tabFlags) {
        updateTabs(mTabFlags | tabFlags);
    }

    private final void clearTabFlags(int tabFlags) {
        updateTabs(mTabFlags & ~tabFlags);
    }

    private void setAttachmentCount(int count) {
        mAttachmentCount = count;
        if (mAttachmentCount > 0) {
            addTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
        } else {
            clearTabFlags(TAB_FLAGS_HAS_ATTACHMENT);
        }
    }

    private static void makeVisible(View v, boolean visible) {
        final int visibility = visible ? View.VISIBLE : View.GONE;
        if ((v != null) && (v.getVisibility() != visibility)) {
            v.setVisibility(visibility);
        }
    }

    private static boolean isVisible(View v) {
        return (v != null) && (v.getVisibility() == View.VISIBLE);
    }

    /**
     * Update the visual of the tabs.  (visibility, text, etc)
     */
    private void updateTabs(int tabFlags) {
        mTabFlags = tabFlags;

        if (getView() == null) {
            return;
        }

        boolean messageTabVisible = (tabFlags & (TAB_FLAGS_HAS_INVITE | TAB_FLAGS_HAS_ATTACHMENT)) != 0;
        makeVisible(mMessageTab, messageTabVisible);
        makeVisible(mInviteTab, (tabFlags & TAB_FLAGS_HAS_INVITE) != 0);
        makeVisible(mAttachmentTab, (tabFlags & TAB_FLAGS_HAS_ATTACHMENT) != 0);

        final boolean hasPictures = (tabFlags & TAB_FLAGS_HAS_PICTURES) != 0;
        final boolean pictureLoaded = (tabFlags & TAB_FLAGS_PICTURE_LOADED) != 0;
        makeVisible(mShowPicturesTab, hasPictures && !pictureLoaded);

        mAttachmentTab.setText(mContext.getResources().getQuantityString(
                R.plurals.message_view_show_attachments_action, mAttachmentCount, mAttachmentCount));

        // Hide the entire section if no tabs are visible.
        makeVisible(mTabSection, isVisible(mMessageTab) || isVisible(mInviteTab) || isVisible(mAttachmentTab)
                || isVisible(mShowPicturesTab) || isVisible(mAlwaysShowPicturesButton));

        // Restore previously selected tab after rotation
        if (mRestoredTab != TAB_NONE && isVisible(getTabViewForFlag(mRestoredTab))) {
            setCurrentTab(mRestoredTab);
            mRestoredTab = TAB_NONE;
        }
    }

    /**
     * Set the current tab.
     *
     * @param tab any of {@link #TAB_MESSAGE}, {@link #TAB_ATTACHMENT} or {@link #TAB_INVITE}.
     */
    private void setCurrentTab(int tab) {
        mCurrentTab = tab;

        // Hide & unselect all tabs
        makeVisible(getTabContentViewForFlag(TAB_MESSAGE), false);
        makeVisible(getTabContentViewForFlag(TAB_ATTACHMENT), false);
        makeVisible(getTabContentViewForFlag(TAB_INVITE), false);
        getTabViewForFlag(TAB_MESSAGE).setSelected(false);
        getTabViewForFlag(TAB_ATTACHMENT).setSelected(false);
        getTabViewForFlag(TAB_INVITE).setSelected(false);

        makeVisible(getTabContentViewForFlag(mCurrentTab), true);
        getTabViewForFlag(mCurrentTab).setSelected(true);
    }

    private View getTabViewForFlag(int tabFlag) {
        switch (tabFlag) {
        case TAB_MESSAGE:
            return mMessageTab;
        case TAB_ATTACHMENT:
            return mAttachmentTab;
        case TAB_INVITE:
            return mInviteTab;
        }
        throw new IllegalArgumentException();
    }

    private View getTabContentViewForFlag(int tabFlag) {
        switch (tabFlag) {
        case TAB_MESSAGE:
            return mMessageContentView;
        case TAB_ATTACHMENT:
            return mAttachmentsScroll;
        case TAB_INVITE:
            return mInviteScroll;
        }
        throw new IllegalArgumentException();
    }

    private void blockNetworkLoads(boolean block) {
        if (mMessageContentView != null) {
            mMessageContentView.getSettings().setBlockNetworkLoads(block);
        }
    }

    private void setMessageHtml(String html) {
        if (html == null) {
            html = "";
        }
        if (mMessageContentView != null) {
            mMessageContentView.loadDataWithBaseURL("email://", html, "text/html", "utf-8", null);
        }
    }

    /**
     * Handle clicks on sender, which shows {@link QuickContact} or prompts to add
     * the sender as a contact.
     */
    private void onClickSender() {
        if (!isMessageOpen())
            return;
        final Address senderEmail = Address.unpackFirst(mMessage.mFrom);
        if (senderEmail == null)
            return;

        if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED) {
            // Status not loaded yet.
            mContactStatusState = CONTACT_STATUS_STATE_UNLOADED_TRIGGERED;
            return;
        }
        if (mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED) {
            return; // Already clicked, and waiting for the data.
        }

        if (mQuickContactLookupUri != null) {
            QuickContact.showQuickContact(mContext, mFromBadge, mQuickContactLookupUri, QuickContact.MODE_MEDIUM,
                    null);
        } else {
            // No matching contact, ask user to create one
            final Uri mailUri = Uri.fromParts("mailto", senderEmail.getAddress(), null);
            final Intent intent = new Intent(ContactsContract.Intents.SHOW_OR_CREATE_CONTACT, mailUri);

            // Only provide personal name hint if we have one
            final String senderPersonal = senderEmail.getPersonal();
            if (!TextUtils.isEmpty(senderPersonal)) {
                intent.putExtra(ContactsContract.Intents.Insert.NAME, senderPersonal);
            }
            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);

            startActivity(intent);
        }
    }

    private static class ContactStatusLoaderCallbacks implements LoaderCallbacks<ContactStatusLoader.Result> {
        private static final String BUNDLE_EMAIL_ADDRESS = "email";
        private final MessageViewFragmentBase mFragment;

        public ContactStatusLoaderCallbacks(MessageViewFragmentBase fragment) {
            mFragment = fragment;
        }

        public static Bundle createArguments(String emailAddress) {
            Bundle b = new Bundle();
            b.putString(BUNDLE_EMAIL_ADDRESS, emailAddress);
            return b;
        }

        @Override
        public Loader<ContactStatusLoader.Result> onCreateLoader(int id, Bundle args) {
            return new ContactStatusLoader(mFragment.mContext, args.getString(BUNDLE_EMAIL_ADDRESS));
        }

        @Override
        public void onLoadFinished(Loader<ContactStatusLoader.Result> loader, ContactStatusLoader.Result result) {
            boolean triggered = (mFragment.mContactStatusState == CONTACT_STATUS_STATE_UNLOADED_TRIGGERED);
            mFragment.mContactStatusState = CONTACT_STATUS_STATE_LOADED;
            mFragment.mQuickContactLookupUri = result.mLookupUri;

            if (result.isUnknown()) {
                mFragment.mSenderPresenceView.setVisibility(View.GONE);
            } else {
                mFragment.mSenderPresenceView.setVisibility(View.VISIBLE);
                mFragment.mSenderPresenceView.setImageResource(result.mPresenceResId);
            }
            if (result.mPhoto != null) { // photo will be null if unknown.
                mFragment.mFromBadge.setImageBitmap(result.mPhoto);
            }
            if (triggered) {
                mFragment.onClickSender();
            }
        }

        @Override
        public void onLoaderReset(Loader<ContactStatusLoader.Result> loader) {
        }
    }

    private void onSaveAttachment(MessageViewAttachmentInfo info) {
        if (!Utility.isExternalStorageMounted()) {
            /*
             * Abort early if there's no place to save the attachment. We don't want to spend
             * the time downloading it and then abort.
             */
            Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
            return;
        }

        if (info.isFileSaved()) {
            // Nothing to do - we have the file saved.
            return;
        }

        File savedFile = performAttachmentSave(info);
        if (savedFile != null) {
            Utility.showToast(getActivity(), String.format(
                    mContext.getString(R.string.message_view_status_attachment_saved), savedFile.getName()));
        } else {
            Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
        }
    }

    private File performAttachmentSave(MessageViewAttachmentInfo info) {
        Attachment attachment = Attachment.restoreAttachmentWithId(mContext, info.mId);
        Uri attachmentUri = AttachmentUtilities.getAttachmentUri(mAccountId, attachment.mId);

        try {
            File downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
            downloads.mkdirs();
            File file = Utility.createUniqueFile(downloads, attachment.mFileName);
            Uri contentUri = AttachmentUtilities.resolveAttachmentIdToContentUri(mContext.getContentResolver(),
                    attachmentUri);
            InputStream in = mContext.getContentResolver().openInputStream(contentUri);
            OutputStream out = new FileOutputStream(file);
            IOUtils.copy(in, out);
            out.flush();
            out.close();
            in.close();

            String absolutePath = file.getAbsolutePath();

            // Although the download manager can scan media files, scanning only happens after the
            // user clicks on the item in the Downloads app. So, we run the attachment through
            // the media scanner ourselves so it gets added to gallery / music immediately.
            MediaScannerConnection.scanFile(mContext, new String[] { absolutePath }, null, null);

            DownloadManager dm = (DownloadManager) getActivity().getSystemService(Context.DOWNLOAD_SERVICE);
            dm.addCompletedDownload(info.mName, info.mName, false /* do not use media scanner */, info.mContentType,
                    absolutePath, info.mSize, true /* show notification */);

            // Cache the stored file information.
            info.setSavedPath(absolutePath);

            // Update our buttons.
            updateAttachmentButtons(info);

            return file;

        } catch (IOException ioe) {
            // Ignore. Callers will handle it from the return code.
        }

        return null;
    }

    private void onOpenAttachment(MessageViewAttachmentInfo info) {
        if (info.mAllowInstall) {
            // The package installer is unable to install files from a content URI; it must be
            // given a file path. Therefore, we need to save it first in order to proceed
            if (!info.mAllowSave || !Utility.isExternalStorageMounted()) {
                Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
                return;
            }

            if (!info.isFileSaved()) {
                if (performAttachmentSave(info) == null) {
                    // Saving failed for some reason - bail.
                    Utility.showToast(getActivity(), R.string.message_view_status_attachment_not_saved);
                    return;
                }
            }
        }
        try {
            Intent intent = info.getAttachmentIntent(mContext, mAccountId);
            startActivity(intent);
        } catch (ActivityNotFoundException e) {
            Utility.showToast(getActivity(), R.string.message_view_display_attachment_toast);
        }
    }

    private void onInfoAttachment(final MessageViewAttachmentInfo attachment) {
        AttachmentInfoDialog dialog = AttachmentInfoDialog.newInstance(getActivity(), attachment.mDenyFlags);
        dialog.show(getActivity().getFragmentManager(), null);
    }

    private void onLoadAttachment(final MessageViewAttachmentInfo attachment) {
        attachment.loadButton.setVisibility(View.GONE);
        // If there's nothing in the download queue, we'll probably start right away so wait a
        // second before showing the cancel button
        if (AttachmentDownloadService.getQueueSize() == 0) {
            // Set to invisible; if the button is still in this state one second from now, we'll
            // assume the download won't start right away, and we make the cancel button visible
            attachment.cancelButton.setVisibility(View.GONE);
            // Create the timed task that will change the button state
            new EmailAsyncTask<Void, Void, Void>(mTaskTracker) {
                @Override
                protected Void doInBackground(Void... params) {
                    try {
                        Thread.sleep(1000L);
                    } catch (InterruptedException e) {
                    }
                    return null;
                }

                @Override
                protected void onSuccess(Void result) {
                    // If the timeout completes and the attachment has not loaded, show cancel
                    if (!attachment.loaded) {
                        attachment.cancelButton.setVisibility(View.VISIBLE);
                    }
                }
            }.executeParallel();
        } else {
            attachment.cancelButton.setVisibility(View.VISIBLE);
        }
        attachment.showProgressIndeterminate();
        mController.loadAttachment(attachment.mId, mMessageId, mAccountId);
    }

    private void onCancelAttachment(MessageViewAttachmentInfo attachment) {
        // Don't change button states if we couldn't cancel the download
        if (AttachmentDownloadService.cancelQueuedAttachment(attachment.mId)) {
            attachment.loadButton.setVisibility(View.VISIBLE);
            attachment.cancelButton.setVisibility(View.GONE);
            attachment.hideProgress();
        }
    }

    /**
     * Called by ControllerResults. Show the "View" and "Save" buttons; hide "Load" and "Stop"
     *
     * @param attachmentId the attachment that was just downloaded
     */
    private void doFinishLoadAttachment(long attachmentId) {
        MessageViewAttachmentInfo info = findAttachmentInfo(attachmentId);
        if (info != null) {
            info.loaded = true;
            updateAttachmentButtons(info);
        }
    }

    private void showPicturesInHtml() {
        boolean picturesAlreadyLoaded = (mTabFlags & TAB_FLAGS_PICTURE_LOADED) != 0;
        if ((mMessageContentView != null) && !picturesAlreadyLoaded) {
            blockNetworkLoads(false);
            // TODO: why is this calling setMessageHtml just because the images can load now?
            setMessageHtml(mHtmlTextWebView);

            // Prompt the user to always show images from this sender.
            makeVisible(UiUtilities.getView(getView(), R.id.always_show_pictures_button), true);

            addTabFlags(TAB_FLAGS_PICTURE_LOADED);
        }
    }

    private void showDetails() {
        if (!isMessageOpen()) {
            return;
        }

        if (!mDetailsFilled) {
            String date = formatDate(mMessage.mTimeStamp, true);
            final String SEPARATOR = "\n";
            String to = Address.toString(Address.unpack(mMessage.mTo), SEPARATOR);
            String cc = Address.toString(Address.unpack(mMessage.mCc), SEPARATOR);
            String bcc = Address.toString(Address.unpack(mMessage.mBcc), SEPARATOR);
            setDetailsRow(mDetailsExpanded, date, R.id.date, R.id.date_row);
            setDetailsRow(mDetailsExpanded, to, R.id.to, R.id.to_row);
            setDetailsRow(mDetailsExpanded, cc, R.id.cc, R.id.cc_row);
            setDetailsRow(mDetailsExpanded, bcc, R.id.bcc, R.id.bcc_row);
            mDetailsFilled = true;
        }

        mDetailsCollapsed.setVisibility(View.GONE);
        mDetailsExpanded.setVisibility(View.VISIBLE);
    }

    private void hideDetails() {
        mDetailsCollapsed.setVisibility(View.VISIBLE);
        mDetailsExpanded.setVisibility(View.GONE);
    }

    private static void setDetailsRow(View root, String text, int textViewId, int rowViewId) {
        if (TextUtils.isEmpty(text)) {
            root.findViewById(rowViewId).setVisibility(View.GONE);
            return;
        }
        ((TextView) UiUtilities.getView(root, textViewId)).setText(text);
    }

    @Override
    public void onClick(View view) {
        if (!isMessageOpen()) {
            return; // Ignore.
        }
        switch (view.getId()) {
        case R.id.badge:
            onClickSender();
            break;
        case R.id.load:
            onLoadAttachment((MessageViewAttachmentInfo) view.getTag());
            break;
        case R.id.info:
            onInfoAttachment((MessageViewAttachmentInfo) view.getTag());
            break;
        case R.id.save:
            onSaveAttachment((MessageViewAttachmentInfo) view.getTag());
            break;
        case R.id.open:
            onOpenAttachment((MessageViewAttachmentInfo) view.getTag());
            break;
        case R.id.cancel:
            onCancelAttachment((MessageViewAttachmentInfo) view.getTag());
            break;
        case R.id.show_message:
            setCurrentTab(TAB_MESSAGE);
            break;
        case R.id.show_invite:
            setCurrentTab(TAB_INVITE);
            break;
        case R.id.show_attachments:
            setCurrentTab(TAB_ATTACHMENT);
            break;
        case R.id.show_pictures:
            showPicturesInHtml();
            break;
        case R.id.always_show_pictures_button:
            setShowImagesForSender();
            break;
        case R.id.sub_header_contents_collapsed:
            showDetails();
            break;
        case R.id.sub_header_contents_expanded:
            hideDetails();
            break;
        }
    }

    /**
     * Start loading contact photo and presence.
     */
    private void queryContactStatus() {
        if (!isMessageOpen())
            return;
        initContactStatusViews(); // Initialize the state, just in case.

        // Find the sender email address, and start presence check.
        Address sender = Address.unpackFirst(mMessage.mFrom);
        if (sender != null) {
            String email = sender.getAddress();
            if (email != null) {
                getLoaderManager().restartLoader(PHOTO_LOADER_ID,
                        ContactStatusLoaderCallbacks.createArguments(email),
                        new ContactStatusLoaderCallbacks(this));
            }
        }
    }

    /**
     * Called by {@link LoadMessageTask} and {@link ReloadMessageTask} to load a message in a
     * subclass specific way.
     *
     * NOTE This method is called on a worker thread!  Implementations must properly synchronize
     * when accessing members.
     *
     * @param activity the parent activity.  Subclass use it as a context, and to show a toast.
     */
    protected abstract Message openMessageSync(Activity activity);

    /**
     * Called in a background thread to reload a new copy of the Message in case something has
     * changed.
     */
    protected Message reloadMessageSync(Activity activity) {
        return openMessageSync(activity);
    }

    /**
     * Async task for loading a single message outside of the UI thread
     */
    private class LoadMessageTask extends EmailAsyncTask<Void, Void, Message> {

        private final boolean mOkToFetch;
        private Mailbox mMailbox;

        /**
         * Special constructor to cache some local info
         */
        public LoadMessageTask(boolean okToFetch) {
            super(mTaskTracker);
            mOkToFetch = okToFetch;
        }

        @Override
        protected Message doInBackground(Void... params) {
            Activity activity = getActivity();
            Message message = null;
            if (activity != null) {
                message = openMessageSync(activity);
            }
            if (message != null) {
                mMailbox = Mailbox.restoreMailboxWithId(mContext, message.mMailboxKey);
                if (mMailbox == null) {
                    message = null; // mailbox removed??
                }
            }
            return message;
        }

        @Override
        protected void onSuccess(Message message) {
            if (message == null) {
                resetView();
                mCallback.onMessageNotExists();
                return;
            }
            mMessageId = message.mId;

            reloadUiFromMessage(message, mOkToFetch);
            queryContactStatus();
            onMessageShown(mMessageId, mMailbox);
            RecentMailboxManager.getInstance(mContext).touch(mAccountId, message.mMailboxKey);
        }
    }

    /**
     * Kicked by {@link MessageObserver}.  Reload the message and update the views.
     */
    private class ReloadMessageTask extends EmailAsyncTask<Void, Void, Message> {
        public ReloadMessageTask() {
            super(mTaskTracker);
        }

        @Override
        protected Message doInBackground(Void... params) {
            Activity activity = getActivity();
            if (activity == null) {
                return null;
            } else {
                return reloadMessageSync(activity);
            }
        }

        @Override
        protected void onSuccess(Message message) {
            if (message == null || message.mMailboxKey != mMessage.mMailboxKey) {
                // Message deleted or moved.
                mCallback.onMessageNotExists();
                return;
            }
            mMessage = message;
            updateHeaderView(mMessage);
        }
    }

    /**
     * Called when a message is shown to the user.
     */
    protected void onMessageShown(long messageId, Mailbox mailbox) {
    }

    /**
     * Called when the message body is loaded.
     */
    protected void onPostLoadBody() {
    }

    /**
     * Async task for loading a single message body outside of the UI thread
     */
    private class LoadBodyTask extends EmailAsyncTask<Void, Void, String[]> {

        private final long mId;
        private boolean mErrorLoadingMessageBody;
        private final boolean mAutoShowPictures;

        /**
         * Special constructor to cache some local info
         */
        public LoadBodyTask(long messageId, boolean autoShowPictures) {
            super(mTaskTracker);
            mId = messageId;
            mAutoShowPictures = autoShowPictures;
        }

        @Override
        protected String[] doInBackground(Void... params) {
            try {
                String text = null;
                String html = Body.restoreBodyHtmlWithMessageId(mContext, mId);
                if (html == null) {
                    text = Body.restoreBodyTextWithMessageId(mContext, mId);
                }
                return new String[] { text, html };
            } catch (RuntimeException re) {
                // This catches SQLiteException as well as other RTE's we've seen from the
                // database calls, such as IllegalStateException
                Log.d(Logging.LOG_TAG, "Exception while loading message body", re);
                mErrorLoadingMessageBody = true;
                return null;
            }
        }

        @Override
        protected void onSuccess(String[] results) {
            if (results == null) {
                if (mErrorLoadingMessageBody) {
                    Utility.showToast(getActivity(), R.string.error_loading_message_body);
                }
                resetView();
                return;
            }
            reloadUiFromBody(results[0], results[1], mAutoShowPictures); // text, html
            onPostLoadBody();
        }
    }

    /**
     * Async task for loading attachments
     *
     * Note:  This really should only be called when the message load is complete - or, we should
     * leave open a listener so the attachments can fill in as they are discovered.  In either case,
     * this implementation is incomplete, as it will fail to refresh properly if the message is
     * partially loaded at this time.
     */
    private class LoadAttachmentsTask extends EmailAsyncTask<Long, Void, Attachment[]> {
        public LoadAttachmentsTask() {
            super(mTaskTracker);
        }

        @Override
        protected Attachment[] doInBackground(Long... messageIds) {
            return Attachment.restoreAttachmentsWithMessageId(mContext, messageIds[0]);
        }

        @Override
        protected void onSuccess(Attachment[] attachments) {
            try {
                if (attachments == null) {
                    return;
                }
                boolean htmlChanged = false;
                int numDisplayedAttachments = 0;
                for (Attachment attachment : attachments) {
                    if (mHtmlTextRaw != null && attachment.mContentId != null && attachment.mContentUri != null) {
                        // for html body, replace CID for inline images
                        // Regexp which matches ' src="cid:contentId"'.
                        String contentIdRe = "\\s+(?i)src=\"cid(?-i):\\Q" + attachment.mContentId + "\\E\"";
                        String srcContentUri = " src=\"" + attachment.mContentUri + "\"";
                        mHtmlTextRaw = mHtmlTextRaw.replaceAll(contentIdRe, srcContentUri);
                        htmlChanged = true;
                    } else {
                        addAttachment(attachment);
                        numDisplayedAttachments++;
                    }
                }
                setAttachmentCount(numDisplayedAttachments);
                mHtmlTextWebView = mHtmlTextRaw;
                mHtmlTextRaw = null;
                if (htmlChanged) {
                    setMessageHtml(mHtmlTextWebView);
                }
            } finally {
                showContent(true, false);
            }
        }
    }

    private static Bitmap getPreviewIcon(Context context, AttachmentInfo attachment) {
        try {
            return BitmapFactory.decodeStream(
                    context.getContentResolver().openInputStream(AttachmentUtilities.getAttachmentThumbnailUri(
                            attachment.mAccountKey, attachment.mId, PREVIEW_ICON_WIDTH, PREVIEW_ICON_HEIGHT)));
        } catch (Exception e) {
            Log.d(Logging.LOG_TAG, "Attachment preview failed with exception " + e.getMessage());
            return null;
        }
    }

    /**
     * Subclass of AttachmentInfo which includes our views and buttons related to attachment
     * handling, as well as our determination of suitability for viewing (based on availability of
     * a viewer app) and saving (based upon the presence of external storage)
     */
    private static class MessageViewAttachmentInfo extends AttachmentInfo {
        private Button openButton;
        private Button saveButton;
        private Button loadButton;
        private Button infoButton;
        private Button cancelButton;
        private ImageView iconView;

        private static final Map<AttachmentInfo, String> sSavedFileInfos = Maps.newHashMap();

        // Don't touch it directly from the outer class.
        private final ProgressBar mProgressView;
        private boolean loaded;

        private MessageViewAttachmentInfo(Context context, Attachment attachment, ProgressBar progressView) {
            super(context, attachment);
            mProgressView = progressView;
        }

        /**
         * Create a new attachment info based upon an existing attachment info. Display
         * related fields (such as views and buttons) are copied from old to new.
         */
        private MessageViewAttachmentInfo(Context context, MessageViewAttachmentInfo oldInfo) {
            super(context, oldInfo);
            openButton = oldInfo.openButton;
            saveButton = oldInfo.saveButton;
            loadButton = oldInfo.loadButton;
            infoButton = oldInfo.infoButton;
            cancelButton = oldInfo.cancelButton;
            iconView = oldInfo.iconView;
            mProgressView = oldInfo.mProgressView;
            loaded = oldInfo.loaded;
        }

        public void hideProgress() {
            // Don't use GONE, which'll break the layout.
            if (mProgressView.getVisibility() != View.INVISIBLE) {
                mProgressView.setVisibility(View.INVISIBLE);
            }
        }

        public void showProgress(int progress) {
            if (mProgressView.getVisibility() != View.VISIBLE) {
                mProgressView.setVisibility(View.VISIBLE);
            }
            if (mProgressView.isIndeterminate()) {
                mProgressView.setIndeterminate(false);
            }
            mProgressView.setProgress(progress);

            // Hide on completion.
            if (progress == 100) {
                hideProgress();
            }
        }

        public void showProgressIndeterminate() {
            if (mProgressView.getVisibility() != View.VISIBLE) {
                mProgressView.setVisibility(View.VISIBLE);
            }
            if (!mProgressView.isIndeterminate()) {
                mProgressView.setIndeterminate(true);
            }
        }

        /**
         * Determines whether or not this attachment has a saved file in the external storage. That
         * is, the user has at some point clicked "save" for this attachment.
         *
         * Note: this is an approximation and uses an in-memory cache that can get wiped when the
         * process dies, and so is somewhat conservative. Additionally, the user can modify the file
         * after saving, and so the file may not be the same (though this is unlikely).
         */
        public boolean isFileSaved() {
            String path = getSavedPath();
            if (path == null) {
                return false;
            }
            boolean savedFileExists = new File(path).exists();
            if (!savedFileExists) {
                // Purge the cache entry.
                setSavedPath(null);
            }
            return savedFileExists;
        }

        private void setSavedPath(String path) {
            if (path == null) {
                sSavedFileInfos.remove(this);
            } else {
                sSavedFileInfos.put(this, path);
            }
        }

        /**
         * Returns an absolute file path for the given attachment if it has been saved. If one is
         * not found, {@code null} is returned.
         *
         * Clients are expected to validate that the file at the given path is still valid.
         */
        private String getSavedPath() {
            return sSavedFileInfos.get(this);
        }

        @Override
        protected Uri getUriForIntent(Context context, long accountId) {
            // Prefer to act on the saved file for intents.
            String path = getSavedPath();
            return (path != null) ? Uri.parse("file://" + getSavedPath())
                    : super.getUriForIntent(context, accountId);
        }
    }

    /**
     * Updates all current attachments on the attachment tab.
     */
    private void updateAttachmentTab() {
        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
            View view = mAttachments.getChildAt(i);
            MessageViewAttachmentInfo oldInfo = (MessageViewAttachmentInfo) view.getTag();
            MessageViewAttachmentInfo newInfo = new MessageViewAttachmentInfo(getActivity(), oldInfo);
            updateAttachmentButtons(newInfo);
            view.setTag(newInfo);
        }
    }

    /**
     * Updates the attachment buttons. Adjusts the visibility of the buttons as well
     * as updating any tag information associated with the buttons.
     */
    private void updateAttachmentButtons(MessageViewAttachmentInfo attachmentInfo) {
        ImageView attachmentIcon = attachmentInfo.iconView;
        Button openButton = attachmentInfo.openButton;
        Button saveButton = attachmentInfo.saveButton;
        Button loadButton = attachmentInfo.loadButton;
        Button infoButton = attachmentInfo.infoButton;
        Button cancelButton = attachmentInfo.cancelButton;

        if (!attachmentInfo.mAllowView) {
            openButton.setVisibility(View.GONE);
        }
        if (!attachmentInfo.mAllowSave) {
            saveButton.setVisibility(View.GONE);
        }

        if (!attachmentInfo.mAllowView && !attachmentInfo.mAllowSave) {
            // This attachment may never be viewed or saved, so block everything
            attachmentInfo.hideProgress();
            openButton.setVisibility(View.GONE);
            saveButton.setVisibility(View.GONE);
            loadButton.setVisibility(View.GONE);
            cancelButton.setVisibility(View.GONE);
            infoButton.setVisibility(View.VISIBLE);
        } else if (attachmentInfo.loaded) {
            // If the attachment is loaded, show 100% progress
            // Note that for POP3 messages, the user will only see "Open" and "Save",
            // because the entire message is loaded before being shown.
            // Hide "Load" and "Info", show "View" and "Save"
            attachmentInfo.showProgress(100);
            if (attachmentInfo.mAllowSave) {
                saveButton.setVisibility(View.VISIBLE);

                boolean isFileSaved = attachmentInfo.isFileSaved();
                saveButton.setEnabled(!isFileSaved);
                if (!isFileSaved) {
                    saveButton.setText(R.string.message_view_attachment_save_action);
                } else {
                    saveButton.setText(R.string.message_view_attachment_saved);
                }
            }
            if (attachmentInfo.mAllowView) {
                // Set the attachment action button text accordingly
                if (attachmentInfo.mContentType.startsWith("audio/")
                        || attachmentInfo.mContentType.startsWith("video/")) {
                    openButton.setText(R.string.message_view_attachment_play_action);
                } else if (attachmentInfo.mAllowInstall) {
                    openButton.setText(R.string.message_view_attachment_install_action);
                } else {
                    openButton.setText(R.string.message_view_attachment_view_action);
                }
                openButton.setVisibility(View.VISIBLE);
            }
            if (attachmentInfo.mDenyFlags == AttachmentInfo.ALLOW) {
                infoButton.setVisibility(View.GONE);
            } else {
                infoButton.setVisibility(View.VISIBLE);
            }
            loadButton.setVisibility(View.GONE);
            cancelButton.setVisibility(View.GONE);

            updatePreviewIcon(attachmentInfo);
        } else {
            // The attachment is not loaded, so present UI to start downloading it

            // Show "Load"; hide "View", "Save" and "Info"
            saveButton.setVisibility(View.GONE);
            openButton.setVisibility(View.GONE);
            infoButton.setVisibility(View.GONE);

            // If the attachment is queued, show the indeterminate progress bar.  From this point,.
            // any progress changes will cause this to be replaced by the normal progress bar
            if (AttachmentDownloadService.isAttachmentQueued(attachmentInfo.mId)) {
                attachmentInfo.showProgressIndeterminate();
                loadButton.setVisibility(View.GONE);
                cancelButton.setVisibility(View.VISIBLE);
            } else {
                loadButton.setVisibility(View.VISIBLE);
                cancelButton.setVisibility(View.GONE);
            }
        }
        openButton.setTag(attachmentInfo);
        saveButton.setTag(attachmentInfo);
        loadButton.setTag(attachmentInfo);
        infoButton.setTag(attachmentInfo);
        cancelButton.setTag(attachmentInfo);
    }

    /**
     * Copy data from a cursor-refreshed attachment into the UI.  Called from UI thread.
     *
     * @param attachment A single attachment loaded from the provider
     */
    private void addAttachment(Attachment attachment) {
        LayoutInflater inflater = getActivity().getLayoutInflater();
        View view = inflater.inflate(R.layout.message_view_attachment, null);

        TextView attachmentName = (TextView) UiUtilities.getView(view, R.id.attachment_name);
        TextView attachmentInfoView = (TextView) UiUtilities.getView(view, R.id.attachment_info);
        ImageView attachmentIcon = (ImageView) UiUtilities.getView(view, R.id.attachment_icon);
        Button openButton = (Button) UiUtilities.getView(view, R.id.open);
        Button saveButton = (Button) UiUtilities.getView(view, R.id.save);
        Button loadButton = (Button) UiUtilities.getView(view, R.id.load);
        Button infoButton = (Button) UiUtilities.getView(view, R.id.info);
        Button cancelButton = (Button) UiUtilities.getView(view, R.id.cancel);
        ProgressBar attachmentProgress = (ProgressBar) UiUtilities.getView(view, R.id.progress);

        MessageViewAttachmentInfo attachmentInfo = new MessageViewAttachmentInfo(mContext, attachment,
                attachmentProgress);

        // Check whether the attachment already exists
        if (Utility.attachmentExists(mContext, attachment)) {
            attachmentInfo.loaded = true;
        }

        attachmentInfo.openButton = openButton;
        attachmentInfo.saveButton = saveButton;
        attachmentInfo.loadButton = loadButton;
        attachmentInfo.infoButton = infoButton;
        attachmentInfo.cancelButton = cancelButton;
        attachmentInfo.iconView = attachmentIcon;

        updateAttachmentButtons(attachmentInfo);

        view.setTag(attachmentInfo);
        openButton.setOnClickListener(this);
        saveButton.setOnClickListener(this);
        loadButton.setOnClickListener(this);
        infoButton.setOnClickListener(this);
        cancelButton.setOnClickListener(this);

        attachmentName.setText(attachmentInfo.mName);
        attachmentInfoView.setText(UiUtilities.formatSize(mContext, attachmentInfo.mSize));

        mAttachments.addView(view);
        mAttachments.setVisibility(View.VISIBLE);
    }

    private MessageViewAttachmentInfo findAttachmentInfoFromView(long attachmentId) {
        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
            MessageViewAttachmentInfo attachmentInfo = (MessageViewAttachmentInfo) mAttachments.getChildAt(i)
                    .getTag();
            if (attachmentInfo.mId == attachmentId) {
                return attachmentInfo;
            }
        }
        return null;
    }

    /**
     * Reload the UI from a provider cursor.  {@link LoadMessageTask#onSuccess} calls it.
     *
     * Update the header views, and start loading the body.
     *
     * @param message A copy of the message loaded from the database
     * @param okToFetch If true, and message is not fully loaded, it's OK to fetch from
     * the network.  Use false to prevent looping here.
     */
    protected void reloadUiFromMessage(Message message, boolean okToFetch) {
        mMessage = message;
        mAccountId = message.mAccountKey;

        mMessageObserver.register(ContentUris.withAppendedId(Message.CONTENT_URI, mMessage.mId));

        updateHeaderView(mMessage);

        // Handle partially-loaded email, as follows:
        // 1. Check value of message.mFlagLoaded
        // 2. If != LOADED, ask controller to load it
        // 3. Controller callback (after loaded) should trigger LoadBodyTask & LoadAttachmentsTask
        // 4. Else start the loader tasks right away (message already loaded)
        if (okToFetch && message.mFlagLoaded != Message.FLAG_LOADED_COMPLETE) {
            mControllerCallback.getWrappee().setWaitForLoadMessageId(message.mId);
            mController.loadMessageForView(message.mId);
        } else {
            Address[] fromList = Address.unpack(mMessage.mFrom);
            boolean autoShowImages = false;
            for (Address sender : fromList) {
                String email = sender.getAddress();
                if (shouldShowImagesFor(email)) {
                    autoShowImages = true;
                    break;
                }
            }
            mControllerCallback.getWrappee().setWaitForLoadMessageId(Message.NO_MESSAGE);
            // Ask for body
            new LoadBodyTask(message.mId, autoShowImages).executeParallel();
        }
    }

    protected void updateHeaderView(Message message) {
        mSubjectView.setText(message.mSubject);
        final Address from = Address.unpackFirst(message.mFrom);

        // Set sender address/display name
        // Note we set " " for empty field, so TextView's won't get squashed.
        // Otherwise their height will be 0, which breaks the layout.
        if (from != null) {
            final String fromFriendly = from.toFriendly();
            final String fromAddress = from.getAddress();
            mFromNameView.setText(fromFriendly);
            mFromAddressView.setText(fromFriendly.equals(fromAddress) ? " " : fromAddress);
        } else {
            mFromNameView.setText(" ");
            mFromAddressView.setText(" ");
        }
        mDateTimeView.setText(DateUtils.getRelativeTimeSpanString(mContext, message.mTimeStamp).toString());

        // To/Cc/Bcc
        final Resources res = mContext.getResources();
        final SpannableStringBuilder ssb = new SpannableStringBuilder();
        final String friendlyTo = Address.toFriendly(Address.unpack(message.mTo));
        final String friendlyCc = Address.toFriendly(Address.unpack(message.mCc));
        final String friendlyBcc = Address.toFriendly(Address.unpack(message.mBcc));

        if (!TextUtils.isEmpty(friendlyTo)) {
            Utility.appendBold(ssb, res.getString(R.string.message_view_to_label));
            ssb.append(" ");
            ssb.append(friendlyTo);
        }
        if (!TextUtils.isEmpty(friendlyCc)) {
            ssb.append("  ");
            Utility.appendBold(ssb, res.getString(R.string.message_view_cc_label));
            ssb.append(" ");
            ssb.append(friendlyCc);
        }
        if (!TextUtils.isEmpty(friendlyBcc)) {
            ssb.append("  ");
            Utility.appendBold(ssb, res.getString(R.string.message_view_bcc_label));
            ssb.append(" ");
            ssb.append(friendlyBcc);
        }
        mAddressesView.setText(ssb);
    }

    /**
     * @return the given date/time in a human readable form.  The returned string always have
     *     month and day (and year if {@code withYear} is set), so is usually long.
     *     Use {@link DateUtils#getRelativeTimeSpanString} instead to save the screen real estate.
     */
    private String formatDate(long millis, boolean withYear) {
        StringBuilder sb = new StringBuilder();
        Formatter formatter = new Formatter(sb);
        DateUtils.formatDateRange(mContext, formatter, millis, millis,
                DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_TIME
                        | (withYear ? DateUtils.FORMAT_SHOW_YEAR : DateUtils.FORMAT_NO_YEAR));
        return sb.toString();
    }

    /**
     * Reload the body from the provider cursor.  This must only be called from the UI thread.
     *
     * @param bodyText text part
     * @param bodyHtml html part
     *
     * TODO deal with html vs text and many other issues <- WHAT DOES IT MEAN??
     */
    private void reloadUiFromBody(String bodyText, String bodyHtml, boolean autoShowPictures) {
        String text = null;
        mHtmlTextRaw = null;
        boolean hasImages = false;

        if (bodyHtml == null) {
            text = bodyText;
            /*
             * Convert the plain text to HTML
             */
            StringBuffer sb = new StringBuffer("<html><body>");
            if (text != null) {
                // Escape any inadvertent HTML in the text message
                text = EmailHtmlUtil.escapeCharacterToDisplay(text);
                // Find any embedded URL's and linkify
                Matcher m = Patterns.WEB_URL.matcher(text);
                while (m.find()) {
                    int start = m.start();
                    /*
                     * WEB_URL_PATTERN may match domain part of email address. To detect
                     * this false match, the character just before the matched string
                     * should not be '@'.
                     */
                    if (start == 0 || text.charAt(start - 1) != '@') {
                        String url = m.group();
                        Matcher proto = WEB_URL_PROTOCOL.matcher(url);
                        String link;
                        if (proto.find()) {
                            // This is work around to force URL protocol part be lower case,
                            // because WebView could follow only lower case protocol link.
                            link = proto.group().toLowerCase() + url.substring(proto.end());
                        } else {
                            // Patterns.WEB_URL matches URL without protocol part,
                            // so added default protocol to link.
                            link = "http://" + url;
                        }
                        String href = String.format("<a href=\"%s\">%s</a>", link, url);
                        m.appendReplacement(sb, href);
                    } else {
                        m.appendReplacement(sb, "$0");
                    }
                }
                m.appendTail(sb);
            }
            sb.append("</body></html>");
            text = sb.toString();
        } else {
            text = bodyHtml;
            mHtmlTextRaw = bodyHtml;
            hasImages = IMG_TAG_START_REGEX.matcher(text).find();
        }

        // TODO this is not really accurate.
        // - Images aren't the only network resources.  (e.g. CSS)
        // - If images are attached to the email and small enough, we download them at once,
        //   and won't need network access when they're shown.
        if (hasImages) {
            if (mRestoredPictureLoaded || autoShowPictures) {
                blockNetworkLoads(false);
                addTabFlags(TAB_FLAGS_PICTURE_LOADED); // Set for next onSaveInstanceState

                // Make sure to reset the flag -- otherwise this will keep taking effect even after
                // moving to another message.
                mRestoredPictureLoaded = false;
            } else {
                addTabFlags(TAB_FLAGS_HAS_PICTURES);
            }
        }
        setMessageHtml(text);

        // Ask for attachments after body
        new LoadAttachmentsTask().executeParallel(mMessage.mId);

        mIsMessageLoadedForTest = true;
    }

    /**
     * Overrides for WebView behaviors.
     */
    private class CustomWebViewClient extends WebViewClient {
        @Override
        public boolean shouldOverrideUrlLoading(WebView view, String url) {
            return mCallback.onUrlInMessageClicked(url);
        }
    }

    private View findAttachmentView(long attachmentId) {
        for (int i = 0, count = mAttachments.getChildCount(); i < count; i++) {
            View view = mAttachments.getChildAt(i);
            MessageViewAttachmentInfo attachment = (MessageViewAttachmentInfo) view.getTag();
            if (attachment.mId == attachmentId) {
                return view;
            }
        }
        return null;
    }

    private MessageViewAttachmentInfo findAttachmentInfo(long attachmentId) {
        View view = findAttachmentView(attachmentId);
        if (view != null) {
            return (MessageViewAttachmentInfo) view.getTag();
        }
        return null;
    }

    /**
     * Controller results listener.  We wrap it with {@link ControllerResultUiThreadWrapper},
     * so all methods are called on the UI thread.
     */
    private class ControllerResults extends Controller.Result {
        private long mWaitForLoadMessageId;

        public void setWaitForLoadMessageId(long messageId) {
            mWaitForLoadMessageId = messageId;
        }

        @Override
        public void loadMessageForViewCallback(MessagingException result, long accountId, long messageId,
                int progress) {
            if (messageId != mWaitForLoadMessageId) {
                // We are not waiting for this message to load, so exit quickly
                return;
            }
            if (result == null) {
                switch (progress) {
                case 0:
                    mCallback.onLoadMessageStarted();
                    // Loading from network -- show the progress icon.
                    showContent(false, true);
                    break;
                case 100:
                    mWaitForLoadMessageId = -1;
                    mCallback.onLoadMessageFinished();
                    // reload UI and reload everything else too
                    // pass false to LoadMessageTask to prevent looping here
                    cancelAllTasks();
                    new LoadMessageTask(false).executeParallel();
                    break;
                default:
                    // do nothing - we don't have a progress bar at this time
                    break;
                }
            } else {
                mWaitForLoadMessageId = Message.NO_MESSAGE;
                String error = mContext.getString(R.string.status_network_error);
                mCallback.onLoadMessageError(error);
                resetView();
            }
        }

        @Override
        public void loadAttachmentCallback(MessagingException result, long accountId, long messageId,
                long attachmentId, int progress) {
            if (messageId == mMessageId) {
                if (result == null) {
                    showAttachmentProgress(attachmentId, progress);
                    switch (progress) {
                    case 100:
                        final MessageViewAttachmentInfo attachmentInfo = findAttachmentInfoFromView(attachmentId);
                        if (attachmentInfo != null) {
                            updatePreviewIcon(attachmentInfo);
                        }
                        doFinishLoadAttachment(attachmentId);
                        break;
                    default:
                        // do nothing - we don't have a progress bar at this time
                        break;
                    }
                } else {
                    MessageViewAttachmentInfo attachment = findAttachmentInfo(attachmentId);
                    if (attachment == null) {
                        // Called before LoadAttachmentsTask finishes.
                        // (Possible if you quickly close & re-open a message)
                        return;
                    }
                    attachment.cancelButton.setVisibility(View.GONE);
                    attachment.loadButton.setVisibility(View.VISIBLE);
                    attachment.hideProgress();

                    final String error;
                    if (result.getCause() instanceof IOException) {
                        error = mContext.getString(R.string.status_network_error);
                    } else {
                        error = mContext.getString(R.string.message_view_load_attachment_failed_toast,
                                attachment.mName);
                    }
                    mCallback.onLoadMessageError(error);
                }
            }
        }

        private void showAttachmentProgress(long attachmentId, int progress) {
            MessageViewAttachmentInfo attachment = findAttachmentInfo(attachmentId);
            if (attachment != null) {
                if (progress == 0) {
                    attachment.cancelButton.setVisibility(View.GONE);
                }
                attachment.showProgress(progress);
            }
        }
    }

    /**
     * Class to detect update on the current message (e.g. toggle star).  When it gets content
     * change notifications, it kicks {@link ReloadMessageTask}.
     */
    private class MessageObserver extends ContentObserver implements Runnable {
        private final Throttle mThrottle;
        private final ContentResolver mContentResolver;

        private boolean mRegistered;

        public MessageObserver(Handler handler, Context context) {
            super(handler);
            mContentResolver = context.getContentResolver();
            mThrottle = new Throttle("MessageObserver", this, handler);
        }

        public void unregister() {
            if (!mRegistered) {
                return;
            }
            mThrottle.cancelScheduledCallback();
            mContentResolver.unregisterContentObserver(this);
            mRegistered = false;
        }

        public void register(Uri notifyUri) {
            unregister();
            mContentResolver.registerContentObserver(notifyUri, true, this);
            mRegistered = true;
        }

        @Override
        public boolean deliverSelfNotifications() {
            return true;
        }

        @Override
        public void onChange(boolean selfChange) {
            if (mRegistered) {
                mThrottle.onEvent();
            }
        }

        /** This method is delay-called by {@link Throttle} on the UI thread. */
        @Override
        public void run() {
            // This method is delay-called, so need to make sure if it's still registered.
            if (mRegistered) {
                new ReloadMessageTask().cancelPreviousAndExecuteParallel();
            }
        }
    }

    private void updatePreviewIcon(MessageViewAttachmentInfo attachmentInfo) {
        new UpdatePreviewIconTask(attachmentInfo).executeParallel();
    }

    private class UpdatePreviewIconTask extends EmailAsyncTask<Void, Void, Bitmap> {
        @SuppressWarnings("hiding")
        private final Context mContext;
        private final MessageViewAttachmentInfo mAttachmentInfo;

        public UpdatePreviewIconTask(MessageViewAttachmentInfo attachmentInfo) {
            super(mTaskTracker);
            mContext = getActivity();
            mAttachmentInfo = attachmentInfo;
        }

        @Override
        protected Bitmap doInBackground(Void... params) {
            return getPreviewIcon(mContext, mAttachmentInfo);
        }

        @Override
        protected void onSuccess(Bitmap result) {
            if (result == null) {
                return;
            }
            mAttachmentInfo.iconView.setImageBitmap(result);
        }
    }

    private boolean shouldShowImagesFor(String senderEmail) {
        return Preferences.getPreferences(getActivity()).shouldShowImagesFor(senderEmail);
    }

    private void setShowImagesForSender() {
        makeVisible(UiUtilities.getView(getView(), R.id.always_show_pictures_button), false);
        Utility.showToast(getActivity(), R.string.message_view_always_show_pictures_confirmation);

        // Force redraw of the container.
        updateTabs(mTabFlags);

        Address[] fromList = Address.unpack(mMessage.mFrom);
        Preferences prefs = Preferences.getPreferences(getActivity());
        for (Address sender : fromList) {
            String email = sender.getAddress();
            prefs.setSenderAsTrusted(email);
        }
    }

    public boolean isMessageLoadedForTest() {
        return mIsMessageLoadedForTest;
    }

    public void clearIsMessageLoadedForTest() {
        mIsMessageLoadedForTest = true;
    }
}