Merge "support message header collapsing"
diff --git a/assets/script.js b/assets/script.js
index a68db32..da380e6 100644
--- a/assets/script.js
+++ b/assets/script.js
@@ -46,6 +46,7 @@
     var isHidden = getComputedStyle(elidedTextElement).display == 'none';
     toggleElement.innerHTML = isHidden ? MSG_HIDE_ELIDED : MSG_SHOW_ELIDED;
     elidedTextElement.style.display = isHidden ? 'block' : 'none';
+    measurePositions();
 }
 
 function collapseQuotedText() {
@@ -166,6 +167,32 @@
         }
     }
 }
+
+function setMessageHeaderSpacerHeight(messageDomId, spacerHeight) {
+    var spacer = document.querySelector("#" + messageDomId + " > .mail-message-header");
+    if (!spacer) {
+        console.log("can't set spacer for message with id: " + messageDomId);
+        return;
+    }
+    spacer.style.height = spacerHeight + "px";
+    measurePositions();
+}
+
+function setMessageBodyVisible(messageDomId, isVisible, spacerHeight) {
+    var i, len;
+    var visibility = isVisible ? "block" : "none";
+    var messageDiv = document.querySelector("#" + messageDomId);
+    var collapsibleDivs = document.querySelectorAll("#" + messageDomId + " > .collapsible");
+    if (!messageDiv || collapsibleDivs.length == 0) {
+        console.log("can't set body visibility for message with id: " + messageDomId);
+        return;
+    }
+    messageDiv.classList.toggle("expanded");
+    for (i = 0, len = collapsibleDivs.length; i < len; i++) {
+        collapsibleDivs[i].style.display = visibility;
+    }
+    setMessageHeaderSpacerHeight(messageDomId, spacerHeight);
+}
 // END Java->JavaScript handlers
 
 collapseQuotedText();
diff --git a/res/layout/conversation_message_footer.xml b/res/layout/conversation_message_footer.xml
index ad675d3..82649e7 100644
--- a/res/layout/conversation_message_footer.xml
+++ b/res/layout/conversation_message_footer.xml
@@ -22,6 +22,5 @@
     android:layout_height="wrap_content"
     android:orientation="vertical"
     android:paddingLeft="16dp"
-    android:paddingRight="16dp"
-    android:paddingBottom="8dp">
+    android:paddingRight="16dp">
 </com.android.mail.browse.MessageFooterView>
diff --git a/res/raw/template_message.html b/res/raw/template_message.html
index 1b75b10..69a25b0 100644
--- a/res/raw/template_message.html
+++ b/res/raw/template_message.html
@@ -1,5 +1,5 @@
 <div id="%s" serverId="%s" class="mail-message %s">
     <div class="mail-message-header spacer" style="height: %spx;"></div>
-    <div class="mail-message-content %s" style="display: %s; zoom: %s; padding: 16px;">%s</div>
-    <div class="mail-message-footer" style="height: %spx;"></div>
+    <div class="mail-message-content collapsible %s" style="display: %s; zoom: %s; padding: 16px;">%s</div>
+    <div class="mail-message-footer collapsible" style="display: %s; height: %spx;"></div>
 </div>
diff --git a/src/com/android/mail/browse/ConversationContainer.java b/src/com/android/mail/browse/ConversationContainer.java
index 16863cf..1aeb4c6 100644
--- a/src/com/android/mail/browse/ConversationContainer.java
+++ b/src/com/android/mail/browse/ConversationContainer.java
@@ -128,6 +128,8 @@
 
     private int mWidthMeasureSpec;
 
+    private boolean mDisableLayoutTracing;
+
     private static final int VIEW_TAG_CONVERSATION_INDEX = R.id.view_tag_conversation_index;
 
     /**
@@ -263,7 +265,9 @@
 
     @Override
     public void onNotifierScroll(final int x, final int y) {
+        mDisableLayoutTracing = true;
         positionOverlays(x, y);
+        mDisableLayoutTracing = false;
     }
 
     private void positionOverlays(int x, int y) {
@@ -279,8 +283,8 @@
         if (mTouchInitialized) {
             mScale = mWebView.getScale();
         }
-        LogUtils.v(TAG, "in positionOverlays, raw scale=%f, effective scale=%f",
-                mWebView.getScale(), mScale);
+        traceLayout("in positionOverlays, raw scale=%f, effective scale=%f", mWebView.getScale(),
+                mScale);
 
         if (mOverlayBottoms == null) {
             return;
@@ -294,8 +298,13 @@
         // in a single stack until you encounter a non-contiguous expanded message header,
         // then decrement to the next spacer.
 
+        traceLayout("IN positionOverlays, spacerCount=%d overlayCount=%d", mOverlayBottoms.length,
+                mOverlayAdapter.getCount());
+
         int adapterIndex = mOverlayAdapter.getCount() - 1;
-        for (int spacerIndex = mOverlayBottoms.length - 1; spacerIndex >= 0; spacerIndex--) {
+        int spacerIndex = mOverlayBottoms.length - 1;
+        while (spacerIndex >= 0 && adapterIndex >= 0) {
+
             final int spacerBottomY = getOverlayBottom(spacerIndex);
 
             // always place at least one overlay per spacer
@@ -304,6 +313,8 @@
             int overlayBottomY = spacerBottomY;
             int overlayTopY = overlayBottomY - adapterItem.getHeight();
 
+            traceLayout("in loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex, adapterIndex,
+                    overlayTopY, overlayBottomY, adapterItem);
             positionOverlay(adapterIndex, overlayTopY, overlayBottomY);
 
             // and keep stacking overlays as long as they are contiguous
@@ -317,8 +328,12 @@
                 overlayBottomY = overlayTopY; // stack on top of previous overlay
                 overlayTopY = overlayBottomY - adapterItem.getHeight();
 
+                traceLayout("in contig loop, spacer=%d overlay=%d t/b=%d/%d (%s)", spacerIndex,
+                        adapterIndex, overlayTopY, overlayBottomY, adapterItem);
                 positionOverlay(adapterIndex, overlayTopY, overlayBottomY);
             }
+
+            spacerIndex--;
         }
     }
 
@@ -326,6 +341,10 @@
      * Copied/stolen from {@link ListView}.
      */
     private void measureItem(View child) {
+        if (child.getVisibility() == GONE) {
+            return;
+        }
+
         ViewGroup.LayoutParams p = child.getLayoutParams();
         if (p == null) {
             p = new ViewGroup.LayoutParams(
@@ -346,7 +365,7 @@
     }
 
     private void onOverlayScrolledOff(final View overlayView, final int itemType,
-            int overlayTop) {
+            int overlayTop, int overlayBottom) {
         // do it asynchronously, as scroll notification can happen during a draw, when it's not
         // safe to remove children
 
@@ -366,7 +385,7 @@
 
         // push it out of view immediately
         // otherwise this scrolled-off header will continue to draw until the runnable runs
-        layoutOverlay(overlayView, overlayTop);
+        layoutOverlay(overlayView, overlayTop, overlayBottom);
     }
 
     public View getScrapView(int type) {
@@ -402,7 +421,7 @@
     @Override
     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
-        LogUtils.d(TAG, "*** IN header container onMeasure spec for w/h=%d/%d", widthMeasureSpec,
+        LogUtils.i(TAG, "*** IN header container onMeasure spec for w/h=%d/%d", widthMeasureSpec,
                 heightMeasureSpec);
 
         if (mWebView.getVisibility() != GONE) {
@@ -422,7 +441,7 @@
 
     @Override
     protected void onLayout(boolean changed, int l, int t, int r, int b) {
-        LogUtils.d(TAG, "*** IN header container onLayout");
+        LogUtils.i(TAG, "*** IN header container onLayout");
 
         mWebView.layout(0, 0, mWebView.getMeasuredWidth(), mWebView.getMeasuredHeight());
         positionOverlays(0, mOffsetY);
@@ -436,27 +455,42 @@
     private void positionOverlay(int adapterIndex, int overlayTopY, int overlayBottomY) {
         View overlayView = findExistingOverlayView(adapterIndex);
         final int itemType = mOverlayAdapter.getItemViewType(adapterIndex);
-        // is the overlay visible?
-        if (overlayBottomY > mOffsetY && overlayTopY < mOffsetY + getHeight()) {
+        // is the overlay visible and does it have non-zero height?
+        if (overlayTopY != overlayBottomY && overlayBottomY > mOffsetY
+                && overlayTopY < mOffsetY + getHeight()) {
             // show and/or move overlay
             if (overlayView == null) {
                 overlayView = addOverlayView(adapterIndex);
                 measureItem(overlayView);
+                traceLayout("show overlay %d", adapterIndex);
+            } else {
+                traceLayout("move overlay %d", adapterIndex);
             }
             layoutOverlay(overlayView, overlayTopY);
         } else {
             // hide overlay
             if (overlayView != null) {
-                onOverlayScrolledOff(overlayView, itemType, overlayTopY);
+                traceLayout("hide overlay %d", adapterIndex);
+                onOverlayScrolledOff(overlayView, itemType, overlayTopY, overlayBottomY);
+            } else {
+                traceLayout("ignore non-visible overlay %d", adapterIndex);
             }
         }
     }
 
+    private void layoutOverlay(View child, int childTop) {
+        layoutOverlay(child, childTop, childTop + child.getMeasuredHeight());
+    }
+
     // layout an existing view
     // need its top offset into the conversation, its height, and the scroll offset
-    private void layoutOverlay(View child, int childTop) {
+    private void layoutOverlay(View child, int childTop, int childBottom) {
+        if (child.getVisibility() == GONE) {
+            return;
+        }
+
         final int top = childTop - mOffsetY;
-        final int bottom = top + child.getMeasuredHeight();
+        final int bottom = childBottom - mOffsetY;
         child.layout(0, top, child.getMeasuredWidth(), bottom);
     }
 
@@ -471,12 +505,10 @@
         // Since external components can contribute to the scrap heap (addScrapView), we can't
         // assume scrap views had already been attached.
         if (view.getRootView() != view) {
-            LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s",
-                    adapterIndex, view);
+            LogUtils.d(TAG, "want to REUSE scrolled-in view: index=%d obj=%s", adapterIndex, view);
             attachViewToParent(view, -1, view.getLayoutParams());
         } else {
-            LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s",
-                    adapterIndex, view);
+            LogUtils.d(TAG, "want to CREATE scrolled-in view: index=%d obj=%s", adapterIndex, view);
             addViewInLayout(view, -1, view.getLayoutParams(),
                     true /* preventRequestLayout */);
         }
@@ -488,22 +520,43 @@
         for (int i = 0, count = getOverlayCount(); i < count; i++) {
             final View overlay = getOverlayAt(i);
             final Integer tag = (Integer) overlay.getTag(VIEW_TAG_CONVERSATION_INDEX);
-            if (tag != null && tag == adapterIndex) {
+            // ignore children queued to be removed
+            // otherwise we'll re-use and lay out this view and then just throw it away
+            if (tag != null && tag == adapterIndex && !mChildrenToRemove.contains(overlay)) {
                 return overlay;
             }
         }
         return null;
     }
 
+    /**
+     * Prevents any layouts from happening until the next time {@link #onGeometryChange(int[])} is
+     * called. Useful when you know the HTML spacer coordinates are inconsistent with adapter items.
+     * <p>
+     * If you call this, you must ensure that a followup call to {@link #onGeometryChange(int[])}
+     * is made later, when the HTML spacer coordinates are updated.
+     *
+     */
+    public void invalidateSpacerGeometry() {
+        mOverlayBottoms = null;
+    }
+
     // TODO: add margin support for children that want it (e.g. tablet headers?)
     public void onGeometryChange(int[] overlayBottoms) {
-        LogUtils.d(TAG, "*** got overlay spacer bottoms:");
+        traceLayout("*** got overlay spacer bottoms:");
         for (int offsetY : overlayBottoms) {
-            LogUtils.d(TAG, "%d", offsetY);
+            traceLayout("%d", offsetY);
         }
 
         mOverlayBottoms = overlayBottoms;
         positionOverlays(0, mOffsetY);
     }
 
+    private void traceLayout(String msg, Object... params) {
+        if (mDisableLayoutTracing) {
+            return;
+        }
+        LogUtils.i(TAG, msg, params);
+    }
+
 }
diff --git a/src/com/android/mail/browse/ConversationViewAdapter.java b/src/com/android/mail/browse/ConversationViewAdapter.java
index eb0e215..282e39e 100644
--- a/src/com/android/mail/browse/ConversationViewAdapter.java
+++ b/src/com/android/mail/browse/ConversationViewAdapter.java
@@ -17,8 +17,6 @@
 
 package com.android.mail.browse;
 
-import com.google.common.collect.Lists;
-
 import android.app.LoaderManager;
 import android.content.Context;
 import android.view.LayoutInflater;
@@ -36,7 +34,9 @@
 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 com.android.mail.utils.Utils;
+import com.google.common.collect.Lists;
 
 import java.util.List;
 
@@ -60,6 +60,7 @@
     private final MessageHeaderViewCallbacks mMessageCallbacks;
     private ConversationViewHeaderCallbacks mConversationCallbacks;
     private final LayoutInflater mInflater;
+    private boolean mDefaultReplyAll;
 
     private final List<ConversationItem> mItems;
 
@@ -68,6 +69,8 @@
     public static final int VIEW_TYPE_MESSAGE_FOOTER = 2;
     public static final int VIEW_TYPE_COUNT = 3;
 
+    public static final String LOG_TAG = new LogUtils().getLogTag();
+
     public static abstract class ConversationItem {
         private int mHeight;  // in px
 
@@ -84,7 +87,6 @@
          * @see CursorAdapter#bindView(View, Context, android.database.Cursor)
          */
         public abstract void bindView(View v);
-        public abstract int measureHeight(View v, ViewGroup parent);
         /**
          * Returns true if this overlay view is meant to be positioned right on top of the overlay
          * below. This special positioning allows {@link ConversationContainer} to stack overlays
@@ -93,11 +95,31 @@
          */
         public abstract boolean isContiguous();
 
+        /**
+         * Measure the expected visible height of the overlay view. Even if the view is initially
+         * GONE, this method must return whatever height the view is going to be when it is later
+         * made VISIBLE.
+         */
+        public int measureHeight(View v, ViewGroup parent) {
+            return Utils.measureViewHeight(v, parent);
+        }
+
+        /**
+         * This method's behavior is critical and requires some 'splainin.
+         * <p>
+         * Subclasses that return a zero-size height to the {@link ConversationContainer} will
+         * cause the scrolling/recycling logic there to remove any matching view from the container.
+         * The item should switch to returning a non-zero height when its view should re-appear.
+         * <p>
+         * It's imperative that this method stay in sync with the current height of the HTML spacer
+         * that matches this overlay.
+         */
         public int getHeight() {
             return mHeight;
         }
 
         public void setHeight(int h) {
+            LogUtils.i(LOG_TAG, "IN setHeight=%dpx of overlay item: %s", h, this);
             mHeight = h;
         }
     }
@@ -135,11 +157,6 @@
         }
 
         @Override
-        public int measureHeight(View v, ViewGroup parent) {
-            return Utils.measureViewHeight(v, parent);
-        }
-
-        @Override
         public boolean isContiguous() {
             return true;
         }
@@ -148,13 +165,14 @@
 
     public class MessageHeaderItem extends ConversationItem {
         public final Message message;
-        public boolean expanded;
-        public boolean defaultReplyAll;
+        private boolean mExpanded;
+        public boolean detailsExpanded;
 
-        private MessageHeaderItem(Message message, boolean defaultReplyAll, boolean expanded) {
+        private MessageHeaderItem(Message message, boolean expanded) {
             this.message = message;
-            this.expanded = expanded;
-            this.defaultReplyAll = defaultReplyAll;
+            mExpanded = expanded;
+
+            detailsExpanded = false;
         }
 
         @Override
@@ -166,7 +184,7 @@
         public View createView(Context context, LayoutInflater inflater, ViewGroup parent) {
             final MessageHeaderView v = (MessageHeaderView) inflater.inflate(
                     R.layout.conversation_message_header, parent, false);
-            v.initialize(mDateBuilder, mAccount, defaultReplyAll /* defaultReplyAll */);
+            v.initialize(mDateBuilder, mAccount);
             v.setCallbacks(mMessageCallbacks);
             return v;
         }
@@ -174,18 +192,22 @@
         @Override
         public void bindView(View v) {
             final MessageHeaderView header = (MessageHeaderView) v;
-            header.bind(message, expanded, message.shouldShowImagePrompt());
-        }
-
-        @Override
-        public int measureHeight(View v, ViewGroup parent) {
-            final MessageHeaderView header = (MessageHeaderView) v;
-            return header.measureHeight(parent);
+            header.bind(this, mDefaultReplyAll);
         }
 
         @Override
         public boolean isContiguous() {
-            return !expanded;
+            return !isExpanded();
+        }
+
+        public boolean isExpanded() {
+            return mExpanded;
+        }
+
+        public void setExpanded(boolean expanded) {
+            if (mExpanded != expanded) {
+                mExpanded = expanded;
+            }
         }
     }
 
@@ -216,18 +238,23 @@
         @Override
         public void bindView(View v) {
             final MessageFooterView attachmentsView = (MessageFooterView) v;
-            attachmentsView.bind(headerItem.message, headerItem.expanded);
-        }
-
-        @Override
-        public int measureHeight(View v, ViewGroup parent) {
-            return Utils.measureViewHeight(v, parent);
+            attachmentsView.bind(headerItem);
         }
 
         @Override
         public boolean isContiguous() {
             return true;
         }
+
+        @Override
+        public int getHeight() {
+            // a footer may change height while its view does not exist because it is offscreen
+            // (but the header is onscreen and thus collapsible)
+            if (!headerItem.isExpanded()) {
+                return 0;
+            }
+            return super.getHeight();
+        }
     }
 
     public ConversationViewAdapter(Context context, Account account, LoaderManager loaderManager,
@@ -244,6 +271,10 @@
         mItems = Lists.newArrayList();
     }
 
+    public void setDefaultReplyAll(boolean defaultReplyAll) {
+        mDefaultReplyAll = defaultReplyAll;
+    }
+
     @Override
     public int getCount() {
         return mItems.size();
@@ -300,8 +331,8 @@
         return addItem(new ConversationHeaderItem(conv));
     }
 
-    public int addMessageHeader(Message msg, boolean defaultReplyAll, boolean expanded) {
-        return addItem(new MessageHeaderItem(msg, defaultReplyAll, expanded));
+    public int addMessageHeader(Message msg, boolean expanded) {
+        return addItem(new MessageHeaderItem(msg, expanded));
     }
 
     public int addMessageFooter(MessageHeaderItem headerItem) {
diff --git a/src/com/android/mail/browse/MessageFooterView.java b/src/com/android/mail/browse/MessageFooterView.java
index fa86e12..39a459e 100644
--- a/src/com/android/mail/browse/MessageFooterView.java
+++ b/src/com/android/mail/browse/MessageFooterView.java
@@ -17,8 +17,6 @@
 
 package com.android.mail.browse;
 
-import com.google.common.collect.Lists;
-
 import android.app.LoaderManager;
 import android.content.Context;
 import android.content.Loader;
@@ -30,19 +28,20 @@
 
 import com.android.mail.browse.AttachmentLoader.AttachmentCursor;
 import com.android.mail.browse.ConversationContainer.DetachListener;
+import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
 import com.android.mail.providers.Attachment;
 import com.android.mail.providers.Message;
 import com.android.mail.utils.LogUtils;
+import com.google.common.collect.Lists;
 
 import java.util.List;
 
 public class MessageFooterView extends LinearLayout implements DetachListener,
         LoaderManager.LoaderCallbacks<Cursor> {
 
-    private Message mMessage;
+    private MessageHeaderItem mMessageHeaderItem;
     private LoaderManager mLoaderManager;
     private AttachmentCursor mAttachmentsCursor;
-    private boolean mIsExpanded;
     private LayoutInflater mInflater;
 
     /**
@@ -82,45 +81,41 @@
         mLoaderManager = loaderManager;
     }
 
-    public void bind(Message msg, boolean expanded) {
-        mMessage = msg;
-        mIsExpanded = expanded;
+    public void bind(MessageHeaderItem headerItem) {
+        mMessageHeaderItem = headerItem;
+
+        /*
+         * Assuming ConversationContainer does not requesting adapter views for zero-height items,
+         * we should just always render even if the matching header is collapsed.
+         */
 
         removeAllViewsInLayout();
 
         // kick off load of Attachment objects in background thread
         final Integer attachmentLoaderId = getAttachmentLoaderId();
         if (sEnableAttachmentLoaders && attachmentLoaderId != null) {
-            LogUtils.d(LOG_TAG, "binding footer view, calling initLoader for message %d",
+            LogUtils.i(LOG_TAG, "binding footer view, calling initLoader for message %d",
                     attachmentLoaderId);
             mLoaderManager.initLoader(attachmentLoaderId, Bundle.EMPTY, this);
         }
 
-        if (mIsExpanded) {
-            setVisibility(VISIBLE);
-            // Do an initial render if initLoader didn't already do one
-            if (getChildCount() == 0) {
-                renderAttachments();
-            }
-        } else {
-            setVisibility(GONE);
+        // Do an initial render if initLoader didn't already do one
+        if (getChildCount() == 0) {
+            renderAttachments();
         }
+        setVisibility(mMessageHeaderItem.isExpanded() ? VISIBLE : GONE);
     }
 
-    private void destroyLoader() {
+    private void unbind() {
         final Integer loaderId = getAttachmentLoaderId();
         if (mLoaderManager != null && loaderId != null) {
-            LogUtils.d(LOG_TAG, "detaching/reusing footer view,"
-                    + " calling destroyLoader for message %d", loaderId);
+            LogUtils.i(LOG_TAG, "detaching footer view, calling destroyLoader for message %d",
+                    loaderId);
             mLoaderManager.destroyLoader(loaderId);
         }
     }
 
     private void renderAttachments() {
-        if (!mIsExpanded) {
-            return;
-        }
-
         List<Attachment> attachments;
         if (mAttachmentsCursor != null && !mAttachmentsCursor.isClosed()) {
             int i = -1;
@@ -131,7 +126,7 @@
         } else {
             // before the attachment loader results are in, we can still render immediately using
             // the basic info in the message's attachmentsJSON
-            attachments = mMessage.getAttachments();
+            attachments = mMessageHeaderItem.message.getAttachments();
         }
         renderAttachments(attachments);
     }
@@ -153,8 +148,9 @@
 
     private Integer getAttachmentLoaderId() {
         Integer id = null;
-        if (mMessage != null && mMessage.hasAttachments && mMessage.attachmentListUri != null) {
-            id = mMessage.attachmentListUri.hashCode();
+        final Message msg = mMessageHeaderItem == null ? null : mMessageHeaderItem.message;
+        if (msg != null && msg.hasAttachments && msg.attachmentListUri != null) {
+            id = msg.attachmentListUri.hashCode();
         }
         return id;
     }
@@ -162,17 +158,17 @@
     @Override
     protected void onDetachedFromWindow() {
         super.onDetachedFromWindow();
-        destroyLoader();
+        unbind();
     }
 
     @Override
     public void onDetachedFromParent() {
-        destroyLoader();
+        unbind();
     }
 
     @Override
     public Loader<Cursor> onCreateLoader(int id, Bundle args) {
-        return new AttachmentLoader(getContext(), mMessage.attachmentListUri);
+        return new AttachmentLoader(getContext(), mMessageHeaderItem.message.attachmentListUri);
     }
 
     @Override
diff --git a/src/com/android/mail/browse/MessageHeaderView.java b/src/com/android/mail/browse/MessageHeaderView.java
index 0e2cc5d..8187942 100644
--- a/src/com/android/mail/browse/MessageHeaderView.java
+++ b/src/com/android/mail/browse/MessageHeaderView.java
@@ -43,6 +43,7 @@
 import com.android.mail.FormattedDateBuilder;
 import com.android.mail.R;
 import com.android.mail.SenderInfoLoader.ContactInfo;
+import com.android.mail.browse.ConversationViewAdapter.MessageHeaderItem;
 import com.android.mail.compose.ComposeActivity;
 import com.android.mail.perf.Timer;
 import com.android.mail.providers.Account;
@@ -81,7 +82,6 @@
     private static final String LOG_TAG = new LogUtils().getLogTag();
 
     private MessageHeaderViewCallbacks mCallbacks;
-    private boolean mSizeChanged;
 
     private TextView mSenderNameView;
     private TextView mSenderEmailView;
@@ -107,8 +107,6 @@
 
     private boolean mIsSending;
 
-    private boolean mIsExpanded;
-
     private boolean mDetailsExpanded;
 
     /**
@@ -152,6 +150,7 @@
 
     private PopupMenu mPopup;
 
+    private MessageHeaderItem mMessageHeaderItem;
     private Message mMessage;
 
     private boolean mCollapsedDetailsValid;
@@ -162,10 +161,9 @@
     private AsyncQueryHandler mQueryHandler;
 
     public interface MessageHeaderViewCallbacks {
-        void setMessageSpacerHeight(Message msg, int height);
+        void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeight);
 
-        void setMessageExpanded(Message msg, boolean expanded,
-                int spacerHeight);
+        void setMessageExpanded(MessageHeaderItem item, int newSpacerHeight);
 
         void showExternalResources(Message msg);
     }
@@ -237,7 +235,8 @@
     }
 
     public boolean isExpanded() {
-        return mIsExpanded;
+        // (let's just arbitrarily say that unbound views are expanded by default)
+        return mMessageHeaderItem == null || mMessageHeaderItem.isExpanded();
     }
 
     @Override
@@ -277,10 +276,12 @@
      * renderUpperHeaderFrom().
      */
     public void unbind() {
+        mMessageHeaderItem = null;
         mMessage = null;
     }
 
     public void renderUpperHeaderFrom(MessageHeaderView other) {
+        mMessageHeaderItem = other.mMessageHeaderItem;
         mMessage = other.mMessage;
         mSender = other.mSender;
         mDefaultReplyAll = other.mDefaultReplyAll;
@@ -297,23 +298,23 @@
         updateChildVisibility();
     }
 
-    public void initialize(FormattedDateBuilder dateBuilder, Account account,
-            boolean defaultReplyAll) {
+    public void initialize(FormattedDateBuilder dateBuilder, Account account) {
         mDateBuilder = dateBuilder;
         mAccount = account;
-        mDefaultReplyAll = defaultReplyAll;
     }
 
-    public void bind(Message message, boolean expanded, boolean showImagePrompt) {
+    public void bind(MessageHeaderItem headerItem, boolean defaultReplyAll) {
         Timer t = new Timer();
         t.start(HEADER_RENDER_TAG);
 
         mCollapsedDetailsValid = false;
         mExpandedDetailsValid = false;
 
-        mMessage = message;
-        setExpanded(expanded);
-        mShowImagePrompt = showImagePrompt;
+        mMessageHeaderItem = headerItem;
+        mMessage = headerItem.message;
+        mShowImagePrompt = mMessage.shouldShowImagePrompt();
+        mDefaultReplyAll = defaultReplyAll;
+        setExpanded(headerItem.isExpanded());
 
         mTimestampMs = mMessage.dateReceivedMs;
         if (mDateBuilder != null) {
@@ -376,12 +377,23 @@
         return false;
     }
 
-    public int measureHeight(ViewGroup parent) {
+    private void updateSpacerHeight() {
+        final int h = measureHeight();
+
+        mMessageHeaderItem.setHeight(h);
+        if (mCallbacks != null) {
+            mCallbacks.setMessageSpacerHeight(mMessageHeaderItem, h);
+        }
+    }
+
+    private int measureHeight() {
+        ViewGroup parent = (ViewGroup) getParent();
         if (parent == null) {
+            LogUtils.e(LOG_TAG, new Error(), "Unable to measure height of detached header");
             return getHeight();
         }
         mPreMeasuring = true;
-        int h = Utils.measureViewHeight(this, parent);
+        final int h = Utils.measureViewHeight(this, parent);
         mPreMeasuring = false;
         return h;
     }
@@ -405,7 +417,7 @@
         if (mIsSending) {
             sub = null;
         } else {
-            sub = mIsExpanded ? getSenderAddress(mSender) : mSnippet;
+            sub = isExpanded() ? getSenderAddress(mSender) : mSnippet;
         }
         return sub;
     }
@@ -439,7 +451,9 @@
         // use View's 'activated' flag to store expanded state
         // child view state lists can use this to toggle drawables
         setActivated(expanded);
-        mIsExpanded = expanded;
+        if (mMessageHeaderItem != null) {
+            mMessageHeaderItem.setExpanded(expanded);
+        }
     }
 
     /**
@@ -449,7 +463,7 @@
     private void updateChildVisibility() {
         // Too bad this can't be done with an XML state list...
 
-        if (mIsExpanded) {
+        if (isExpanded()) {
             int normalVis, draftVis;
 
             setMessageDetailsVisibility((mIsSnappy) ? GONE : VISIBLE);
@@ -783,7 +797,7 @@
             return;
         }
 
-        setExpanded(!mIsExpanded);
+        setExpanded(!isExpanded());
 
         mSenderNameView.setText(getHeaderTitle());
         mSenderEmailView.setText(getHeaderSubtitle());
@@ -794,15 +808,17 @@
         // reveal the message
         // div in one pass. Force-measuring makes it unnecessary to set
         // mSizeChanged.
-        int h = measureHeight((ViewGroup) getParent());
+        int h = measureHeight();
+        mMessageHeaderItem.setHeight(h);
         if (mCallbacks != null) {
-            mCallbacks.setMessageExpanded(mMessage, mIsExpanded, h);
+            mCallbacks.setMessageExpanded(mMessageHeaderItem, h);
         }
     }
 
     private void toggleMessageDetails(View visibleDetailsView) {
-        setMessageDetailsExpanded(visibleDetailsView == mCollapsedDetailsView);
-        mSizeChanged = true;
+        final boolean detailsExpanded = (visibleDetailsView == mCollapsedDetailsView);
+        setMessageDetailsExpanded(detailsExpanded);
+        updateSpacerHeight();
     }
 
     private void setMessageDetailsExpanded(boolean expand) {
@@ -813,7 +829,9 @@
             hideExpandedDetails();
             showCollapsedDetails();
         }
-        mDetailsExpanded = expand;
+        if (mMessageHeaderItem != null) {
+            mMessageHeaderItem.detailsExpanded = expand;
+        }
     }
 
     public void setMessageDetailsVisibility(int vis) {
@@ -821,23 +839,13 @@
             hideCollapsedDetails();
             hideExpandedDetails();
             hideShowImagePrompt();
-            // FIXME: coordinate with matching footer (if exists) to show/hide
-            // hideAttachments();
         } else {
-            setMessageDetailsExpanded(mDetailsExpanded);
+            setMessageDetailsExpanded(mMessageHeaderItem.detailsExpanded);
             if (mShowImagePrompt) {
                 showImagePrompt();
             } else {
                 hideShowImagePrompt();
             }
-            // FIXME: coordinate with matching footer (if exists) to show/hide
-            /*
-            if (mMessage.hasAttachments) {
-                showAttachments();
-            } else {
-                hideAttachments();
-            }
-            */
         }
         if (mBottomBorderView != null) {
             mBottomBorderView.setVisibility(vis);
@@ -902,10 +910,8 @@
                 TextView descriptionView = (TextView) v.findViewById(R.id.show_pictures_text);
                 descriptionView.setText(R.string.always_show_images);
                 v.setTag(SHOW_IMAGE_PROMPT_ALWAYS);
-                // the new text's line count may differ, which should trigger a
-                // size change to
-                // update the spacer height
-                mSizeChanged = true;
+                // the new text's line count may differ, so update the spacer height
+                updateSpacerHeight();
                 break;
             case SHOW_IMAGE_PROMPT_ALWAYS:
                 mMessage.markAlwaysShowImages(getQueryHandler(), 0 /* token */, null /* cookie */);
@@ -913,7 +919,7 @@
                 mShowImagePrompt = false;
                 v.setTag(null);
                 v.setVisibility(GONE);
-                mSizeChanged = true;
+                updateSpacerHeight();
                 Toast.makeText(getContext(), R.string.always_show_images_toast, Toast.LENGTH_SHORT)
                         .show();
                 break;
@@ -996,21 +1002,6 @@
         mExpandedDetailsView.setVisibility(VISIBLE);
     }
 
-    @Override
-    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
-        super.onSizeChanged(w, h, oldw, oldh);
-
-        if (mSizeChanged) {
-            // propagate new size to webview header spacer
-            // only do this for known size changes
-            if (mCallbacks != null) {
-                mCallbacks.setMessageSpacerHeight(mMessage, h);
-            }
-
-            mSizeChanged = false;
-        }
-    }
-
     /**
      * Returns a short plaintext snippet generated from the given HTML message
      * body. Collapses whitespace, ignores '&lt;' and '&gt;' characters and
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 8898c00..1f3ba50 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -326,6 +326,11 @@
 
         boolean allowNetworkImages = false;
 
+        // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
+
+        mAdapter.setDefaultReplyAll(mActivity.getSettings().replyBehavior ==
+                UIProvider.DefaultReplyBehavior.REPLY_ALL);
+
         // Walk through the cursor and build up an overlay adapter as you go.
         // Each overlay has an entry in the adapter for easy scroll handling in the container.
         // Items are not necessarily 1:1 in cursor and adapter because of super-collapsed blocks.
@@ -353,12 +358,9 @@
             final boolean safeForImages = msg.alwaysShowImages /* || savedStateSaysSafe */;
             allowNetworkImages |= safeForImages;
 
-            final int headerPos = mAdapter
-                    .addMessageHeader(
-                            msg,
-                            (mActivity.getSettings().replyBehavior
-                                    == UIProvider.DefaultReplyBehavior.REPLY_ALL),
-                            true /* expanded */);
+            final boolean expanded = !msg.read || msg.starred || messageCursor.isLast();
+
+            final int headerPos = mAdapter.addMessageHeader(msg, expanded);
             final MessageHeaderItem headerItem = (MessageHeaderItem) mAdapter.getItem(headerPos);
 
             final int footerPos = mAdapter.addMessageFooter(headerItem);
@@ -369,7 +371,7 @@
             final int headerDp = measureOverlayHeight(headerPos);
             final int footerDp = measureOverlayHeight(footerPos);
 
-            mTemplates.appendMessageHtml(msg, true /* expanded */, safeForImages, 1.0f, headerDp,
+            mTemplates.appendMessageHtml(msg, expanded, safeForImages, 1.0f, headerDp,
                     footerDp);
         }
 
@@ -439,14 +441,26 @@
 
     // START message header callbacks
     @Override
-    public void setMessageSpacerHeight(Message msg, int height) {
-        // TODO: update message HTML spacer height
-        // TODO: expand this concept to handle bottom-aligned attachments
+    public void setMessageSpacerHeight(MessageHeaderItem item, int newSpacerHeightPx) {
+        mConversationContainer.invalidateSpacerGeometry();
+
+        // update message HTML spacer height
+        LogUtils.i(LOG_TAG, "setting HTML spacer h=%dpx", newSpacerHeightPx);
+        final int heightDp = (int) (newSpacerHeightPx / mDensity);
+        mWebView.loadUrl(String.format("javascript:setMessageHeaderSpacerHeight('%s', %d);",
+                mTemplates.getMessageDomId(item.message), heightDp));
     }
 
     @Override
-    public void setMessageExpanded(Message msg, boolean expanded, int spacerHeight) {
-        // TODO: show/hide the HTML message body and update the spacer height
+    public void setMessageExpanded(MessageHeaderItem item, int newSpacerHeightPx) {
+        mConversationContainer.invalidateSpacerGeometry();
+
+        // show/hide the HTML message body and update the spacer height
+        LogUtils.i(LOG_TAG, "setting HTML spacer expanded=%s h=%dpx", item.isExpanded(),
+                newSpacerHeightPx);
+        final int heightDp = (int) (newSpacerHeightPx / mDensity);
+        mWebView.loadUrl(String.format("javascript:setMessageBodyVisible('%s', %s, %d);",
+                mTemplates.getMessageDomId(item.message), item.isExpanded(), heightDp));
     }
 
     @Override
diff --git a/src/com/android/mail/ui/HtmlConversationTemplates.java b/src/com/android/mail/ui/HtmlConversationTemplates.java
index 24a96b4..7fae644 100644
--- a/src/com/android/mail/ui/HtmlConversationTemplates.java
+++ b/src/com/android/mail/ui/HtmlConversationTemplates.java
@@ -167,6 +167,7 @@
                 bodyDisplay,
                 zoomValue,
                 body,
+                bodyDisplay,
                 footerHeight
         );
     }