attachment UI in conversation view

Load from a message's attachment list URI to render initial
attachment views. Each attachment view then monitors its own
URI for changes when it expects them (this is quite likely
too granular!).

Change-Id: Ie559672e63910034e4dbf7766101a2b5768129aa
diff --git a/res/layout/conversation_message_attachment.xml b/res/layout/conversation_message_attachment.xml
new file mode 100644
index 0000000..db9463f
--- /dev/null
+++ b/res/layout/conversation_message_attachment.xml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2012 Google Inc.
+     Licensed to 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.
+-->
+<com.android.mail.browse.MessageHeaderAttachment xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:layout_marginTop="8dp"
+    android:orientation="vertical"
+    android:divider="?android:attr/dividerHorizontal"
+    android:showDividers="middle"
+    android:background="@drawable/attachment_bg_holo">
+
+    <RelativeLayout
+        android:layout_width="match_parent"
+        android:layout_height="48dp">
+
+        <ImageView
+            android:id="@+id/attachment_icon"
+            android:layout_width="48dp"
+            android:layout_height="match_parent"
+            android:scaleType="fitCenter"
+            android:background="#e5e5e5" />
+
+        <RelativeLayout
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:layout_toRightOf="@id/attachment_icon"
+            android:gravity="center_vertical"
+            android:layout_marginLeft="16dp"
+            android:layout_marginRight="16dp">
+
+            <TextView
+                android:id="@+id/attachment_title"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:singleLine="true" />
+
+            <TextView
+                android:id="@+id/attachment_subtitle"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_below="@id/attachment_title"
+                android:singleLine="true" />
+
+            <ProgressBar
+                android:id="@+id/attachment_progress"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_below="@id/attachment_title"
+                style="?android:attr/progressBarStyleHorizontal"
+                android:indeterminate="false"
+                android:visibility="gone" />
+
+        </RelativeLayout>
+
+    </RelativeLayout>
+
+    <LinearLayout
+            android:id="@+id/attachment_buttons"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:divider="?android:attr/dividerVertical"
+            android:showDividers="middle"
+            android:dividerPadding="8dp"
+            android:orientation="horizontal">
+        <include layout="@layout/conversation_message_attachment_buttons" />
+    </LinearLayout>
+
+</com.android.mail.browse.MessageHeaderAttachment>
diff --git a/res/layout/conversation_message_attachment_buttons.xml b/res/layout/conversation_message_attachment_buttons.xml
new file mode 100644
index 0000000..9ef8504
--- /dev/null
+++ b/res/layout/conversation_message_attachment_buttons.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2012 Google Inc.
+     Licensed to 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.
+-->
+<merge xmlns:android="http://schemas.android.com/apk/res/android" >
+
+    <Button
+        android:id="@+id/preview_attachment"
+        android:text="@string/preview_attachment"
+        style="@style/message_attachment_button" />
+
+    <Button
+        android:id="@+id/view_attachment"
+        android:text="@string/view_attachment"
+        style="@style/message_attachment_button" />
+
+    <Button
+        android:id="@+id/save_attachment"
+        android:text="@string/save_attachment"
+        style="@style/message_attachment_button" />
+
+    <Button
+        android:id="@+id/info_attachment"
+        android:text="@string/info_attachment"
+        style="@style/message_attachment_button" />
+
+    <Button
+        android:id="@+id/play_attachment"
+        android:text="@string/play_attachment"
+        style="@style/message_attachment_button" />
+
+    <Button
+        android:id="@+id/install_attachment"
+        android:text="@string/install_attachment"
+        style="@style/message_attachment_button" />
+
+    <Button
+        android:id="@+id/cancel_attachment"
+        android:text="@string/cancel_attachment"
+        style="@style/message_attachment_button" />
+
+</merge>
diff --git a/res/layout/conversation_message_attachments.xml b/res/layout/conversation_message_attachments.xml
new file mode 100644
index 0000000..6bf6862
--- /dev/null
+++ b/res/layout/conversation_message_attachments.xml
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+     Copyright (C) 2012 Google Inc.
+     Licensed to 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.
+-->
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/attachments"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:layout_marginTop="8dp"
+    android:layout_marginLeft="16dp"
+    android:layout_marginRight="16dp">
+</LinearLayout>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 9762ae1..1a926c7 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -198,6 +198,34 @@
     <string name="attachment_application_pdf">PDF</string>
     <!-- Attachment description for unknown files [CHAR LIMIT=30]-->
     <string name="attachment_unknown"><xliff:g id="attachmentExtension">%s</xliff:g> File</string>
+    <!-- Read email screen, button name. Preview an attachment by Gview. [CHAR LIMIT=10] -->
+    <string name="preview_attachment">Preview</string>
+    <!-- Read email screen, button name. View an attachment by an application on device. [CHAR LIMIT=10] -->
+    <string name="view_attachment">View</string>
+    <!-- Read email screen, button name. Save an attachment to sd card. [CHAR LIMIT=10] -->
+    <string name="save_attachment">Save</string>
+    <!-- Read email screen, button name. Open a dialog to explain that no application found to view the attachment. [CHAR LIMIT=10] -->
+    <string name="info_attachment">Info</string>
+    <!-- Read email screen, button name. Play a video or audio attachment. [CHAR LIMIT=10] -->
+    <string name="play_attachment">Play</string>
+    <!-- Read email screen, button name. Install an apk attachment. [CHAR LIMIT=10] -->
+    <string name="install_attachment">Install</string>
+    <!-- Read email screen, button name. Cancel a downloading attachment. [CHAR LIMIT=10] -->
+    <string name="cancel_attachment">Cancel</string>
+    <!-- Dialog box title [CHAR LIMIT=30] -->
+    <string name="more_info_attachment">Info</string>
+    <!-- Dialog box message, displayed when we block downloading an attachment due to security concerns. [CHAR LIMIT=200]-->
+    <string name="attachment_type_blocked">Can\'t save or open this type of attachment because it could contain malicious software.</string>
+    <!-- Dialog box message, displayed when we could not view an attachment. [CHAR LIMIT=200]-->
+    <string name="no_application_found">No app can open this attachment for viewing.</string>
+    <!-- Dialog box title. [CHAR LIMIT=30] -->
+    <string name="fetching_attachment">Fetching attachment</string>
+    <!-- Dialog box message. [CHAR LIMIT=80] -->
+    <string name="please_wait">Please wait\u2026</string>
+    <!-- Displayed in the conversation view. Status of a saved attachment. [CHAR LIMIT=20]-->
+    <string name="saved">Saved,&#160;</string>
+    <!-- Displayed in the conversation view. Status of a failed attachment. [CHAR LIMIT=20]-->
+    <string name="download_failed">Couldn\'t download.</string>
 
     <!-- Webview Context Menu Strings -->
     <!-- Title of dialog for choosing which activity to share a link with. [CHAR LIMIT=50]-->
diff --git a/res/values/styles.xml b/res/values/styles.xml
index aea2bf7..b923623 100644
--- a/res/values/styles.xml
+++ b/res/values/styles.xml
@@ -306,6 +306,20 @@
         <item name="android:src">@drawable/ic_menu_expander_maximized_holo_light</item>
         <item name="android:contentDescription">@string/collapse_recipient_details</item>
     </style>
+    <style name="message_attachment_button_base" parent="@android:style/Widget.Holo.Button.Borderless">
+        <item name="android:layout_width">wrap_content</item>
+        <item name="android:minHeight">0dip</item>
+        <item name="android:textAppearance">?android:attr/textAppearanceSmall</item>
+        <item name="android:textStyle">bold</item>
+        <item name="android:textColor">#777777</item>
+        <item name="android:singleLine">true</item>
+        <item name="android:textAllCaps">true</item>
+    </style>
+    <style name="message_attachment_button" parent="message_attachment_button_base">
+        <item name="android:padding">8dp</item>
+        <item name="android:layout_height">wrap_content</item>
+        <item name="android:layout_weight">1</item>
+    </style>
     <!-- End conversation view message header styles -->
 
     <!-- Folder styles -->
diff --git a/src/com/android/mail/browse/AttachmentLoader.java b/src/com/android/mail/browse/AttachmentLoader.java
new file mode 100644
index 0000000..252c6ba
--- /dev/null
+++ b/src/com/android/mail/browse/AttachmentLoader.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2012 Google Inc.
+ * Licensed to 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.android.mail.browse;
+
+import com.google.common.collect.Maps;
+
+import android.content.Context;
+import android.content.CursorLoader;
+import android.database.Cursor;
+import android.database.CursorWrapper;
+import android.net.Uri;
+
+import com.android.mail.providers.Attachment;
+import com.android.mail.providers.UIProvider;
+
+import java.util.Map;
+
+public class AttachmentLoader extends CursorLoader {
+
+    public AttachmentLoader(Context c, Uri uri) {
+        super(c, uri, UIProvider.ATTACHMENT_PROJECTION, null, null, null);
+    }
+
+    @Override
+    public Cursor loadInBackground() {
+        return new AttachmentCursor(super.loadInBackground());
+    }
+
+    public static class AttachmentCursor extends CursorWrapper {
+
+        private Map<Long, Attachment> mCache = Maps.newHashMap();
+
+        private AttachmentCursor(Cursor inner) {
+            super(inner);
+        }
+
+        public Attachment get() {
+            long id = getWrappedCursor().getLong(0);
+            Attachment m = mCache.get(id);
+            if (m == null) {
+                m = new Attachment(this);
+                mCache.put(id, m);
+            }
+            return m;
+        }
+    }
+}
diff --git a/src/com/android/mail/browse/MessageHeaderAttachment.java b/src/com/android/mail/browse/MessageHeaderAttachment.java
new file mode 100644
index 0000000..20cc845
--- /dev/null
+++ b/src/com/android/mail/browse/MessageHeaderAttachment.java
@@ -0,0 +1,428 @@
+/*
+ * Copyright (C) 2012 Google Inc.
+ * Licensed to 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.android.mail.browse;
+
+import android.app.AlertDialog;
+import android.app.LoaderManager;
+import android.app.ProgressDialog;
+import android.content.ActivityNotFoundException;
+import android.content.AsyncQueryHandler;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.Loader;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.AttributeSet;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.PopupMenu.OnMenuItemClickListener;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+
+import com.android.mail.R;
+import com.android.mail.browse.AttachmentLoader.AttachmentCursor;
+import com.android.mail.providers.Attachment;
+import com.android.mail.providers.UIProvider.AttachmentColumns;
+import com.android.mail.providers.UIProvider.AttachmentDestination;
+import com.android.mail.providers.UIProvider.AttachmentState;
+import com.android.mail.utils.AttachmentUtils;
+import com.android.mail.utils.LogUtils;
+import com.android.mail.utils.MimeType;
+import com.android.mail.utils.Utils;
+
+/**
+ * View for a single attachment in conversation view. Shows download status and allows launching
+ * intents to act on an attachment.
+ *
+ */
+public class MessageHeaderAttachment extends LinearLayout implements OnClickListener,
+        OnMenuItemClickListener, DialogInterface.OnCancelListener,
+        DialogInterface.OnDismissListener, LoaderManager.LoaderCallbacks<Cursor> {
+
+    private LoaderManager mLoaderManager;
+    private Attachment mAttachment;
+    private ImageView mIcon;
+    private TextView mTitle;
+    private TextView mSubTitle;
+    private String mAttachmentSizeText;
+    private String mDisplayType;
+    private ProgressDialog mViewProgressDialog;
+    private AttachmentCommandHandler mCommandHandler;
+    private ProgressBar mProgress;
+    private Button mPreviewButton;
+    private Button mViewButton;
+    private Button mSaveButton;
+    private Button mInfoButton;
+    private Button mPlayButton;
+    private Button mInstallButton;
+    private Button mCancelButton;
+
+    private static final String LOG_TAG = new LogUtils().getLogTag();
+
+    private class AttachmentCommandHandler extends AsyncQueryHandler {
+
+        public AttachmentCommandHandler() {
+            super(getContext().getContentResolver());
+        }
+
+        /**
+         * Asynchronously begin an update() on a ContentProvider and initialize a loader to watch
+         * for resulting changes on this attachment.
+         *
+         */
+        public void sendCommand(ContentValues params) {
+            startUpdate(0, null, mAttachment.uri, params, null, null);
+            mLoaderManager.initLoader(mAttachment.uri.hashCode(), Bundle.EMPTY,
+                    MessageHeaderAttachment.this);
+        }
+
+    }
+
+    public MessageHeaderAttachment(Context context) {
+        super(context);
+    }
+
+    public MessageHeaderAttachment(Context context, AttributeSet attrs) {
+        super(context, attrs);
+
+        mCommandHandler = new AttachmentCommandHandler();
+    }
+
+    public static MessageHeaderAttachment inflate(LayoutInflater inflater, ViewGroup parent,
+            LoaderManager loaderManager) {
+        MessageHeaderAttachment view = (MessageHeaderAttachment) inflater.inflate(
+                R.layout.conversation_message_attachment, parent, false);
+
+        view.mLoaderManager = loaderManager;
+
+        return view;
+    }
+
+    /**
+     * Render most of the UI using given immutable attachment properties. This happens immediately
+     * upon instantiation.
+     *
+     */
+    public void render(Attachment attachment) {
+        mAttachment = attachment;
+
+        mTitle.setText(attachment.name);
+
+        mAttachmentSizeText = AttachmentUtils.convertToHumanReadableSize(getContext(),
+                attachment.size);
+        mDisplayType = AttachmentUtils.getDisplayType(getContext(), attachment);
+        updateSubtitleText(null);
+
+        if (mAttachment.isImage() && mAttachment.thumbnailUri != null) {
+            // FIXME: this decodes on the UI thread. Also, it doesn't handle large images, so
+            // using the full image is out of the question.
+            mIcon.setImageURI(mAttachment.thumbnailUri);
+        }
+        if (mIcon.getDrawable() == null) {
+            // not an image, or image load failed. fall back to default.
+            mIcon.setImageResource(R.drawable.ic_menu_attachment_holo_light);
+            mIcon.setScaleType(ImageView.ScaleType.CENTER);
+        }
+
+        mProgress.setMax(attachment.size);
+
+        updateActions();
+
+        if (mAttachment.isDownloading()) {
+            mLoaderManager.initLoader(mAttachment.uri.hashCode(), Bundle.EMPTY, this);
+            // TODO: clean up loader when the view is detached
+        }
+    }
+
+    private void updateStatus(Attachment newAttachment) {
+
+        LogUtils.d(LOG_TAG, "got attachment update: uri=%s dled=%d state=%d contentUri=%s MIME=%s",
+                newAttachment.uri, newAttachment.downloadedSize, newAttachment.state,
+                newAttachment.contentUri, newAttachment.mimeType);
+
+        mAttachment = newAttachment;
+
+        final boolean showProgress = newAttachment.size > 0 && newAttachment.downloadedSize > 0
+                && newAttachment.downloadedSize < newAttachment.size;
+
+        if (mViewProgressDialog != null && mViewProgressDialog.isShowing()) {
+            mViewProgressDialog.setProgress(newAttachment.downloadedSize);
+            mViewProgressDialog.setIndeterminate(showProgress);
+
+            if (!newAttachment.isDownloading()) {
+                mViewProgressDialog.dismiss();
+            }
+
+            if (newAttachment.state == AttachmentState.SAVED) {
+                sendViewIntent();
+            }
+        } else {
+
+            if (newAttachment.isDownloading()) {
+                mProgress.setProgress(newAttachment.downloadedSize);
+                setProgressVisible(true);
+            } else {
+                setProgressVisible(false);
+            }
+
+        }
+
+        if (newAttachment.state == AttachmentState.FAILED) {
+            mSubTitle.setText(getResources().getString(R.string.download_failed));
+        } else {
+            updateSubtitleText(newAttachment.isSavedToExternal() ?
+                    getResources().getString(R.string.saved) : null);
+        }
+
+        updateActions();
+    }
+
+    private void setProgressVisible(boolean visible) {
+        if (visible) {
+            mProgress.setVisibility(VISIBLE);
+            mSubTitle.setVisibility(INVISIBLE);
+        } else {
+            mProgress.setVisibility(GONE);
+            mSubTitle.setVisibility(VISIBLE);
+        }
+    }
+
+    private void updateSubtitleText(String prefix) {
+        // TODO: make this a formatted resource when we have a UX design.
+        // not worth translation right now.
+        StringBuilder sb = new StringBuilder();
+        if (prefix != null) {
+            sb.append(prefix);
+        }
+        sb.append(mAttachmentSizeText);
+        sb.append(' ');
+        sb.append(mDisplayType);
+        mSubTitle.setText(sb.toString());
+    }
+
+    @Override
+    protected void onFinishInflate() {
+        super.onFinishInflate();
+
+        mIcon = (ImageView) findViewById(R.id.attachment_icon);
+        mTitle = (TextView) findViewById(R.id.attachment_title);
+        mSubTitle = (TextView) findViewById(R.id.attachment_subtitle);
+        mProgress = (ProgressBar) findViewById(R.id.attachment_progress);
+
+        mPreviewButton = (Button) findViewById(R.id.preview_attachment);
+        mViewButton = (Button) findViewById(R.id.view_attachment);
+        mSaveButton = (Button) findViewById(R.id.save_attachment);
+        mInfoButton = (Button) findViewById(R.id.info_attachment);
+        mPlayButton = (Button) findViewById(R.id.play_attachment);
+        mInstallButton = (Button) findViewById(R.id.install_attachment);
+        mCancelButton = (Button) findViewById(R.id.cancel_attachment);
+
+        setOnClickListener(this);
+        mPreviewButton.setOnClickListener(this);
+        mViewButton.setOnClickListener(this);
+        mSaveButton.setOnClickListener(this);
+        mInfoButton.setOnClickListener(this);
+        mPlayButton.setOnClickListener(this);
+        mInstallButton.setOnClickListener(this);
+        mCancelButton.setOnClickListener(this);
+    }
+
+    @Override
+    public void onClick(View v) {
+        onClick(v.getId(), v);
+    }
+
+    @Override
+    public boolean onMenuItemClick(MenuItem item) {
+        return onClick(item.getItemId(), null);
+    }
+
+    private boolean onClick(int res, View v) {
+        switch (res) {
+            case R.id.preview_attachment:
+                getContext().startActivity(mAttachment.previewIntent);
+                break;
+            case R.id.view_attachment:
+            case R.id.play_attachment:
+                showAttachment(AttachmentDestination.CACHE);
+                break;
+            case R.id.save_attachment:
+                if (mAttachment.canSave()) {
+                    startDownloadingAttachment(AttachmentDestination.EXTERNAL);
+                }
+                break;
+            case R.id.info_attachment:
+                AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
+                int dialogMessage = MimeType.isBlocked(mAttachment.mimeType)
+                        ? R.string.attachment_type_blocked : R.string.no_application_found;
+                builder.setTitle(R.string.more_info_attachment).setMessage(dialogMessage).show();
+                break;
+            case R.id.install_attachment:
+                showAttachment(AttachmentDestination.EXTERNAL);
+                break;
+            case R.id.cancel_attachment:
+                cancelAttachment();
+                break;
+            default:
+                // entire attachment view is clickable.
+                // TODO: this should execute a default action
+                break;
+        }
+        return true;
+    }
+
+    private void showAttachment(int destination) {
+        if (mAttachment.isPresentLocally()) {
+            sendViewIntent();
+        } else {
+            showDownloadingDialog();
+            startDownloadingAttachment(destination);
+        }
+    }
+
+    @Override
+    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+        return new AttachmentLoader(getContext(), mAttachment.uri);
+    }
+
+    @Override
+    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+        AttachmentCursor cursor = (AttachmentCursor) data;
+        if (cursor == null || cursor.isClosed() || cursor.getCount() == 0) {
+            return;
+        }
+        cursor.moveToFirst();
+        updateStatus(cursor.get());
+    }
+
+    @Override
+    public void onLoaderReset(Loader<Cursor> loader) {
+        // Do nothing.
+    }
+
+    private void startDownloadingAttachment(int destination) {
+        final ContentValues params = new ContentValues(2);
+        params.put(AttachmentColumns.STATE, AttachmentState.DOWNLOADING);
+        params.put(AttachmentColumns.DESTINATION, destination);
+
+        mCommandHandler.sendCommand(params);
+    }
+
+    private void cancelAttachment() {
+        final ContentValues params = new ContentValues(1);
+        params.put(AttachmentColumns.STATE, AttachmentState.NOT_SAVED);
+
+        mCommandHandler.sendCommand(params);
+    }
+
+    private void setButtonVisible(View button, boolean visible) {
+        button.setVisibility(visible ? VISIBLE : GONE);
+    }
+
+    /**
+     * Update all action buttons based on current downloading state.
+     */
+    private void updateActions() {
+        // To avoid visibility state transition bugs, every button's visibility should be touched
+        // once by this routine.
+
+        final boolean isDownloading = mAttachment.isDownloading();
+
+        setButtonVisible(mCancelButton, isDownloading);
+
+        final boolean canInstall = MimeType.isInstallable(mAttachment.mimeType);
+        setButtonVisible(mInstallButton, canInstall && !isDownloading);
+
+        if (!canInstall) {
+
+            final boolean canPreview = (mAttachment.previewIntent != null);
+            final boolean canView = MimeType.isViewable(getContext(), mAttachment.mimeType);
+            final boolean canPlay = MimeType.isPlayable(mAttachment.mimeType);
+
+            setButtonVisible(mPreviewButton, canPreview);
+            setButtonVisible(mPlayButton, canView && canPlay && !isDownloading);
+            setButtonVisible(mViewButton, canView && !canPlay && !isDownloading);
+            setButtonVisible(mSaveButton, canView && mAttachment.canSave() && !isDownloading);
+            setButtonVisible(mInfoButton, !(canPreview || canView));
+
+        } else {
+
+            setButtonVisible(mPreviewButton, false);
+            setButtonVisible(mPlayButton, false);
+            setButtonVisible(mViewButton, false);
+            setButtonVisible(mSaveButton, false);
+            setButtonVisible(mInfoButton, false);
+
+        }
+    }
+
+    /**
+     * View an attachment by an application on device.
+     */
+    private void sendViewIntent() {
+        Intent intent = new Intent(Intent.ACTION_VIEW);
+        intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
+                | Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET);
+        Utils.setIntentDataAndTypeAndNormalize(intent, Uri.parse(mAttachment.contentUri),
+                mAttachment.mimeType);
+        try {
+            getContext().startActivity(intent);
+        } catch (ActivityNotFoundException e) {
+            // couldn't find activity for View intent
+            LogUtils.e(LOG_TAG, "Coun't find Activity for intent", e);
+        }
+    }
+
+    /**
+     * Displays a loading dialog to be used for downloading attachments.
+     * Must be called on the UI thread.
+     */
+    private void showDownloadingDialog() {
+        mViewProgressDialog = new ProgressDialog(getContext());
+        mViewProgressDialog.setTitle(R.string.fetching_attachment);
+        mViewProgressDialog.setMessage(getResources().getString(R.string.please_wait));
+        mViewProgressDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
+        mViewProgressDialog.setMax(mAttachment.size);
+        mViewProgressDialog.setOnDismissListener(this);
+        mViewProgressDialog.setOnCancelListener(this);
+        mViewProgressDialog.show();
+
+        // The progress number format needs to be set after the dialog is shown.  See bug: 5149918
+        mViewProgressDialog.setProgressNumberFormat(null);
+    }
+
+    @Override
+    public void onDismiss(DialogInterface dialog) {
+        mViewProgressDialog = null;
+    }
+
+    @Override
+    public void onCancel(DialogInterface dialog) {
+        cancelAttachment();
+    }
+
+}
diff --git a/src/com/android/mail/browse/MessageHeaderView.java b/src/com/android/mail/browse/MessageHeaderView.java
index f429a97..2cbb91e 100644
--- a/src/com/android/mail/browse/MessageHeaderView.java
+++ b/src/com/android/mail/browse/MessageHeaderView.java
@@ -18,9 +18,13 @@
 
 import com.google.common.annotations.VisibleForTesting;
 
+import android.app.LoaderManager;
 import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
 import android.graphics.Canvas;
 import android.graphics.Typeface;
+import android.os.Bundle;
 import android.provider.ContactsContract;
 import android.text.Spannable;
 import android.text.SpannableStringBuilder;
@@ -44,6 +48,7 @@
 import com.android.mail.FormattedDateBuilder;
 import com.android.mail.R;
 import com.android.mail.SenderInfoLoader.ContactInfo;
+import com.android.mail.browse.AttachmentLoader.AttachmentCursor;
 import com.android.mail.compose.ComposeActivity;
 import com.android.mail.perf.Timer;
 import com.android.mail.providers.Account;
@@ -56,11 +61,9 @@
 
 import java.io.IOException;
 import java.io.StringReader;
-import java.util.List;
 
-// TODO: this will probably becomes the message header view?
 public class MessageHeaderView extends LinearLayout implements OnClickListener,
-        OnMenuItemClickListener, HeaderBlock {
+        OnMenuItemClickListener, HeaderBlock, LoaderManager.LoaderCallbacks<Cursor> {
 
     /**
      * Cap very long recipient lists during summary construction for efficiency.
@@ -86,7 +89,6 @@
     private MessageHeaderViewCallbacks mCallbacks;
     private long mLocalMessageId = UIProvider.INVALID_CONVERSATION_ID;
     private long mServerMessageId;
-    private long mConversationId;
     private boolean mSizeChanged;
 
     private TextView mSenderNameView;
@@ -97,6 +99,7 @@
     private ViewGroup mCollapsedDetailsView;
     private ViewGroup mExpandedDetailsView;
     private ViewGroup mImagePromptView;
+    private ViewGroup mAttachmentsView;
     private View mBottomBorderView;
     private ImageView mPresenceView;
 
@@ -140,9 +143,9 @@
     private int mDrawTranslateY;
 
     /**
-     * List of attachments for this message. Will not be null.
+     * List of attachments for this message, loaded asynchronously.
      */
-    private List<Attachment> mAttachments;
+    private AttachmentCursor mAttachments;
 
     private CharSequence mTimestampShort;
 
@@ -168,6 +171,10 @@
     private boolean mCollapsedDetailsValid;
     private boolean mExpandedDetailsValid;
 
+    private LoaderManager mLoaderManager;
+
+    private final LayoutInflater mInflater;
+
     public MessageHeaderView(Context context) {
         this(context, null);
     }
@@ -178,6 +185,8 @@
 
     public MessageHeaderView(Context context, AttributeSet attrs, int defStyle) {
         super(context, attrs, defStyle);
+
+        mInflater = LayoutInflater.from(context);
     }
 
     @Override
@@ -315,10 +324,12 @@
         updateChildVisibility();
     }
 
-    public void initialize(FormattedDateBuilder dateBuilder, Account account, boolean expanded,
-            boolean showImagePrompt, boolean defaultReplyAll) {
+    public void initialize(FormattedDateBuilder dateBuilder, Account account,
+            LoaderManager loaderManager, boolean expanded, boolean showImagePrompt,
+            boolean defaultReplyAll) {
         mDateBuilder = dateBuilder;
         mAccount = account;
+        mLoaderManager = loaderManager;
         setExpanded(expanded);
         mShowImagePrompt = showImagePrompt;
         mDefaultReplyAll = defaultReplyAll;
@@ -334,7 +345,6 @@
         mMessage = message;
         mLocalMessageId = mMessage.id;
         mServerMessageId = mMessage.serverId;
-        mConversationId = mMessage.conversationId;
         if (mCallbacks != null) {
             mCallbacks.onHeaderCreated(mLocalMessageId);
         }
@@ -351,6 +361,12 @@
         mBcc = getBccAddresses(mMessage);
         mReplyTo = Utils.splitCommaSeparatedString(mMessage.replyTo);
 
+        // kick off load of Attachment objects in background thread
+        if (mMessage.hasAttachments) {
+            mLoaderManager.initLoader(mMessage.hashCode(), Bundle.EMPTY, this);
+            // TODO: clean up loader when the view is detached
+        }
+
         /**
          * Turns draft mode on or off. Draft mode hides message operations other
          * than "edit", hides contact photo, hides presence, and changes the
@@ -409,6 +425,33 @@
         return h;
     }
 
+    // Attachment list loader methods
+
+    @Override
+    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
+        return new AttachmentLoader(getContext(), mMessage.attachmentListUri);
+    }
+
+    @Override
+    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
+        mAttachments = (AttachmentCursor) data;
+
+        if (mAttachmentsView != null) {
+            mAttachmentsView.removeAllViews();
+        }
+
+        if (mAttachments == null || mAttachments.isClosed()) {
+            return;
+        }
+
+        renderAttachments(mAttachmentsView);
+    }
+
+    @Override
+    public void onLoaderReset(Loader<Cursor> loader) {
+        // Do nothing.
+    }
+
     private boolean isInOutbox() {
         // TODO: what should this read? Folder info?
         return false;
@@ -518,7 +561,7 @@
             setChildVisibility(GONE, R.id.edit_draft, R.id.reply, R.id.reply_all, R.id.forward);
             setChildVisibility(GONE, R.id.overflow);
 
-            setChildVisibility(mAttachments == null || mAttachments.isEmpty() ? GONE : VISIBLE,
+            setChildVisibility(mMessage.hasAttachments ? VISIBLE : GONE,
                     R.id.attachment);
 
             setChildVisibility(mCollapsedStarVis, R.id.star);
@@ -698,7 +741,6 @@
      * Get BCC addresses attached to a recipient ONLY if this is a msg the
      * current user sent.
      *
-     * @param messageCursor Cursor to query for folder objects with
      */
     private static String[] getBccAddresses(Message m) {
         return Utils.splitCommaSeparatedString(m.bcc);
@@ -874,7 +916,7 @@
             if (mShowImagePrompt) {
                 showImagePrompt();
             }
-            if (mAttachments != null && !mAttachments.isEmpty()) {
+            if (mMessage.hasAttachments) {
                 showAttachments();
             }
         }
@@ -884,11 +926,34 @@
     }
 
     private void showAttachments() {
-        // Do nothing. Attachments not supported yet.
+        if (mAttachmentsView == null) {
+            ViewGroup container = (ViewGroup) mInflater.inflate(
+                    R.layout.conversation_message_attachments, this, false);
+
+            renderAttachments(container);
+            addView(container);
+            mAttachmentsView = container;
+        }
+        mAttachmentsView.setVisibility(VISIBLE);
+    }
+
+    private void renderAttachments(ViewGroup container) {
+        if (container != null && mAttachments != null && !mAttachments.isClosed()) {
+            int i = -1;
+            while (mAttachments.moveToPosition(++i)) {
+                final Attachment attachment = mAttachments.get();
+                MessageHeaderAttachment attachView =
+                        MessageHeaderAttachment.inflate(mInflater, container, mLoaderManager);
+                attachView.render(attachment);
+                container.addView(attachView);
+            }
+        }
     }
 
     private void hideAttachments() {
-        // Do nothing. Attachments not supported yet.
+        if (mAttachmentsView != null) {
+            mAttachmentsView.setVisibility(GONE);
+        }
     }
 
     public void hideMessageDetails() {
diff --git a/src/com/android/mail/providers/Attachment.java b/src/com/android/mail/providers/Attachment.java
index 59607a1..4190c3d 100644
--- a/src/com/android/mail/providers/Attachment.java
+++ b/src/com/android/mail/providers/Attachment.java
@@ -15,11 +15,18 @@
  */
 package com.android.mail.providers;
 
+import com.google.common.collect.Lists;
+
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
 import android.os.Parcel;
 import android.os.Parcelable;
 import android.text.TextUtils;
 
-import com.google.common.collect.Lists;
+import com.android.mail.providers.UIProvider.AttachmentColumns;
+import com.android.mail.providers.UIProvider.AttachmentDestination;
+import com.android.mail.providers.UIProvider.AttachmentState;
 
 import java.util.ArrayList;
 
@@ -29,10 +36,64 @@
     public static final int  LOCAL_FILE = 1;
 
     /**
-     * Attachment name.
+     * Attachment file name. See {@link AttachmentColumns#NAME}.
      */
     public String name;
 
+    /**
+     * Attachment size in bytes. See {@link AttachmentColumns#SIZE}.
+     */
+    public int size;
+
+    /**
+     * See {@link AttachmentColumns#URI}.
+     */
+    public Uri uri;
+
+    /**
+     * MIME type of the file. See {@link AttachmentColumns#CONTENT_TYPE}.
+     */
+    // TODO: rename to be consistent with UIProvider name: "contentType"
+    @Deprecated
+    public String mimeType;
+
+    /**
+     * See {@link AttachmentColumns#STATE}.
+     */
+    public int state;
+
+    /**
+     * See {@link AttachmentColumns#DESTINATION}.
+     */
+    public int destination;
+
+    /**
+     * See {@link AttachmentColumns#DOWNLOADED_SIZE}.
+     */
+    public int downloadedSize;
+
+    /**
+     * See {@link AttachmentColumns#CONTENT_URI}.
+     */
+    // TODO: change this to be a Uri for consistency with other URIs in data model objects.
+    @Deprecated
+    public String contentUri;
+
+    /**
+     * See {@link AttachmentColumns#THUMBNAIL_URI}. Might be null.
+     */
+    public Uri thumbnailUri;
+
+    /**
+     * See {@link AttachmentColumns#PREVIEW_INTENT}. Might be null.
+     */
+    public Intent previewIntent;
+
+    /**
+     * Part id of the attachment.
+     */
+    public String partId;
+
     public int origin;
 
     /**
@@ -40,36 +101,49 @@
      * TODO: do we want this? Or location?
      */
     public String originExtras;
-    /**
-     * Mime type of the file.
-     */
-    public String mimeType;
-    /**
-     * Content uri location of the attachment.
-     */
-    public String contentUri;
-    /**
-     * Part id of the attachment.
-     */
-    public String partId;
-    /**
-     * Attachment size in kb.
-     */
-    public long size;
 
     public Attachment(Parcel in) {
         name = in.readString();
-        originExtras = in.readString();
+        size = in.readInt();
+        uri = in.readParcelable(null);
         mimeType = in.readString();
+        state = in.readInt();
+        destination = in.readInt();
+        downloadedSize = in.readInt();
         contentUri = in.readString();
+        thumbnailUri = in.readParcelable(null);
+        previewIntent = in.readParcelable(null);
         partId = in.readString();
-        size = in.readLong();
         origin = in.readInt();
+        originExtras = in.readString();
     }
 
     public Attachment() {
     }
 
+    public Attachment(Cursor cursor) {
+        if (cursor == null) {
+            return;
+        }
+
+        name = cursor.getString(UIProvider.ATTACHMENT_NAME_COLUMN);
+        size = cursor.getInt(UIProvider.ATTACHMENT_SIZE_COLUMN);
+        uri = Uri.parse(cursor.getString(UIProvider.ATTACHMENT_URI_COLUMN));
+        mimeType = cursor.getString(UIProvider.ATTACHMENT_CONTENT_TYPE_COLUMN);
+        state = cursor.getInt(UIProvider.ATTACHMENT_STATE_COLUMN);
+        destination = cursor.getInt(UIProvider.ATTACHMENT_DESTINATION_COLUMN);
+        downloadedSize = cursor.getInt(UIProvider.ATTACHMENT_DOWNLOADED_SIZE_COLUMN);
+        // TODO: change to use parseUri()
+        contentUri = cursor.getString(UIProvider.ATTACHMENT_CONTENT_URI_COLUMN);
+        thumbnailUri = parseOptionalUri(
+                cursor.getString(UIProvider.ATTACHMENT_THUMBNAIL_URI_COLUMN));
+        previewIntent = getOptionalIntentFromBlob(
+                cursor.getBlob(UIProvider.ATTACHMENT_PREVIEW_INTENT_COLUMN));
+
+        // TODO: ensure that local files attached to a draft have sane values, like SAVED/EXTERNAL
+        // and that contentUri is populated
+    }
+
     public Attachment(String attachmentString) {
         String[] attachmentValues = attachmentString.split("\\|");
         if (attachmentValues != null) {
@@ -77,7 +151,7 @@
             name = attachmentValues[1];
             mimeType = attachmentValues[2];
             try {
-                size = Long.parseLong(attachmentValues[3]);
+                size = Integer.parseInt(attachmentValues[3]);
             } catch (NumberFormatException e) {
                 size = 0;
             }
@@ -88,6 +162,29 @@
         }
     }
 
+    public String toJoinedString() {
+        // FIXME: mimeType is read/written twice
+        return TextUtils.join("|", Lists.newArrayList(partId == null ? "" : partId,
+                name == null ? "" : name.replaceAll("[|\n]", ""), mimeType, size, mimeType,
+                origin + "", contentUri, TextUtils.isEmpty(originExtras) ? contentUri
+                        : originExtras, ""));
+    }
+
+    private static Intent getOptionalIntentFromBlob(byte[] blob) {
+        if (blob == null) {
+            return null;
+        }
+        final Parcel intentParcel = Parcel.obtain();
+        intentParcel.unmarshall(blob, 0, blob.length);
+        final Intent intent = new Intent();
+        intent.readFromParcel(intentParcel);
+        return intent;
+    }
+
+    private static Uri parseOptionalUri(String uriString) {
+        return uriString == null ? null : Uri.parse(uriString);
+    }
+
     @Override
     public int describeContents() {
         return 0;
@@ -96,12 +193,18 @@
     @Override
     public void writeToParcel(Parcel dest, int flags) {
         dest.writeString(name);
-        dest.writeString(originExtras);
+        dest.writeInt(size);
+        dest.writeParcelable(uri, flags);
         dest.writeString(mimeType);
+        dest.writeInt(state);
+        dest.writeInt(destination);
+        dest.writeInt(downloadedSize);
         dest.writeString(contentUri);
+        dest.writeParcelable(thumbnailUri, flags);
+        dest.writeParcelable(previewIntent, flags);
         dest.writeString(partId);
-        dest.writeLong(size);
         dest.writeInt(origin);
+        dest.writeString(originExtras);
     }
 
     public static final Creator<Attachment> CREATOR = new Creator<Attachment>() {
@@ -116,18 +219,26 @@
         }
     };
 
-
-    public String toJoinedString() {
-        return TextUtils.join("|", Lists.newArrayList(partId == null ? "" : partId,
-                name == null ? "" : name.replaceAll("[|\n]", ""), mimeType, size, mimeType,
-                origin + "", contentUri, TextUtils.isEmpty(originExtras) ? contentUri
-                        : originExtras, ""));
-    }
-
     public boolean isImage() {
         return mimeType.startsWith("image");
     }
 
+    public boolean isDownloading() {
+        return state == AttachmentState.DOWNLOADING;
+    }
+
+    public boolean isPresentLocally() {
+        return state == AttachmentState.SAVED || origin == LOCAL_FILE;
+    }
+
+    public boolean isSavedToExternal() {
+        return state == AttachmentState.SAVED && destination == AttachmentDestination.EXTERNAL;
+    }
+
+    public boolean canSave() {
+        return origin == SERVER_ATTACHMENT && state != AttachmentState.DOWNLOADING
+                && state != AttachmentState.SAVED;
+    }
 
     /**
      * Translate attachment info from a message into attachment objects.
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 31ec7ae..dd5ac85 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -37,6 +37,7 @@
 import android.webkit.ConsoleMessage;
 import android.webkit.WebChromeClient;
 import android.webkit.WebSettings;
+import android.widget.Adapter;
 import android.widget.ResourceCursorAdapter;
 import android.widget.TextView;
 
@@ -207,8 +208,9 @@
         MessageCursor messageCursor = (MessageCursor) data;
         mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html",
                 "utf-8", null);
-        mConversationContainer.setOverlayAdapter(
-                new MessageListAdapter(mActivity.getActivityContext(), messageCursor, mAccount));
+        final Adapter messageListAdapter = new MessageListAdapter(
+                mActivity.getActivityContext(), messageCursor, mAccount, getLoaderManager());
+        mConversationContainer.setOverlayAdapter(messageListAdapter);
     }
 
     @Override
@@ -266,18 +268,21 @@
 
         private final FormattedDateBuilder mDateBuilder;
         private final Account mAccount;
+        private final LoaderManager mLoaderManager;
 
-        public MessageListAdapter(Context context, Cursor cursor, Account account) {
-            super(context, R.layout.conversation_message_header, cursor, 0);
+        public MessageListAdapter(Context context, Cursor messageCursor, Account account,
+                LoaderManager loaderManager) {
+            super(context, R.layout.conversation_message_header, messageCursor, 0);
             mDateBuilder = new FormattedDateBuilder(context);
             mAccount = account;
+            mLoaderManager = loaderManager;
         }
 
         @Override
         public void bindView(View view, Context context, Cursor cursor) {
             Message m = ((MessageCursor) cursor).get();
             MessageHeaderView header = (MessageHeaderView) view;
-            header.initialize(mDateBuilder, mAccount, true, false, false);
+            header.initialize(mDateBuilder, mAccount, mLoaderManager, true, false, false);
             header.bind(m);
         }
     }
diff --git a/src/com/android/mail/utils/AttachmentUtils.java b/src/com/android/mail/utils/AttachmentUtils.java
index ad7714e..4e4b22c 100644
--- a/src/com/android/mail/utils/AttachmentUtils.java
+++ b/src/com/android/mail/utils/AttachmentUtils.java
@@ -15,17 +15,27 @@
  */
 package com.android.mail.utils;
 
+import com.google.common.collect.ImmutableMap;
+
 import android.content.Context;
 
 import com.android.mail.R;
+import com.android.mail.providers.Attachment;
 
 import java.text.DecimalFormat;
+import java.util.Map;
 
 public class AttachmentUtils {
     private static final int KILO = 1024;
     private static final int MEGA = KILO * KILO;
 
     /**
+     * Singleton map of MIME->friendly description
+     * @see #getMimeTypeDisplayName(Context, String)
+     */
+    private static Map<String, String> sDisplayNameMap;
+
+    /**
      * @return A string suitable for display in bytes, kilobytes or megabytes
      *         depending on its size.
      */
@@ -40,4 +50,77 @@
                     + context.getString(R.string.megabytes);
         }
     }
+
+    /**
+     * Return a friendly localized file type for this attachment, or the empty string if
+     * unknown.
+     * @param context a Context to do resource lookup against
+     * @return friendly file type or empty string
+     */
+    public static String getDisplayType(final Context context, final Attachment attachment) {
+        // try to get a friendly name for the exact mime type
+        // then try to show a friendly name for the mime family
+        // finally, give up and just show the file extension
+        String displayType = getMimeTypeDisplayName(context, attachment.mimeType);
+        int index = attachment.mimeType.indexOf('/');
+        if (displayType == null && index > 0) {
+            displayType = getMimeTypeDisplayName(context,
+                    attachment.mimeType.substring(0, index));
+        }
+        if (displayType == null) {
+            String extension = Utils.getFileExtension(attachment.name);
+            // show '$EXTENSION File' for unknown file types
+            if (extension != null && extension.length() > 1 && extension.indexOf('.') == 0) {
+                displayType = context.getString(R.string.attachment_unknown,
+                        extension.substring(1).toUpperCase());
+            }
+        }
+        if (displayType == null) {
+         // no extension to display, but the map doesn't accept null entries
+            displayType = "";
+        }
+        return displayType;
+    }
+
+    /**
+     * Returns a user-friendly localized description of either a complete a MIME type or a
+     * MIME family.
+     * @param context used to look up localized strings
+     * @param type complete MIME type or just MIME family
+     * @return localized description text, or null if not recognized
+     */
+    public static synchronized String getMimeTypeDisplayName(final Context context,
+            String type) {
+        if (sDisplayNameMap == null) {
+            String docName = context.getString(R.string.attachment_application_msword);
+            String presoName = context.getString(R.string.attachment_application_vnd_ms_powerpoint);
+            String sheetName = context.getString(R.string.attachment_application_vnd_ms_excel);
+
+            sDisplayNameMap = new ImmutableMap.Builder<String, String>()
+                .put("image", context.getString(R.string.attachment_image))
+                .put("audio", context.getString(R.string.attachment_audio))
+                .put("video", context.getString(R.string.attachment_video))
+                .put("text", context.getString(R.string.attachment_text))
+                .put("application/pdf", context.getString(R.string.attachment_application_pdf))
+
+                // Documents
+                .put("application/msword", docName)
+                .put("application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+                        docName)
+
+                // Presentations
+                .put("application/vnd.ms-powerpoint",
+                        presoName)
+                .put("application/vnd.openxmlformats-officedocument.presentationml.presentation",
+                        presoName)
+
+                // Spreadsheets
+                .put("application/vnd.ms-excel", sheetName)
+                .put("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+                        sheetName)
+
+                .build();
+        }
+        return sDisplayNameMap.get(type);
+    }
 }
diff --git a/src/com/android/mail/utils/MimeType.java b/src/com/android/mail/utils/MimeType.java
index f88b262..abe07e0 100644
--- a/src/com/android/mail/utils/MimeType.java
+++ b/src/com/android/mail/utils/MimeType.java
@@ -31,7 +31,7 @@
 import java.util.Set;
 
 /**
- * Utilities for working with different content types within Gmail.
+ * Utilities for working with different content types within Mail.
  */
 public class MimeType {
     public static final String ANDROID_ARCHIVE = "application/vnd.android.package-archive";
@@ -71,7 +71,7 @@
     /**
      * Returns whether or not an attachment of the specified type is viewable.
      */
-    public static boolean isViewable(Context context, Uri contentUri, String contentType) {
+    public static boolean isViewable(Context context, String contentType) {
         // The provider returns a contentType of "null" instead of null, when the
         // content type is not known.  Changing the provider to return null,
         // breaks other areas that will need to be fixed in a later CL.
@@ -87,7 +87,7 @@
 
         Intent mimetypeIntent = new Intent(Intent.ACTION_VIEW);
 
-        mimetypeIntent.setDataAndType(contentUri, contentType);
+        Utils.setIntentTypeAndNormalize(mimetypeIntent, contentType);
         PackageManager manager;
         // We need to catch the exception to make CanvasConversationHeaderView
         // test pass.  Bug: http://b/issue?id=3470653.
diff --git a/src/com/android/mail/utils/Utils.java b/src/com/android/mail/utils/Utils.java
index c9032fd..fbd0fda 100644
--- a/src/com/android/mail/utils/Utils.java
+++ b/src/com/android/mail/utils/Utils.java
@@ -46,7 +46,6 @@
 import com.android.mail.providers.Account;
 import com.android.mail.providers.Conversation;
 import com.android.mail.providers.Folder;
-import com.android.mail.providers.UIProvider;
 
 import java.util.Locale;
 import java.util.Map;
@@ -693,4 +692,98 @@
         String query = intent.getStringExtra(SearchManager.QUERY);
         return TextUtils.isEmpty(query) ? null : query.trim();
    }
+
+    /**
+     * Split out a filename's extension and return it.
+     * @param filename a file name
+     * @return the file extension (max of 5 chars including period, like ".docx"), or null
+     */
+   public static String getFileExtension(String filename) {
+        String extension = null;
+        int index = filename.lastIndexOf('.');
+        // Limit the suffix to dot + four characters
+        if (index >= 0 && filename.length() - index <= FILE_EXTENSION_MAX_CHARS + 1) {
+            extension = filename.substring(index);
+        }
+        return extension;
+    }
+
+   /**
+    * (copied from {@link Intent#normalizeMimeType(String)} for pre-J)
+    *
+    * Normalize a MIME data type.
+    *
+    * <p>A normalized MIME type has white-space trimmed,
+    * content-type parameters removed, and is lower-case.
+    * This aligns the type with Android best practices for
+    * intent filtering.
+    *
+    * <p>For example, "text/plain; charset=utf-8" becomes "text/plain".
+    * "text/x-vCard" becomes "text/x-vcard".
+    *
+    * <p>All MIME types received from outside Android (such as user input,
+    * or external sources like Bluetooth, NFC, or the Internet) should
+    * be normalized before they are used to create an Intent.
+    *
+    * @param type MIME data type to normalize
+    * @return normalized MIME data type, or null if the input was null
+    * @see {@link #setType}
+    * @see {@link #setTypeAndNormalize}
+    */
+   public static String normalizeMimeType(String type) {
+       if (type == null) {
+           return null;
+       }
+
+       type = type.trim().toLowerCase(Locale.US);
+
+       final int semicolonIndex = type.indexOf(';');
+       if (semicolonIndex != -1) {
+           type = type.substring(0, semicolonIndex);
+       }
+       return type;
+   }
+
+   /**
+    * (copied from {@link Uri#normalize()} for pre-J)
+    *
+    * Return a normalized representation of this Uri.
+    *
+    * <p>A normalized Uri has a lowercase scheme component.
+    * This aligns the Uri with Android best practices for
+    * intent filtering.
+    *
+    * <p>For example, "HTTP://www.android.com" becomes
+    * "http://www.android.com"
+    *
+    * <p>All URIs received from outside Android (such as user input,
+    * or external sources like Bluetooth, NFC, or the Internet) should
+    * be normalized before they are used to create an Intent.
+    *
+    * <p class="note">This method does <em>not</em> validate bad URI's,
+    * or 'fix' poorly formatted URI's - so do not use it for input validation.
+    * A Uri will always be returned, even if the Uri is badly formatted to
+    * begin with and a scheme component cannot be found.
+    *
+    * @return normalized Uri (never null)
+    * @see {@link android.content.Intent#setData}
+    * @see {@link #setNormalizedData}
+    */
+   public static Uri normalizeUri(Uri uri) {
+       String scheme = uri.getScheme();
+       if (scheme == null) return uri;  // give up
+       String lowerScheme = scheme.toLowerCase(Locale.US);
+       if (scheme.equals(lowerScheme)) return uri;  // no change
+
+       return uri.buildUpon().scheme(lowerScheme).build();
+   }
+
+   public static Intent setIntentTypeAndNormalize(Intent intent, String type) {
+       return intent.setType(normalizeMimeType(type));
+   }
+
+   public static Intent setIntentDataAndTypeAndNormalize(Intent intent, Uri data, String type) {
+       return intent.setDataAndType(normalizeUri(data), normalizeMimeType(type));
+   }
+
 }