conversation view zooming

Zoom is enabled on the entire conversation using the built-in
WebView mechanism.

Wide messages are individually best-effort shrunken to
fit-width.

Start a custom ViewGroup to hold both message headers and the
single WebView. A common view parent makes input handling easy:
the WebView drives scroll position, and position is relayed
to the custom ViewGroup to cheaply shift around headers without
a full relayout. For now, all headers are inflated at once, but
soon they can be recycled depending on the virtual scroll
viewport.

Change-Id: I92555f9b79e10630457b17ca970ab9a2e9028e80
diff --git a/assets/script.js b/assets/script.js
new file mode 100644
index 0000000..acc5432
--- /dev/null
+++ b/assets/script.js
@@ -0,0 +1,109 @@
+/*
+ * 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.
+ */
+
+
+/**
+ * Returns the page offset of an element.
+ *
+ * @param {Element} element The element to return the page offset for.
+ * @return {left: number, top: number} A tuple including a left and top value representing
+ *     the page offset of the element.
+ */
+function getTotalOffset(el) {
+    var result = {
+        left: 0,
+        top: 0
+    };
+    var parent = el;
+
+    while (parent) {
+        result.left += parent.offsetLeft;
+        result.top += parent.offsetTop;
+        parent = parent.offsetParent;
+    }
+
+    return result;
+}
+
+function toggleQuotedText(e) {
+    var toggleElement = e.target;
+    var elidedTextElement = toggleElement.nextSibling;
+    var isHidden = getComputedStyle(elidedTextElement).display == 'none';
+    toggleElement.innerHTML = isHidden ? MSG_HIDE_ELIDED : MSG_SHOW_ELIDED;
+    elidedTextElement.style.display = isHidden ? 'block' : 'none';
+}
+
+function collapseQuotedText() {
+    var i;
+    var elements = document.getElementsByClassName("elided-text");
+    var elidedElement, toggleElement;
+    for (i = 0; i < elements.length; i++) {
+        elidedElement = elements[i];
+        toggleElement = document.createElement("div");
+        toggleElement.display = "mail-elided-text";
+        toggleElement.innerHTML = MSG_SHOW_ELIDED;
+        toggleElement.onclick = toggleQuotedText;
+        elidedElement.parentNode.insertBefore(toggleElement, elidedElement);
+    }
+}
+
+function shrinkWideMessages() {
+    var i;
+    var elements = document.getElementsByClassName("mail-message-content");
+    var messageElement;
+    var documentWidth = document.documentElement.offsetWidth;
+    var scale;
+    for (i = 0; i < elements.length; i++) {
+        messageElement = elements[i];
+        if (messageElement.scrollWidth > documentWidth) {
+            scale = documentWidth / messageElement.scrollWidth;
+
+            // TODO: 'zoom' is nice because it does a proper layout, but WebView seems to clamp the
+            // minimum 'zoom' level.
+            if (false) {
+                // TODO: this alternative works well in Chrome but doesn't work in WebView.
+                messageElement.style.webkitTransformOrigin = "left top";
+                messageElement.style.webkitTransform = "scale(" + scale + ")";
+                messageElement.style.height = (messageElement.offsetHeight * scale) + "px";
+                messageElement.style.overflowX = "visible";
+            } else {
+                messageElement.style.zoom = documentWidth / messageElement.scrollWidth;
+            }
+        }
+    }
+}
+
+function measurePositions() {
+    var messageTops;
+    var i;
+    var len;
+
+    var headers = document.querySelectorAll(".mail-message");
+
+    messageTops = new Array(headers.length);
+    for (i = 0, len = headers.length; i < len; i++) {
+        // addJavascriptInterface handler only supports string arrays
+        messageTops[i] = "" + getTotalOffset(headers[i]).top;
+    }
+
+    window.mail.onWebContentGeometryChange(messageTops);
+}
+
+collapseQuotedText();
+shrinkWideMessages();
+measurePositions();
+
diff --git a/proguard.flags b/proguard.flags
index 61b14fe..6996693 100644
--- a/proguard.flags
+++ b/proguard.flags
@@ -31,3 +31,7 @@
 -keepclasseswithmembers class com.android.mail.ui.AnimatingItemView {
   *** setAnimatedHeight(...);
 }
+
+-keepclasseswithmembers class com.android.mail.ui.ConversationViewFragment$MailJsBridge {
+  public <methods>;
+}
diff --git a/res/layout/conversation_view.xml b/res/layout/conversation_view.xml
index a133e3f..97ea061 100644
--- a/res/layout/conversation_view.xml
+++ b/res/layout/conversation_view.xml
@@ -24,9 +24,15 @@
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         android:ellipsize="end"/>
-    <!-- TODO: set layout_height to wrap_content once the rest
-    of the conversation view is in place -->
-    <ListView android:id="@+id/message_list"
+    <com.android.mail.ui.ConversationContainer
+        android:id="@+id/conversation_container"
         android:layout_width="match_parent"
-        android:layout_height="match_parent"/>
-</LinearLayout>
\ No newline at end of file
+        android:layout_height="match_parent">
+
+        <com.android.mail.ui.ConversationWebView
+            android:id="@+id/webview"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent" />
+
+    </com.android.mail.ui.ConversationContainer>
+</LinearLayout>
diff --git a/res/layout/message.xml b/res/layout/message.xml
deleted file mode 100644
index ae44337..0000000
--- a/res/layout/message.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
-     Copyright (C) 2011 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.
--->
-<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    android:layout_width="match_parent"
-    android:layout_height="wrap_content">
-    <include
-        android:id="@+id/message_header"
-        layout="@layout/conversation_message_header" />
-    <!-- Disable hardware acceleration of each WebView due to b/4184270 -->
-    <com.android.mail.browse.MessageWebView
-        android:id="@+id/body"
-        android:layout_below="@id/message_header"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:layout_marginLeft="32dp"
-        android:layout_marginRight="32dp"
-        android:layerType="software" />
-</RelativeLayout>
diff --git a/res/raw/template_conversation_lower.html b/res/raw/template_conversation_lower.html
new file mode 100644
index 0000000..7c48447
--- /dev/null
+++ b/res/raw/template_conversation_lower.html
@@ -0,0 +1,10 @@
+</div>
+</body>
+<script type="text/javascript">
+  var MSG_HIDE_ELIDED = '%s';
+  var MSG_SHOW_ELIDED = '%s';
+  var ACCOUNT_URI = '%s';
+  var VIEW_WIDTH = %s;
+</script>
+<script type="text/javascript" src="file:///android_asset/script.js"></script>
+</html>
diff --git a/res/raw/template_conversation_upper.html b/res/raw/template_conversation_upper.html
new file mode 100644
index 0000000..01f420e
--- /dev/null
+++ b/res/raw/template_conversation_upper.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <meta name="viewport" content="width=device-width"/>
+<!--   <link rel="stylesheet" href="file:///android_res/raw/styles.css"> -->
+  <style>
+    .elided-text {
+        display: none;
+    }
+    .mail-message-content {
+        overflow-x: hidden;
+    }
+  </style>
+</head>
+<body style="margin: 0; background-color: #ccc;">
+<div id="conversation-header-spacer" style="height: %spx;"></div>
+<div id="message-container">
diff --git a/res/raw/template_message.html b/res/raw/template_message.html
new file mode 100644
index 0000000..9db4424
--- /dev/null
+++ b/res/raw/template_message.html
@@ -0,0 +1,3 @@
+<div id="%s" serverId="%s" class="mail-message" style="padding-top: %spx;">
+    <div class="mail-message-content %s" style="display: %s; zoom: %s; padding: 8px; background-color: white; -webkit-border-radius: 16px;">%s</div>
+</div>
diff --git a/res/raw/template_super_collapsed.html b/res/raw/template_super_collapsed.html
new file mode 100644
index 0000000..3209a85
--- /dev/null
+++ b/res/raw/template_super_collapsed.html
@@ -0,0 +1 @@
+<div class="gm-super-collapsed-spacer" index="%s" style="height: %spx;"></div>
diff --git a/res/values/strings.xml b/res/values/strings.xml
index f53d739..7a494d5 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -430,6 +430,10 @@
     <string name="sending_message">Sending message\u2026</string>
     <!-- Displayed for one second after trying to send with invalid recipients [CHAR LIMIT=50]-->
     <string name="invalid_recipient">The address <xliff:g id="wrongemail" example="foo@@gmail..com">%s</xliff:g> is invalid.</string>
+    <!-- Shown in HTML to allow the user to see quoted text; should match Gmail web UI. 25B6 is Unicode for a right-pointing triangle. [CHAR LIMIT=50] -->
+    <string name="show_elided">\u25B6 Show quoted text</string>
+    <!-- Shown in HTML to allow the user to hide quoted text; should match Gmail web UI. 25BC is Unicode for a downward-pointing triangle. [CHAR LIMIT=50] -->
+    <string name="hide_elided">\u25BC Hide quoted text</string>
 
     <!-- An enumeration comma for separating items in lists. [CHAR LIMIT=2] -->
     <string name="enumeration_comma">,\u0020</string>
diff --git a/src/com/android/mail/browse/MessageHeaderView.java b/src/com/android/mail/browse/MessageHeaderView.java
index 3b3d305..1466bb0 100644
--- a/src/com/android/mail/browse/MessageHeaderView.java
+++ b/src/com/android/mail/browse/MessageHeaderView.java
@@ -17,7 +17,6 @@
 package com.android.mail.browse;
 
 import android.content.Context;
-import android.database.Cursor;
 import android.graphics.Canvas;
 import android.graphics.Typeface;
 import android.provider.ContactsContract;
@@ -165,6 +164,9 @@
 
     private Message mMessage;
 
+    private boolean mCollapsedDetailsValid;
+    private boolean mExpandedDetailsValid;
+
     public MessageHeaderView(Context context) {
         this(context, null);
     }
@@ -321,46 +323,47 @@
         mDefaultReplyAll = defaultReplyAll;
     }
 
-    public int bind(Cursor cursor) {
+    public int bind(Message message) {
         Timer t = new Timer();
         t.start(HEADER_RENDER_TAG);
 
-        mMessage = new Message(cursor);
-        mLocalMessageId = cursor.getLong(UIProvider.MESSAGE_ID_COLUMN);
-        mServerMessageId = cursor.getLong(UIProvider.MESSAGE_SERVER_ID_COLUMN);
-        mConversationId = cursor.getLong(UIProvider.MESSAGE_CONVERSATION_ID_COLUMN);
+        mCollapsedDetailsValid = false;
+        mExpandedDetailsValid = false;
+
+        mMessage = message;
+        mLocalMessageId = mMessage.id;
+        mServerMessageId = mMessage.serverId;
+        mConversationId = mMessage.conversationId;
         if (mCallbacks != null) {
             mCallbacks.onHeaderCreated(mLocalMessageId);
         }
 
         setTag(mLocalMessageId);
 
-        mTimestampMs = cursor.getLong(UIProvider.MESSAGE_DATE_RECEIVED_MS_COLUMN);
+        mTimestampMs = mMessage.dateReceivedMs;
         if (mDateBuilder != null) {
             mTimestampShort = mDateBuilder.formatShortDate(mTimestampMs);
         }
 
-        mTo = Utils.splitCommaSeparatedString(cursor.getString(UIProvider.MESSAGE_TO_COLUMN));
-        mCc = Utils.splitCommaSeparatedString(cursor.getString(UIProvider.MESSAGE_CC_COLUMN));
-        mBcc = getBccAddresses(cursor);
-        mReplyTo = Utils.splitCommaSeparatedString(cursor
-                .getString(UIProvider.MESSAGE_REPLY_TO_COLUMN));
+        mTo = Utils.splitCommaSeparatedString(mMessage.to);
+        mCc = Utils.splitCommaSeparatedString(mMessage.cc);
+        mBcc = getBccAddresses(mMessage);
+        mReplyTo = Utils.splitCommaSeparatedString(mMessage.replyTo);
 
         /**
          * Turns draft mode on or off. Draft mode hides message operations other
          * than "edit", hides contact photo, hides presence, and changes the
          * sender name to "Draft".
          */
-        mIsDraft = cursor.getInt(UIProvider.MESSAGE_DRAFT_TYPE_COLUMN) !=
-                UIProvider.DraftType.NOT_A_DRAFT;
-        mIsSending = isInOutbox(cursor);
+        mIsDraft = mMessage.draftType != UIProvider.DraftType.NOT_A_DRAFT;
+        mIsSending = isInOutbox();
 
         updateChildVisibility();
 
-        if (mIsDraft || isInOutbox(cursor)) {
-            mSnippet = makeSnippet(cursor.getString(UIProvider.MESSAGE_SNIPPET_COLUMN));
+        if (mIsDraft || isInOutbox()) {
+            mSnippet = makeSnippet(mMessage.snippet);
         } else {
-            mSnippet = cursor.getString(UIProvider.MESSAGE_SNIPPET_COLUMN);
+            mSnippet = mMessage.snippet;
         }
 
         // If this was a sent message AND:
@@ -370,7 +373,7 @@
         // 2. the account has no custom froms, fromAddress will be empty, and we
         // can safely fall back and show the account name as sender since it's
         // the only possible fromAddress.
-        String from = cursor.getString(UIProvider.MESSAGE_FROM_COLUMN);
+        String from = mMessage.from;
         if (TextUtils.isEmpty(from)) {
             from = mAccount.name;
         }
@@ -384,8 +387,7 @@
             upperDateView.setText(mTimestampShort);
         }
 
-        mStarView.setSelected((cursor.getInt(UIProvider.MESSAGE_FLAGS_COLUMN)
-                & UIProvider.MessageFlags.STARRED) == 1);
+        mStarView.setSelected((mMessage.messageFlags & UIProvider.MessageFlags.STARRED) == 1);
         mStarView.setContentDescription(getResources().getString(
                 mStarView.isSelected() ? R.string.remove_star : R.string.add_star));
 
@@ -406,7 +408,7 @@
         return h;
     }
 
-    private boolean isInOutbox(Cursor cursor) {
+    private boolean isInOutbox() {
         // TODO: what should this read? Folder info?
         return false;
     }
@@ -697,8 +699,8 @@
      *
      * @param messageCursor Cursor to query for label objects with
      */
-    private static String[] getBccAddresses(Cursor cursor) {
-        return Utils.splitCommaSeparatedString(cursor.getString(UIProvider.MESSAGE_BCC_COLUMN));
+    private static String[] getBccAddresses(Message m) {
+        return Utils.splitCommaSeparatedString(m.bcc);
     }
 
     @Override
@@ -987,11 +989,14 @@
             mCollapsedDetailsView = (ViewGroup) findViewById(R.id.details_collapsed_content);
 
             mCollapsedDetailsView.setOnClickListener(this);
-
+        }
+        if (!mCollapsedDetailsValid) {
             ((TextView) findViewById(R.id.recipients_summary)).setText(getRecipientSummaryText(
                     getContext(), mAccount.name, mTo, mCc, mBcc));
 
             ((TextView) findViewById(R.id.date_summary)).setText(mTimestampShort);
+
+            mCollapsedDetailsValid = true;
         }
         mCollapsedDetailsView.setVisibility(VISIBLE);
     }
@@ -1016,6 +1021,9 @@
             addView(v, indexOfChild(mCollapsedDetailsView) + 1);
             v.setOnClickListener(this);
 
+            mExpandedDetailsView = (ViewGroup) v;
+        }
+        if (!mExpandedDetailsValid) {
             CharSequence longTimestamp = mDateBuilder != null ? mDateBuilder
                     .formatLongDateTime(mTimestampMs) : mTimestampMs + "";
             ((TextView) findViewById(R.id.date_value)).setText(longTimestamp);
@@ -1023,10 +1031,8 @@
             renderEmailList(R.id.to_row, R.id.to_value, mTo);
             renderEmailList(R.id.cc_row, R.id.cc_value, mCc);
             renderEmailList(R.id.bcc_row, R.id.bcc_value, mBcc);
-            // don't need these any more, release them
-            mReplyTo = mTo = mCc = mBcc = null;
 
-            mExpandedDetailsView = (ViewGroup) v;
+            mExpandedDetailsValid = true;
         }
         mExpandedDetailsView.setVisibility(VISIBLE);
     }
diff --git a/src/com/android/mail/ui/ConversationContainer.java b/src/com/android/mail/ui/ConversationContainer.java
new file mode 100644
index 0000000..0f7ab3c
--- /dev/null
+++ b/src/com/android/mail/ui/ConversationContainer.java
@@ -0,0 +1,139 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.WebView;
+import android.widget.Adapter;
+
+import com.android.mail.ui.ScrollNotifier.ScrollListener;
+import com.android.mail.utils.LogUtils;
+
+/**
+ * TODO
+ *
+ */
+public class ConversationContainer extends ViewGroup implements ScrollListener {
+
+    private Adapter mOverlayAdapter;
+    private int[] mOverlayTops;
+
+    private static final String TAG = new LogUtils().getLogTag();
+
+    private int mOffsetY;
+    private float mScale;
+
+    public ConversationContainer(Context c) {
+        this(c, null);
+    }
+
+    public ConversationContainer(Context c, AttributeSet attrs) {
+        super(c, attrs);
+    }
+
+    public void setOverlayAdapter(Adapter a) {
+        mOverlayAdapter = a;
+    }
+
+    private int getOverlayCount() {
+        return Math.max(0, getChildCount() - 1);
+    }
+
+    private View getOverlayAt(int i) {
+        return getChildAt(i + 1);
+    }
+
+    private WebView getBackgroundView() {
+        return (WebView) getChildAt(0);
+    }
+
+    @Override
+    public void onNotifierScroll(int x, int y) {
+        mOffsetY = y;
+        mScale = getBackgroundView().getScale();
+        LogUtils.v(TAG, "*** IN on scroll, x/y=%d/%d zoom=%f", x, y, mScale);
+        layoutOverlays();
+
+        // TODO: recycle scrolled-off views and add newly visible views
+    }
+
+    @Override
+    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
+        LogUtils.d(TAG, "*** IN header container onMeasure");
+        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
+
+        measureChildren(widthMeasureSpec, heightMeasureSpec);
+    }
+
+    @Override
+    protected void onLayout(boolean changed, int l, int t, int r, int b) {
+        LogUtils.d(TAG, "*** IN header container onLayout");
+        final View backgroundView = getBackgroundView();
+        backgroundView.layout(0, 0, backgroundView.getMeasuredWidth(),
+                backgroundView.getMeasuredHeight());
+
+        layoutOverlays();
+    }
+
+    private void layoutOverlays() {
+        final int count = getOverlayCount();
+
+        if (count > 0 && count != mOverlayTops.length) {
+            LogUtils.e(TAG,
+                    "Header/body count mismatch. headers=%d, message bodies=%d",
+                    count, mOverlayTops.length);
+        }
+
+        for (int i = 0; i < count; i++) {
+            View child = getOverlayAt(i);
+            // TODO: round or truncate?
+            final int top = (int) (mOverlayTops[i] * mScale) - mOffsetY;
+            final int bottom = top + child.getMeasuredHeight();
+            child.layout(0, top, child.getMeasuredWidth(), bottom);
+        }
+    }
+
+    // TODO: add margin support for children that want it (e.g. tablet headers)
+
+    public void onGeometryChange(int[] messageTops) {
+        LogUtils.d(TAG, "*** got message tops:");
+        for (int top : messageTops) {
+            LogUtils.d(TAG, "%d", top);
+        }
+
+        mOverlayTops = messageTops;
+
+        for (int i = 0; i < messageTops.length; i++) {
+            View overlayView = getOverlayAt(i);
+            if (overlayView == null) {
+                // TODO: dig through recycler instead of creating new views each time
+                overlayView = mOverlayAdapter.getView(i, null, this);
+                addView(overlayView, i + 1);
+            }
+            // TODO: inform header of its bottom (== top of the next header) so it can know where
+            // to position bottom-anchored content like attachments
+        }
+
+        mScale = getBackgroundView().getScale();
+
+    }
+
+}
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 7e47617..a64ce29 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -17,6 +17,8 @@
 
 package com.android.mail.ui;
 
+import com.google.common.collect.Maps;
+
 import android.app.Activity;
 import android.app.Fragment;
 import android.app.LoaderManager;
@@ -24,25 +26,31 @@
 import android.content.CursorLoader;
 import android.content.Loader;
 import android.database.Cursor;
+import android.database.CursorWrapper;
 import android.net.Uri;
 import android.os.Bundle;
+import android.os.Handler;
 import android.view.LayoutInflater;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.ListView;
-import android.widget.SimpleCursorAdapter;
+import android.webkit.ConsoleMessage;
+import android.webkit.WebChromeClient;
+import android.webkit.WebSettings;
+import android.widget.ResourceCursorAdapter;
 import android.widget.TextView;
 
 import com.android.mail.FormattedDateBuilder;
 import com.android.mail.R;
 import com.android.mail.browse.MessageHeaderView;
-import com.android.mail.browse.MessageWebView;
 import com.android.mail.providers.Account;
 import com.android.mail.providers.Conversation;
+import com.android.mail.providers.Message;
 import com.android.mail.providers.UIProvider;
 import com.android.mail.utils.LogUtils;
 
+import java.util.Map;
+
 /**
  * The conversation view UI component.
  */
@@ -55,32 +63,44 @@
 
     private ControllableActivity mActivity;
 
-    private final Conversation mConversation;
+    private Context mContext;
+
+    private Conversation mConversation;
 
     private TextView mSubject;
 
-    private ListView mMessageList;
+    private ConversationContainer mConversationContainer;
 
-    private FormattedDateBuilder mDateBuilder;
+    private Account mAccount;
 
-    private Cursor mMessageCursor;
+    private ConversationWebView mWebView;
 
-    private final Account mAccount;
-    /**
-     * Hidden constructor.
-     */
-    private ConversationViewFragment(Account account, Conversation conversation) {
-        super();
-        mConversation = conversation;
-        mAccount = account;
+    private HtmlConversationTemplates mTemplates;
+
+    private String mBaseUri;
+
+    private final Handler mHandler = new Handler();
+
+    private final MailJsBridge mJsBridge = new MailJsBridge();
+
+    private static final String ARG_ACCOUNT = "account";
+    private static final String ARG_CONVERSATION = "conversation";
+
+    public ConversationViewFragment() {
     }
 
     /**
      * Creates a new instance of {@link ConversationViewFragment}, initialized
      * to display conversation.
      */
-    public static ConversationViewFragment newInstance(Account account, Conversation conversation) {
-       return new ConversationViewFragment(account, conversation);
+    public static ConversationViewFragment newInstance(Account account,
+            Conversation conversation) {
+       ConversationViewFragment f = new ConversationViewFragment();
+       Bundle args = new Bundle();
+       args.putParcelable(ARG_ACCOUNT, account);
+       args.putParcelable(ARG_CONVERSATION, conversation);
+       f.setArguments(args);
+       return f;
     }
 
     @Override
@@ -93,16 +113,17 @@
         // ControllableActivity.
         final Activity activity = getActivity();
         if (! (activity instanceof ControllableActivity)){
-            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to" +
-                    "create it. Cannot proceed.");
+            LogUtils.wtf(LOG_TAG, "ConversationViewFragment expects only a ControllableActivity to"
+                    + "create it. Cannot proceed.");
         }
         mActivity = (ControllableActivity) activity;
+        mContext = mActivity.getApplicationContext();
         if (mActivity.isFinishing()) {
             // Activity is finishing, just bail.
             return;
         }
         mActivity.attachConversationView(this);
-        mDateBuilder = new FormattedDateBuilder(mActivity.getActivityContext());
+        mTemplates = new HtmlConversationTemplates(mContext);
         // Show conversation and start loading messages.
         showConversation();
     }
@@ -111,6 +132,11 @@
     public void onCreate(Bundle savedState) {
         LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this);
         super.onCreate(savedState);
+
+        Bundle args = getArguments();
+        mAccount = args.getParcelable(ARG_ACCOUNT);
+        mConversation = args.getParcelable(ARG_CONVERSATION);
+        mBaseUri = "x-thread://" + mAccount.name + "/" + mConversation.id;
     }
 
     @Override
@@ -118,7 +144,35 @@
             ViewGroup container, Bundle savedInstanceState) {
         View rootView = inflater.inflate(R.layout.conversation_view, null);
         mSubject = (TextView) rootView.findViewById(R.id.subject);
-        mMessageList = (ListView) rootView.findViewById(R.id.message_list);
+        mConversationContainer = (ConversationContainer) rootView
+                .findViewById(R.id.conversation_container);
+        mWebView = (ConversationWebView) rootView.findViewById(R.id.webview);
+
+        mWebView.addScrollListener(mConversationContainer);
+
+        mWebView.addJavascriptInterface(mJsBridge, "mail");
+
+        mWebView.setWebChromeClient(new WebChromeClient() {
+            @Override
+            public boolean onConsoleMessage(ConsoleMessage consoleMessage) {
+                LogUtils.i(LOG_TAG, "JS: %s (%s:%d)", consoleMessage.message(),
+                        consoleMessage.sourceId(), consoleMessage.lineNumber());
+                return true;
+            }
+        });
+
+        WebSettings settings = mWebView.getSettings();
+
+        settings.setBlockNetworkImage(true);
+
+        settings.setJavaScriptEnabled(true);
+        settings.setUseWideViewPort(true);
+
+        settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
+
+        settings.setSupportZoom(true);
+        settings.setBuiltInZoomControls(true);
+        settings.setDisplayZoomControls(false);
 
         return rootView;
     }
@@ -126,7 +180,7 @@
     @Override
     public void onDestroyView() {
         // Clear the adapter.
-        mMessageList.setAdapter(null);
+        mConversationContainer.setOverlayAdapter(null);
         mActivity.attachConversationView(null);
 
         super.onDestroyView();
@@ -143,16 +197,17 @@
 
     @Override
     public Loader<Cursor> onCreateLoader(int id, Bundle args) {
-        return new CursorLoader(mActivity.getActivityContext(),
-                Uri.parse(mConversation.messageListUri), UIProvider.MESSAGE_PROJECTION, null, null,
-                null);
+        return new CursorLoader(mContext, Uri.parse(mConversation.messageListUri),
+                UIProvider.MESSAGE_PROJECTION, null, null, null);
     }
 
     @Override
     public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
-        mMessageCursor = data;
-        mMessageList.setAdapter(new MessageListAdapter(mActivity.getActivityContext(),
-                mMessageCursor));
+        MessageCursor messageCursor = new MessageCursor(data);
+        mWebView.loadDataWithBaseURL(mBaseUri, renderMessageBodies(messageCursor), "text/html",
+                "utf-8", null);
+        mConversationContainer.setOverlayAdapter(
+                new MessageListAdapter(mContext, messageCursor, mAccount));
     }
 
     @Override
@@ -160,26 +215,81 @@
         // Do nothing.
     }
 
-    class MessageListAdapter extends SimpleCursorAdapter {
-        public MessageListAdapter(Context context, Cursor cursor) {
-            super(context, R.layout.message, cursor,
-                    UIProvider.MESSAGE_PROJECTION, new int[0], 0);
+    private String renderMessageBodies(MessageCursor messageCursor) {
+        int pos = -1;
+        mTemplates.startConversation(0);
+        while (messageCursor.moveToPosition(++pos)) {
+            mTemplates.appendMessageHtml(messageCursor.get(), true, false, 1.0f, 96);
         }
-
-        @Override
-        public void bindView(View view, Context context, Cursor cursor) {
-            super.bindView(view, context, cursor);
-            MessageHeaderView header = (MessageHeaderView) view.findViewById(R.id.message_header);
-            header.initialize(mDateBuilder, mAccount, true, true, false);
-            header.bind(cursor);
-            MessageWebView webView = (MessageWebView) view.findViewById(R.id.body);
-            webView.loadData(cursor.getString(UIProvider.MESSAGE_BODY_HTML_COLUMN), "text/html",
-                    null);
-        }
+        return mTemplates.endConversation(mBaseUri, 320);
     }
 
     public void onTouchEvent(MotionEvent event) {
         // TODO: (mindyp) when there is an undo bar, check for event !in undo bar
         // if its not in undo bar, dismiss the undo bar.
     }
+
+    private static class MessageCursor extends CursorWrapper {
+
+        private Map<Long, Message> mCache = Maps.newHashMap();
+
+        public MessageCursor(Cursor inner) {
+            super(inner);
+        }
+
+        public Message get() {
+            long id = getWrappedCursor().getLong(0);
+            Message m = mCache.get(id);
+            if (m == null) {
+                m = new Message(this);
+                mCache.put(id, m);
+            }
+            return m;
+        }
+    }
+
+    private static class MessageListAdapter extends ResourceCursorAdapter {
+
+        private final FormattedDateBuilder mDateBuilder;
+        private final Account mAccount;
+
+        public MessageListAdapter(Context context, Cursor cursor, Account account) {
+            super(context, R.layout.conversation_message_header, cursor, 0);
+            mDateBuilder = new FormattedDateBuilder(context);
+            mAccount = account;
+        }
+
+        @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.bind(m);
+        }
+    }
+
+    /**
+     * NOTE: all public methods must be listed in the proguard flags so that they can be accessed
+     * via reflection and not stripped.
+     *
+     */
+    private class MailJsBridge {
+
+        @SuppressWarnings("unused")
+        public void onWebContentGeometryChange(final String[] messageTopStrs) {
+            mHandler.post(new Runnable() {
+                @Override
+                public void run() {
+                    final int len = messageTopStrs.length;
+                    int[] messageTops = new int[len];
+                    for (int i = 0; i < len; i++) {
+                        messageTops[i] = Integer.parseInt(messageTopStrs[i]);
+                    }
+                    mConversationContainer.onGeometryChange(messageTops);
+                }
+            });
+        }
+
+    }
+
 }
diff --git a/src/com/android/mail/ui/ConversationWebView.java b/src/com/android/mail/ui/ConversationWebView.java
new file mode 100644
index 0000000..edf337b
--- /dev/null
+++ b/src/com/android/mail/ui/ConversationWebView.java
@@ -0,0 +1,60 @@
+/*
+ * 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.ui;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.webkit.WebView;
+
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+public class ConversationWebView extends WebView implements ScrollNotifier {
+
+    private final Set<ScrollListener> mScrollListeners =
+            new CopyOnWriteArraySet<ScrollListener>();
+
+    public ConversationWebView(Context c) {
+        this(c, null);
+    }
+
+    public ConversationWebView(Context c, AttributeSet attrs) {
+        super(c, attrs);
+    }
+
+    @Override
+    public void addScrollListener(ScrollListener l) {
+        mScrollListeners.add(l);
+    }
+
+    @Override
+    public void removeScrollListener(ScrollListener l) {
+        mScrollListeners.remove(l);
+    }
+
+    @Override
+    protected void onScrollChanged(int l, int t, int oldl, int oldt) {
+        super.onScrollChanged(l, t, oldl, oldt);
+
+        for (ScrollListener listener : mScrollListeners) {
+            listener.onNotifierScroll(l, t);
+        }
+    }
+
+
+}
diff --git a/src/com/android/mail/ui/HtmlConversationTemplates.java b/src/com/android/mail/ui/HtmlConversationTemplates.java
new file mode 100644
index 0000000..40466e4
--- /dev/null
+++ b/src/com/android/mail/ui/HtmlConversationTemplates.java
@@ -0,0 +1,233 @@
+/*
+ * 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.ui;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import android.content.Context;
+import android.content.res.Resources.NotFoundException;
+
+import com.android.mail.R;
+import com.android.mail.providers.Message;
+import com.android.mail.utils.LogUtils;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.Formatter;
+import java.util.regex.Pattern;
+
+/**
+ * Renders data into very simple string-substitution HTML templates for conversation view.
+ *
+ * Templates should be UTF-8 encoded HTML with '%s' placeholders to be substituted upon render.
+ * Plain-jane string substitution with '%s' is slightly faster than typed substitution.
+ *
+ */
+public class HtmlConversationTemplates {
+
+    /**
+     * Prefix applied to a message id for use as a div id
+     */
+    public static final String MESSAGE_PREFIX = "m";
+    public static final int MESSAGE_PREFIX_LENGTH = MESSAGE_PREFIX.length();
+
+    // TODO: refine. too expensive to iterate over cursor and pre-calculate total. so either
+    // estimate it, or defer assembly until the end when size is known (deferring increases
+    // working set size vs. estimation but is exact).
+    private static final int BUFFER_SIZE_CHARS = 64 * 1024;
+
+    private static final String TAG = new LogUtils().getLogTag();
+
+    /**
+     * Pattern for HTML img tags with a "src" attribute where the value is an absolutely-specified
+     * HTTP or HTTPS URL. In other words, these are images with valid URLs that we should munge to
+     * prevent WebView from firing bad onload handlers for them. Part of the workaround for
+     * b/5522414.
+     *
+     * Pattern documentation:
+     * There are 3 top-level parts of the pattern:
+     * 1. required preceding string
+     * 2. the literal string "src"
+     * 3. required trailing string
+     *
+     * The preceding string must be an img tag "<img " with intermediate spaces allowed. The
+     * trailing whitespace is required.
+     * Non-whitespace chars are allowed before "src", but if they are present, they must be followed
+     * by another whitespace char. The idea is to allow other attributes, and avoid matching on
+     * "src" in a later attribute value as much as possible.
+     *
+     * The following string must contain "=" and "http", with intermediate whitespace and single-
+     * and double-quote allowed in between. The idea is to avoid matching Gmail-hosted relative URLs
+     * for inline attachment images of the form "?view=KEYVALUES".
+     *
+     */
+    private static final Pattern sAbsoluteImgUrlPattern = Pattern.compile(
+            "(<\\s*img\\s+(?:[^>]*\\s+)?)src(\\s*=[\\s'\"]*http)", Pattern.CASE_INSENSITIVE
+                    | Pattern.MULTILINE);
+    /**
+     * The text replacement for {@link #sAbsoluteImgUrlPattern}. The "src" attribute is set to
+     * something inert and not left unset to minimize interactions with existing JS.
+     */
+    private static final String IMG_URL_REPLACEMENT = "$1src='data:' gm-src$2";
+
+    private static boolean sLoadedTemplates;
+    private static String sSuperCollapsed;
+    private static String sMessage;
+    private static String sConversationUpper;
+    private static String sConversationLower;
+
+    private Context mContext;
+    private Formatter mFormatter;
+    private StringBuilder mBuilder;
+    private boolean mInProgress = false;
+
+    public HtmlConversationTemplates(Context context) {
+        mContext = context;
+
+        // The templates are small (~2KB total in ICS MR2), so it's okay to load them once and keep
+        // them in memory.
+        if (!sLoadedTemplates) {
+            sLoadedTemplates = true;
+            sSuperCollapsed = readTemplate(R.raw.template_super_collapsed);
+            sMessage = readTemplate(R.raw.template_message);
+            sConversationUpper = readTemplate(R.raw.template_conversation_upper);
+            sConversationLower = readTemplate(R.raw.template_conversation_lower);
+        }
+    }
+
+    public void appendSuperCollapsedHtml(int firstCollapsed, int blockHeight) {
+        if (!mInProgress) {
+            throw new IllegalStateException("must call startConversation first");
+        }
+
+        append(sSuperCollapsed, firstCollapsed, blockHeight);
+    }
+
+    @VisibleForTesting
+    static String replaceAbsoluteImgUrls(final String html) {
+        return sAbsoluteImgUrlPattern.matcher(html).replaceAll(IMG_URL_REPLACEMENT);
+    }
+
+    public void appendMessageHtml(Message message, boolean isExpanded,
+            boolean safeForImages, float zoomValue, int headerHeight) {
+
+        final String bodyDisplay = isExpanded ? "block" : "none";
+        final String showImagesClass = safeForImages ? "gm-show-images" : "";
+
+        String body = message.bodyHtml;
+
+        /* Work around a WebView bug (5522414) in setBlockNetworkImage that causes img onload event
+         * handlers to fire before an image is loaded.
+         * WebView will report bad dimensions when revealing inline images with absolute URLs, but
+         * we can prevent WebView from ever seeing those images by changing all img "src" attributes
+         * into "gm-src" before loading the HTML. Parsing the potentially dirty HTML input is
+         * prohibitively expensive with TagSoup, so use a little regular expression instead.
+         *
+         * To limit the scope of this workaround, only use it on messages that the server claims to
+         * have external resources, and even then, only use it on img tags where the src is absolute
+         * (i.e. url does not begin with "?"). The existing JavaScript implementation of this
+         * attribute swap will continue to handle inline image attachments (they have relative
+         * URLs) and any false negatives that the regex misses. This maintains overall security
+         * level by not relying solely on the regex.
+         */
+        if (!safeForImages && message.embedsExternalResources) {
+            body = replaceAbsoluteImgUrls(body);
+        }
+
+        append(sMessage,
+                MESSAGE_PREFIX + message.id,
+                MESSAGE_PREFIX + message.serverId,
+                headerHeight,
+                showImagesClass,
+                bodyDisplay,
+                zoomValue,
+                body
+        );
+    }
+
+    public void startConversation(int conversationHeaderHeight) {
+        if (mInProgress) {
+            throw new IllegalStateException("must call startConversation first");
+        }
+
+        reset();
+        append(sConversationUpper, conversationHeaderHeight);
+        mInProgress = true;
+    }
+
+    public String endConversation(String baseUri, int viewWidth) {
+        if (!mInProgress) {
+            throw new IllegalStateException("must call startConversation first");
+        }
+
+        append(sConversationLower, mContext.getString(R.string.hide_elided),
+                mContext.getString(R.string.show_elided), baseUri, viewWidth);
+
+        mInProgress = false;
+
+        LogUtils.d(TAG, "rendered conversation of %d bytes, buffer capacity=%d",
+                mBuilder.length() << 1, mBuilder.capacity() << 1);
+
+        return emit();
+    }
+
+    private String emit() {
+        String out = mFormatter.toString();
+        // release the builder memory ASAP
+        mFormatter = null;
+        mBuilder = null;
+        return out;
+    }
+
+    public void reset() {
+        mBuilder = new StringBuilder(BUFFER_SIZE_CHARS);
+        mFormatter = new Formatter(mBuilder, null /* no localization */);
+    }
+
+    private String readTemplate(int id) throws NotFoundException {
+        StringBuilder out = new StringBuilder();
+        InputStreamReader in = null;
+        try {
+            try {
+                in = new InputStreamReader(
+                        mContext.getResources().openRawResource(id), "UTF-8");
+                char[] buf = new char[4096];
+                int chars;
+
+                while ((chars=in.read(buf)) > 0) {
+                    out.append(buf, 0, chars);
+                }
+
+                return out.toString();
+
+            } finally {
+                if (in != null) {
+                    in.close();
+                }
+            }
+        } catch (IOException e) {
+            throw new NotFoundException("Unable to open template id=" + Integer.toHexString(id)
+                    + " exception=" + e.getMessage());
+        }
+    }
+
+    private void append(String template, Object... args) {
+        mFormatter.format(template, args);
+    }
+
+}
diff --git a/src/com/android/mail/ui/ScrollNotifier.java b/src/com/android/mail/ui/ScrollNotifier.java
new file mode 100644
index 0000000..b906e0e
--- /dev/null
+++ b/src/com/android/mail/ui/ScrollNotifier.java
@@ -0,0 +1,27 @@
+/*
+ * 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.ui;
+
+public interface ScrollNotifier {
+    public interface ScrollListener {
+        void onNotifierScroll(int x, int y);
+    }
+
+    void addScrollListener(ScrollListener l);
+    void removeScrollListener(ScrollListener l);
+}
diff --git a/src/com/android/mail/utils/Utils.java b/src/com/android/mail/utils/Utils.java
index 2fe83e0..3648cf5 100644
--- a/src/com/android/mail/utils/Utils.java
+++ b/src/com/android/mail/utils/Utils.java
@@ -16,6 +16,8 @@
 
 package com.android.mail.utils;
 
+import com.google.common.collect.Maps;
+
 import android.content.Context;
 import android.content.Intent;
 import android.content.res.Resources;
@@ -32,8 +34,8 @@
 import android.text.style.ForegroundColorSpan;
 import android.text.style.StyleSpan;
 import android.view.View;
-import android.view.ViewGroup;
 import android.view.View.MeasureSpec;
+import android.view.ViewGroup;
 import android.webkit.WebSettings;
 import android.webkit.WebView;
 
@@ -41,7 +43,6 @@
 import com.android.mail.providers.Account;
 import com.android.mail.providers.Folder;
 import com.android.mail.providers.UIProvider;
-import com.google.common.collect.Maps;
 
 import java.util.Map;
 
@@ -214,7 +215,7 @@
      * conversation and then describe the most important messages in order,
      * indicating the priority of each message and whether the message is
      * unread.
-     * 
+     *
      * @param instructions instructions as described above
      * @param senderBuilder the SpannableStringBuilder to append to for sender
      *            information
@@ -431,7 +432,7 @@
 
     /**
      * Adds a fragment with given style to a string builder.
-     * 
+     *
      * @param builder the current string builder
      * @param fragment the fragment to be added
      * @param style the style of the fragment
@@ -479,7 +480,7 @@
      * child has a ViewGroup parent and that it should be laid out within that
      * parent with a matching width but variable height. Code largely lifted
      * from AnimatedAdapter.measureChildHeight().
-     * 
+     *
      * @param child a child view that has already been placed within its parent
      *            ViewGroup
      * @param parent the parent ViewGroup of child