conversation paging

This CL takes the approach of using a permanent ViewPager that
pages through conversation fragments. Its adapter is only
set and populated when the conversation view is shown. In all
other cases, it is an inert empty layer.

One risk with this approach is that it breaks with the typical
fragment transitions that all other content panes use.

On the other hand: conversation fragments are full-on fragments
and benefit from loader separation, and the FragmentManager
takes care of save/restore of state.

Change-Id: Ic17d1ae3f35a0cb1119967f2d34433ad27fa307c
diff --git a/src/com/android/mail/ui/AbstractActivityController.java b/src/com/android/mail/ui/AbstractActivityController.java
index 8a6f79e..01c1937 100644
--- a/src/com/android/mail/ui/AbstractActivityController.java
+++ b/src/com/android/mail/ui/AbstractActivityController.java
@@ -34,6 +34,8 @@
 import android.content.Intent;
 import android.content.Loader;
 import android.database.Cursor;
+import android.database.DataSetObservable;
+import android.database.DataSetObserver;
 import android.net.Uri;
 import android.os.Bundle;
 import android.os.Handler;
@@ -52,6 +54,7 @@
 import com.android.mail.ConversationListContext;
 import com.android.mail.R;
 import com.android.mail.browse.ConversationCursor;
+import com.android.mail.browse.ConversationPagerController;
 import com.android.mail.browse.ConversationCursor.ConversationListener;
 import com.android.mail.browse.SelectedConversationsActionMenu;
 import com.android.mail.compose.ComposeActivity;
@@ -112,8 +115,6 @@
     protected static final String TAG_WAIT = "wait-fragment";
     /** Tag used when loading a conversation list fragment. */
     protected static final String TAG_CONVERSATION_LIST = "tag-conversation-list";
-    /** Tag used when loading a conversation fragment. */
-    protected static final String TAG_CONVERSATION = "tag-conversation";
     /** Tag used when loading a folder list fragment. */
     protected static final String TAG_FOLDER_LIST = "tag-folder-list";
 
@@ -149,6 +150,22 @@
     private final Set<Uri> mCurrentAccountUris = Sets.newHashSet();
     protected Settings mCachedSettings;
     protected ConversationCursor mConversationListCursor;
+    private final DataSetObservable mConversationListObservable = new DataSetObservable() {
+        @Override
+        public void registerObserver(DataSetObserver observer) {
+            final int count = mObservers.size();
+            super.registerObserver(observer);
+            LogUtils.d(LOG_TAG, "IN AAC.registerListObserver: %s before=%d after=%d", observer,
+                    count, mObservers.size());
+        }
+        @Override
+        public void unregisterObserver(DataSetObserver observer) {
+            final int count = mObservers.size();
+            super.unregisterObserver(observer);
+            LogUtils.d(LOG_TAG, "IN AAC.unregisterListObserver: %s before=%d after=%d", observer,
+                    count, mObservers.size());
+        }
+    };
     protected boolean mConversationListenerAdded = false;
 
     private boolean mIsConversationListScrolling = false;
@@ -175,6 +192,7 @@
      */
     SelectedConversationsActionMenu mCabActionMenu;
     protected UndoBarView mUndoBarView;
+    protected ConversationPagerController mPagerController;
 
     // this is split out from the general loader dispatcher because its loader doesn't return a
     // basic Cursor
@@ -275,19 +293,6 @@
     }
 
     /**
-     * Returns the conversation view fragment attached with this activity. If no such fragment
-     * is attached, this method returns null.
-     * @return
-     */
-    protected ConversationViewFragment getConversationViewFragment() {
-        final Fragment fragment = mFragmentManager.findFragmentByTag(TAG_CONVERSATION);
-        if (isValidFragment(fragment)) {
-            return (ConversationViewFragment) fragment;
-        }
-        return null;
-    }
-
-    /**
      * Returns the folder list fragment attached with this activity. If no such fragment is attached
      * this method returns null.
      * @return
@@ -547,13 +552,32 @@
         assert (mActionBarView != null);
         mViewMode.addListener(mActionBarView);
 
-        restoreState(savedState);
-        mUndoBarView = (UndoBarView) mActivity.findViewById(R.id.undo_view);
-        return true;
-    }
+        mPagerController = new ConversationPagerController(mActivity, this);
 
-    @Override
-    public void onRestoreInstanceState(Bundle savedInstanceState) {
+        mUndoBarView = (UndoBarView) mActivity.findViewById(R.id.undo_view);
+
+        final Intent intent = mActivity.getIntent();
+        // immediately handle a clean launch with intent, and any state restoration
+        // that does not rely on restored fragments or loader data
+        // any state restoration that relies on those can be done later in
+        // onRestoreInstanceState, once fragments are up and loader data is re-delivered
+        if (savedState != null) {
+            if (savedState.containsKey(SAVED_ACCOUNT)) {
+                setAccount((Account) savedState.getParcelable(SAVED_ACCOUNT));
+                mActivity.invalidateOptionsMenu();
+            }
+            if (savedState.containsKey(SAVED_FOLDER)) {
+                // Open the folder.
+                onFolderChanged((Folder) savedState.getParcelable(SAVED_FOLDER));
+            }
+        } else if (intent != null) {
+            handleIntent(intent);
+        }
+
+        // Create the accounts loader; this loads the account switch spinner.
+        mActivity.getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
+
+        return true;
     }
 
     @Override
@@ -804,6 +828,12 @@
         // TODO(viki): Auto-generated method stub
     }
 
+    @Override
+    public void onDestroy() {
+        // unregister the ViewPager's observer on the conversation cursor
+        mPagerController.onDestroy();
+    }
+
     /**
      * {@inheritDoc} Subclasses must override this to listen to mode changes
      * from the ViewMode. Subclasses <b>must</b> call the parent's
@@ -816,11 +846,6 @@
         // controllers do
         // this themselves?
 
-        // In conversation list mode, clean up the conversation.
-        if (newMode == ViewMode.CONVERSATION_LIST) {
-            // Clean up the conversation here.
-        }
-
         // We don't want to invalidate the options menu when switching to
         // conversation
         // mode, as it will happen when the conversation finishes loading.
@@ -854,90 +879,13 @@
      *
      * @param savedState
      */
-    protected void restoreState(Bundle savedState) {
-        final Intent intent = mActivity.getIntent();
-        boolean handled = false;
-        if (savedState != null) {
-            if (savedState.containsKey(SAVED_ACCOUNT)) {
-                setAccount((Account) savedState.getParcelable(SAVED_ACCOUNT));
-                mActivity.invalidateOptionsMenu();
-            }
-            if (savedState.containsKey(SAVED_FOLDER)) {
-                // Open the folder.
-                onFolderChanged((Folder) savedState.getParcelable(SAVED_FOLDER));
-                handled = true;
-            }
-            if (savedState.containsKey(SAVED_CONVERSATION)) {
-                // Open the conversation.
-                setCurrentConversation((Conversation) savedState.getParcelable(SAVED_CONVERSATION));
-                showConversation(mCurrentConversation);
-                handled = true;
-            }
-        } else if (intent != null) {
-            if (Intent.ACTION_VIEW.equals(intent.getAction())) {
-                if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) {
-                    setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT));
-                } else if (intent.hasExtra(Utils.EXTRA_ACCOUNT_STRING)) {
-                    setAccount(Account.newinstance(intent
-                            .getStringExtra(Utils.EXTRA_ACCOUNT_STRING)));
-                }
-                if (mAccount != null) {
-                    mActivity.invalidateOptionsMenu();
-                }
-
-                Folder folder = null;
-                if (intent.hasExtra(Utils.EXTRA_FOLDER)) {
-                    // Open the folder.
-                    LogUtils.d(LOG_TAG, "SHOW THE FOLDER at %s",
-                            intent.getParcelableExtra(Utils.EXTRA_FOLDER));
-                    folder = (Folder) intent.getParcelableExtra(Utils.EXTRA_FOLDER);
-
-                } else if (intent.hasExtra(Utils.EXTRA_FOLDER_STRING)) {
-                    // Open the folder.
-                    folder = new Folder(intent.getStringExtra(Utils.EXTRA_FOLDER_STRING));
-                }
-                if (folder != null) {
-                    onFolderChanged(folder);
-                    handled = true;
-                }
-
-                if (intent.hasExtra(Utils.EXTRA_CONVERSATION)) {
-                    // Open the conversation.
-                    LogUtils.d(LOG_TAG, "SHOW THE CONVERSATION at %s",
-                            intent.getParcelableExtra(Utils.EXTRA_CONVERSATION));
-                    setCurrentConversation((Conversation) intent
-                            .getParcelableExtra(Utils.EXTRA_CONVERSATION));
-                    showConversation(mCurrentConversation);
-                    handled = true;
-                }
-
-                if (!handled) {
-                    // Nothing was saved; just load the account inbox.
-                    loadAccountInbox();
-                }
-            } else if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
-                if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) {
-                    // Save this search query for future suggestions.
-                    final String query = intent.getStringExtra(SearchManager.QUERY);
-                    final String authority = mContext.getString(R.string.suggestions_authority);
-                    SearchRecentSuggestions suggestions = new SearchRecentSuggestions(
-                            mContext, authority, SuggestionsProvider.MODE);
-                    suggestions.saveRecentQuery(query, null);
-
-                    mViewMode.enterSearchResultsListMode();
-                    setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT));
-                    mActivity.invalidateOptionsMenu();
-                    restartOptionalLoader(LOADER_RECENT_FOLDERS);
-                    mRecentFolderList.setCurrentAccount(mAccount);
-                    fetchSearchFolder(intent);
-                } else {
-                    LogUtils.e(LOG_TAG, "Missing account extra from search intent.  Finishing");
-                    mActivity.finish();
-                }
-            }
-            if (mAccount != null) {
-                restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR);
-            }
+    @Override
+    public void onRestoreInstanceState(Bundle savedState) {
+        LogUtils.d(LOG_TAG, "IN AAC.onRestoreInstanceState");
+        if (savedState.containsKey(SAVED_CONVERSATION)) {
+            // Open the conversation.
+            setCurrentConversation((Conversation) savedState.getParcelable(SAVED_CONVERSATION));
+            showConversation(mCurrentConversation);
         }
 
         /**
@@ -947,8 +895,74 @@
          * @param savedState
          */
         restoreSelectedConversations(savedState);
-        // Create the accounts loader; this loads the account switch spinner.
-        mActivity.getLoaderManager().initLoader(LOADER_ACCOUNT_CURSOR, null, this);
+    }
+
+    private void handleIntent(Intent intent) {
+        boolean handled = false;
+        if (Intent.ACTION_VIEW.equals(intent.getAction())) {
+            if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) {
+                setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT));
+            } else if (intent.hasExtra(Utils.EXTRA_ACCOUNT_STRING)) {
+                setAccount(Account.newinstance(intent
+                        .getStringExtra(Utils.EXTRA_ACCOUNT_STRING)));
+            }
+            if (mAccount != null) {
+                mActivity.invalidateOptionsMenu();
+            }
+
+            Folder folder = null;
+            if (intent.hasExtra(Utils.EXTRA_FOLDER)) {
+                // Open the folder.
+                LogUtils.d(LOG_TAG, "SHOW THE FOLDER at %s",
+                        intent.getParcelableExtra(Utils.EXTRA_FOLDER));
+                folder = (Folder) intent.getParcelableExtra(Utils.EXTRA_FOLDER);
+
+            } else if (intent.hasExtra(Utils.EXTRA_FOLDER_STRING)) {
+                // Open the folder.
+                folder = new Folder(intent.getStringExtra(Utils.EXTRA_FOLDER_STRING));
+            }
+            if (folder != null) {
+                onFolderChanged(folder);
+                handled = true;
+            }
+
+            if (intent.hasExtra(Utils.EXTRA_CONVERSATION)) {
+                // Open the conversation.
+                LogUtils.d(LOG_TAG, "SHOW THE CONVERSATION at %s",
+                        intent.getParcelableExtra(Utils.EXTRA_CONVERSATION));
+                setCurrentConversation((Conversation) intent
+                        .getParcelableExtra(Utils.EXTRA_CONVERSATION));
+                showConversation(mCurrentConversation);
+                handled = true;
+            }
+
+            if (!handled) {
+                // Nothing was saved; just load the account inbox.
+                loadAccountInbox();
+            }
+        } else if (Intent.ACTION_SEARCH.equals(intent.getAction())) {
+            if (intent.hasExtra(Utils.EXTRA_ACCOUNT)) {
+                // Save this search query for future suggestions.
+                final String query = intent.getStringExtra(SearchManager.QUERY);
+                final String authority = mContext.getString(R.string.suggestions_authority);
+                SearchRecentSuggestions suggestions = new SearchRecentSuggestions(
+                        mContext, authority, SuggestionsProvider.MODE);
+                suggestions.saveRecentQuery(query, null);
+
+                mViewMode.enterSearchResultsListMode();
+                setAccount((Account) intent.getParcelableExtra(Utils.EXTRA_ACCOUNT));
+                mActivity.invalidateOptionsMenu();
+                restartOptionalLoader(LOADER_RECENT_FOLDERS);
+                mRecentFolderList.setCurrentAccount(mAccount);
+                fetchSearchFolder(intent);
+            } else {
+                LogUtils.e(LOG_TAG, "Missing account extra from search intent.  Finishing");
+                mActivity.finish();
+            }
+        }
+        if (mAccount != null) {
+            restartOptionalLoader(LOADER_ACCOUNT_UPDATE_CURSOR);
+        }
     }
 
     /**
@@ -966,6 +980,8 @@
             mSelectedSet.clear();
             return;
         }
+
+        // putAll will take care of calling our registered onSetPopulated method
         mSelectedSet.putAll(selectedSet);
     }
 
@@ -1045,7 +1061,8 @@
      * perform common actions associated with changing the current conversation.
      * @param conversation
      */
-    protected void setCurrentConversation(Conversation conversation) {
+    @Override
+    public void setCurrentConversation(Conversation conversation) {
         mCurrentConversation = conversation;
         mTracker.initialize(mCurrentConversation);
     }
@@ -1131,6 +1148,16 @@
         lm.initLoader(id, Bundle.EMPTY, this);
     }
 
+    @Override
+    public void registerConversationListObserver(DataSetObserver observer) {
+        mConversationListObservable.registerObserver(observer);
+    }
+
+    @Override
+    public void unregisterConversationListObserver(DataSetObserver observer) {
+        mConversationListObservable.unregisterObserver(observer);
+    }
+
     private boolean accountsUpdated(Cursor accountCursor) {
         // Check to see if the current account hasn't been set, or the account cursor is empty
         if (mAccount == null || !accountCursor.moveToFirst()) {
@@ -1506,6 +1533,8 @@
     @Override
     public void onDataSetChanged() {
         refreshAdapter();
+
+        mConversationListObservable.notifyChanged();
     }
 
     private void refreshAdapter() {
@@ -1712,6 +1741,11 @@
         }
     }
 
+    @Override
+    public void onConversationSeen(Conversation conv) {
+        mPagerController.onConversationSeen(conv);
+    }
+
     private class ConversationListLoaderCallbacks implements
         LoaderManager.LoaderCallbacks<ConversationCursor> {
 
@@ -1724,6 +1758,8 @@
 
         @Override
         public void onLoadFinished(Loader<ConversationCursor> loader, ConversationCursor data) {
+            LogUtils.d(LOG_TAG, "IN AAC.ConversationCursor.onLoadFinished, data=%s loader=%s",
+                    data, loader);
             mConversationListCursor = data;
 
             // Call the method that updates things when values in the cursor change
diff --git a/src/com/android/mail/ui/ActivityController.java b/src/com/android/mail/ui/ActivityController.java
index 4b4c497..5d2e3c6 100644
--- a/src/com/android/mail/ui/ActivityController.java
+++ b/src/com/android/mail/ui/ActivityController.java
@@ -147,6 +147,11 @@
     void onPause();
 
     /**
+     * @see android.app.Activity#onDestroy
+     */
+    void onDestroy();
+
+    /**
      * @see android.app.Activity#onPrepareDialog
      * @param id
      * @param dialog
@@ -280,4 +285,10 @@
 
     void onUndoCancel();
 
+    /**
+     * Coordinates actions that might occur in response to a conversation that has finished loading
+     * and is now user-visible.
+     */
+    void onConversationSeen(Conversation conv);
+
 }
diff --git a/src/com/android/mail/ui/ControllableActivity.java b/src/com/android/mail/ui/ControllableActivity.java
index 7018282..3950a98 100644
--- a/src/com/android/mail/ui/ControllableActivity.java
+++ b/src/com/android/mail/ui/ControllableActivity.java
@@ -17,6 +17,7 @@
 
 package com.android.mail.ui;
 
+import com.android.mail.providers.Conversation;
 import com.android.mail.ui.ViewMode.ModeChangeListener;
 import com.android.mail.ui.FolderListFragment;
 
@@ -75,4 +76,6 @@
     FolderListFragment.FolderListSelectionListener getFolderListSelectionListener();
 
     DragListener getDragListener();
+
+    void onConversationSeen(Conversation conv);
 }
diff --git a/src/com/android/mail/ui/ConversationListCallbacks.java b/src/com/android/mail/ui/ConversationListCallbacks.java
index c493a1e..3d6a85f 100644
--- a/src/com/android/mail/ui/ConversationListCallbacks.java
+++ b/src/com/android/mail/ui/ConversationListCallbacks.java
@@ -17,6 +17,8 @@
 
 package com.android.mail.ui;
 
+import android.database.DataSetObserver;
+
 import com.android.mail.browse.ConversationCursor;
 import com.android.mail.providers.Conversation;
 
@@ -32,4 +34,9 @@
     void onConversationSelected(Conversation conversation);
 
     ConversationCursor getConversationListCursor();
+
+    void setCurrentConversation(Conversation c);
+
+    void registerConversationListObserver(DataSetObserver observer);
+    void unregisterConversationListObserver(DataSetObserver observer);
 }
diff --git a/src/com/android/mail/ui/ConversationListFragment.java b/src/com/android/mail/ui/ConversationListFragment.java
index 729821d..0d03431 100644
--- a/src/com/android/mail/ui/ConversationListFragment.java
+++ b/src/com/android/mail/ui/ConversationListFragment.java
@@ -402,12 +402,12 @@
      * @param position
      */
     protected void viewConversation(int position) {
+        getListView().setItemChecked(position, true);
         ConversationCursor conversationListCursor = getConversationListCursor();
         conversationListCursor.moveToPosition(position);
         Conversation conv = new Conversation(conversationListCursor);
         conv.position = position;
         mCallbacks.onConversationSelected(conv);
-        getListView().setItemChecked(position, true);
     }
 
     private ConversationCursor getConversationListCursor() {
diff --git a/src/com/android/mail/ui/ConversationViewFragment.java b/src/com/android/mail/ui/ConversationViewFragment.java
index 914cf9c..75f0ee8 100644
--- a/src/com/android/mail/ui/ConversationViewFragment.java
+++ b/src/com/android/mail/ui/ConversationViewFragment.java
@@ -84,6 +84,7 @@
         SuperCollapsedBlock.OnClickListener {
 
     private static final String LOG_TAG = new LogUtils().getLogTag();
+    public static final String LAYOUT_TAG = "ConvLayout";
 
     private static final int MESSAGE_LOADER_ID = 0;
 
@@ -118,6 +119,9 @@
 
     private float mDensity;
 
+    /**
+     * Folder is used to help determine valid menu actions for this conversation.
+     */
     private Folder mFolder;
 
     private final Map<String, Address> mAddressCache = Maps.newHashMap();
@@ -130,8 +134,14 @@
      */
     private String mTempBodiesHtml;
 
+    private boolean mUserVisible;
+
+    private int  mMaxAutoLoadMessages;
+
+    private boolean mDeferredConversationLoad;
+
     private static final String ARG_ACCOUNT = "account";
-    private static final String ARG_CONVERSATION = "conversation";
+    public static final String ARG_CONVERSATION = "conversation";
     private static final String ARG_FOLDER = "folder";
 
     /**
@@ -143,7 +153,7 @@
 
     /**
      * Creates a new instance of {@link ConversationViewFragment}, initialized
-     * to display conversation.
+     * to display a conversation.
      */
     public static ConversationViewFragment newInstance(Account account,
             Conversation conversation, Folder folder) {
@@ -156,8 +166,31 @@
        return f;
     }
 
+    /**
+     * Creates a new instance of {@link ConversationViewFragment}, initialized
+     * to display a conversation with other parameters inherited/copied from an existing bundle,
+     * typically one created using {@link #makeBasicArgs}.
+     */
+    public static ConversationViewFragment newInstance(Bundle existingArgs,
+            Conversation conversation) {
+        ConversationViewFragment f = new ConversationViewFragment();
+        Bundle args = new Bundle(existingArgs);
+        args.putParcelable(ARG_CONVERSATION, conversation);
+        f.setArguments(args);
+        return f;
+    }
+
+    public static Bundle makeBasicArgs(Account account, Folder folder) {
+        Bundle args = new Bundle();
+        args.putParcelable(ARG_ACCOUNT, account);
+        args.putParcelable(ARG_FOLDER, folder);
+        return args;
+    }
+
     @Override
     public void onActivityCreated(Bundle savedInstanceState) {
+        LogUtils.d(LOG_TAG, "IN CVF.onActivityCreated, this=%s subj=%s", this,
+                mConversation.subject);
         super.onActivityCreated(savedInstanceState);
         // Strictly speaking, we get back an android.app.Activity from getActivity. However, the
         // only activity creating a ConversationListContext is a MailActivity which is of type
@@ -183,13 +216,14 @@
 
         mDensity = getResources().getDisplayMetrics().density;
 
-        // Show conversation and start loading messages.
+        mMaxAutoLoadMessages = getResources().getInteger(R.integer.max_auto_load_messages);
+
         showConversation();
     }
 
     @Override
     public void onCreate(Bundle savedState) {
-        LogUtils.v(LOG_TAG, "onCreate in FolderListFragment(this=%s)", this);
+        LogUtils.d(LOG_TAG, "onCreate in ConversationViewFragment (this=%s)", this);
         super.onCreate(savedState);
 
         Bundle args = getArguments();
@@ -205,7 +239,7 @@
     @Override
     public View onCreateView(LayoutInflater inflater,
             ViewGroup container, Bundle savedInstanceState) {
-        View rootView = inflater.inflate(R.layout.conversation_view, null);
+        View rootView = inflater.inflate(R.layout.conversation_view, container, false);
         mConversationContainer = (ConversationContainer) rootView
                 .findViewById(R.id.conversation_container);
         mWebView = (ConversationWebView) mConversationContainer.findViewById(R.id.webview);
@@ -284,14 +318,54 @@
                         && mFolder.supportsCapability(FolderCapabilities.DESTRUCTIVE_MUTE)
                         && !mConversation.muted);
     }
+
+    /**
+     * {@link #setUserVisibleHint(boolean)} only works on API >= 15, so implement our own for
+     * reliability on older platforms.
+     */
+    public void setExtraUserVisibleHint(boolean isVisibleToUser) {
+        LogUtils.v(LOG_TAG, "in CVF.setHint, val=%s (%s)", isVisibleToUser, this);
+
+        if (mUserVisible != isVisibleToUser) {
+            mUserVisible = isVisibleToUser;
+
+            if (isVisibleToUser && mViewsCreated) {
+
+                if (mCursor == null && mDeferredConversationLoad) {
+                    // load
+                    LogUtils.v(LOG_TAG, "Fragment is now user-visible, showing conversation: %s",
+                            mConversation.uri);
+                    showConversation();
+                    mDeferredConversationLoad = false;
+                } else {
+                    onConversationSeen();
+                }
+
+            }
+        }
+    }
+
     /**
      * Handles a request to show a new conversation list, either from a search query or for viewing
      * a folder. This will initiate a data load, and hence must be called on the UI thread.
      */
     private void showConversation() {
+        if (!mUserVisible && mConversation.numMessages > mMaxAutoLoadMessages) {
+            LogUtils.v(LOG_TAG, "Fragment not user-visible, not showing conversation: %s",
+                    mConversation.uri);
+            mDeferredConversationLoad = true;
+            return;
+        }
+        LogUtils.v(LOG_TAG,
+                "Fragment is short or user-visible, immediately rendering conversation: %s",
+                mConversation.uri);
         getLoaderManager().initLoader(MESSAGE_LOADER_ID, Bundle.EMPTY, this);
     }
 
+    public Conversation getConversation() {
+        return mConversation;
+    }
+
     @Override
     public Loader<Cursor> onCreateLoader(int id, Bundle args) {
         return new MessageLoader(mContext, mConversation.messageListUri);
@@ -301,6 +375,12 @@
     public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
         MessageCursor messageCursor = (MessageCursor) data;
 
+        // ignore truly duplicate results
+        // this can happen when restoring after rotation
+        if (mCursor == messageCursor) {
+            return;
+        }
+
         // TODO: handle Gmail loading states (like LOADING and ERROR)
         if (messageCursor.getCount() == 0) {
             if (mCursor != null) {
@@ -312,6 +392,10 @@
             return;
         }
 
+        // TODO: if this is not user-visible, delay render until user-visible fragment is done.
+        // This is needed in addition to the showConversation() delay to speed up rotation and
+        // restoration.
+
         renderConversation(messageCursor);
     }
 
@@ -343,6 +427,9 @@
      */
     private String renderMessageBodies(MessageCursor messageCursor) {
         int pos = -1;
+
+        LogUtils.d(LOG_TAG, "IN renderMessageBodies, fragment=%s subj=%s", this,
+                mConversation.subject);
         boolean allowNetworkImages = false;
 
         // TODO: re-use any existing adapter item state (expanded, details expanded, show pics)
@@ -501,6 +588,19 @@
         return (int) (heightPx / mDensity);
     }
 
+    private void onConversationSeen() {
+        // mark as read upon open
+        if (!mConversation.read) {
+            mConversation.markRead(mContext, true /* read */);
+            mConversation.read = true;
+        }
+
+        ControllableActivity activity = (ControllableActivity) getActivity();
+        if (activity != null) {
+            activity.onConversationSeen(mConversation);
+        }
+    }
+
     // BEGIN conversation header callbacks
     @Override
     public void onFoldersClicked() {
@@ -530,7 +630,7 @@
         mConversationContainer.invalidateSpacerGeometry();
 
         // update message HTML spacer height
-        LogUtils.i(LOG_TAG, "setting HTML spacer h=%dpx", newSpacerHeightPx);
+        LogUtils.i(LAYOUT_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));
@@ -541,7 +641,7 @@
         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(),
+        LogUtils.i(LAYOUT_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);",
@@ -613,15 +713,16 @@
 
         @Override
         public void onPageFinished(WebView view, String url) {
+            LogUtils.i(LOG_TAG, "IN CVF.onPageFinished, url=%s fragment=%s", url,
+                    ConversationViewFragment.this);
+
             super.onPageFinished(view, url);
 
             // TODO: save off individual message unread state (here, or in onLoadFinished?) so
             // 'mark unread' restores the original unread state for each individual message
 
-            // mark as read upon open
-            if (!mConversation.read) {
-                mConversation.markRead(mContext, true /* read */);
-                mConversation.read = true;
+            if (mUserVisible) {
+                onConversationSeen();
             }
         }
 
diff --git a/src/com/android/mail/ui/FolderSelectionActivity.java b/src/com/android/mail/ui/FolderSelectionActivity.java
index 2d93b71..e0423b3 100644
--- a/src/com/android/mail/ui/FolderSelectionActivity.java
+++ b/src/com/android/mail/ui/FolderSelectionActivity.java
@@ -35,6 +35,7 @@
 
 import com.android.mail.R;
 import com.android.mail.providers.Account;
+import com.android.mail.providers.Conversation;
 import com.android.mail.providers.Folder;
 import com.android.mail.providers.Settings;
 import com.android.mail.ui.FolderListFragment.FolderListSelectionListener;
@@ -314,4 +315,9 @@
     public void onUndoAvailable(UndoOperation undoOp) {
         // Do nothing.
     }
+
+    @Override
+    public void onConversationSeen(Conversation conv) {
+        // Do nothing.
+    }
 }
diff --git a/src/com/android/mail/ui/MailActivity.java b/src/com/android/mail/ui/MailActivity.java
index 8540a4e..fd64237 100644
--- a/src/com/android/mail/ui/MailActivity.java
+++ b/src/com/android/mail/ui/MailActivity.java
@@ -29,6 +29,7 @@
 import android.view.MenuItem;
 import android.view.MotionEvent;
 
+import com.android.mail.providers.Conversation;
 import com.android.mail.providers.Folder;
 import com.android.mail.providers.Settings;
 import com.android.mail.ui.FolderListFragment.FolderListSelectionListener;
@@ -220,6 +221,12 @@
     }
 
     @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        mController.onDestroy();
+    }
+
+    @Override
     public void onWindowFocusChanged(boolean hasFocus) {
         super.onWindowFocusChanged(hasFocus);
         mController.onWindowFocusChanged(hasFocus);
@@ -289,4 +296,9 @@
     public void onUndoAvailable(UndoOperation undoOp) {
         mController.onUndoAvailable(undoOp);
     }
+
+    @Override
+    public void onConversationSeen(Conversation conv) {
+        mController.onConversationSeen(conv);
+    }
 }
diff --git a/src/com/android/mail/ui/OnePaneController.java b/src/com/android/mail/ui/OnePaneController.java
index df55ba1..e454884 100644
--- a/src/com/android/mail/ui/OnePaneController.java
+++ b/src/com/android/mail/ui/OnePaneController.java
@@ -18,6 +18,7 @@
 package com.android.mail.ui;
 
 import android.app.Fragment;
+import android.app.FragmentManager;
 import android.app.FragmentTransaction;
 import android.database.Cursor;
 import android.net.Uri;
@@ -82,8 +83,8 @@
     }
 
     @Override
-    protected void restoreState(Bundle inState) {
-        super.restoreState(inState);
+    public void onRestoreInstanceState(Bundle inState) {
+        super.onRestoreInstanceState(inState);
         // TODO(mindyp) handle saved state.
         if (inState != null) {
             mLastFolderListTransactionId = inState.getInt(FOLDER_LIST_TRANSACTION_KEY, INVALID_ID);
@@ -147,6 +148,13 @@
     @Override
     public void onViewModeChanged(int newMode) {
         super.onViewModeChanged(newMode);
+
+        // When entering conversation list mode, hide and clean up any currently visible
+        // conversation.
+        // TODO: improve this transition
+        if (newMode == ViewMode.CONVERSATION_LIST) {
+            mPagerController.hide();
+        }
     }
 
     @Override
@@ -190,9 +198,26 @@
         } else {
             mViewMode.enterConversationMode();
         }
-        mLastConversationTransactionId = replaceFragment(
-                ConversationViewFragment.newInstance(mAccount, conversation, mFolder),
-                FragmentTransaction.TRANSIT_FRAGMENT_OPEN, TAG_CONVERSATION);
+
+        // Switching to conversation view is an incongruous transition: we are not replacing a
+        // fragment with another fragment as usual. Instead, reveal the heretofore inert
+        // conversation ViewPager and just remove the previously visible fragment
+        // (e.g. conversation list, or possibly label list?).
+        FragmentManager fm = mActivity.getFragmentManager();
+        Fragment f = fm.findFragmentById(R.id.content_pane);
+        if (f != null) {
+            FragmentTransaction ft = fm.beginTransaction();
+            ft.addToBackStack(null);
+            ft.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
+            ft.remove(f);
+            ft.commitAllowingStateLoss();
+        }
+
+        // TODO: improve this transition
+        mPagerController.show(mAccount, mFolder, conversation);
+
+        resetActionBarIcon();
+
         mConversationListVisible = false;
     }
 
diff --git a/src/com/android/mail/ui/TwoPaneController.java b/src/com/android/mail/ui/TwoPaneController.java
index 28cdd35..7a3ace9 100644
--- a/src/com/android/mail/ui/TwoPaneController.java
+++ b/src/com/android/mail/ui/TwoPaneController.java
@@ -42,11 +42,11 @@
 import android.widget.FrameLayout;
 
 /**
- * Controller for one-pane Mail activity. One Pane is used for phones, where screen real estate is
- * limited.
+ * Controller for two-pane Mail activity. Two Pane is used for tablets, where screen real estate
+ * abounds.
  */
 
-// Called OnePaneActivityController in Gmail.
+// Called TwoPaneActivityController in Gmail.
 public final class TwoPaneController extends AbstractActivityController {
     private TwoPaneLayout mLayout;
     private final ActionCompleteListener mDeleteListener = new TwoPaneDestructiveActionListener(
@@ -194,6 +194,15 @@
     }
 
     @Override
+    public void onConversationVisibilityChanged(boolean visible) {
+        super.onConversationVisibilityChanged(visible);
+
+        if (!visible) {
+            mPagerController.hide();
+        }
+    }
+
+    @Override
     public void resetActionBarIcon() {
         if (mViewMode.getMode() == ViewMode.CONVERSATION_LIST) {
             mActionBarView.removeBackButton();
@@ -215,12 +224,8 @@
         } else {
             mViewMode.enterConversationMode();
         }
-        Fragment convFragment = ConversationViewFragment.newInstance(mAccount, conversation,
-                mFolder);
-        FragmentTransaction fragmentTransaction = mActivity.getFragmentManager().beginTransaction();
-        fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
-        fragmentTransaction.replace(R.id.conversation_pane, convFragment, TAG_CONVERSATION);
-        fragmentTransaction.commitAllowingStateLoss();
+
+        mPagerController.show(mAccount, mFolder, conversation);
     }
 
     @Override
@@ -487,7 +492,6 @@
                 }
                 break;
             case ViewMode.CONVERSATION:
-                final ConversationViewFragment convView = getConversationViewFragment();
                 if (op.mBatch) {
                     // Show undo bar in the conversation list.
                     params = (FrameLayout.LayoutParams) mUndoBarView.getLayoutParams();
@@ -497,15 +501,11 @@
                     // Show undo bar in the conversation.
                     params = (FrameLayout.LayoutParams) mUndoBarView.getLayoutParams();
                     params.gravity = Gravity.BOTTOM | Gravity.RIGHT;
-                    if (convView != null) {
-                        params.width = convView.getView().getWidth();
-                    }
+                    params.width = mLayout.getConversationView().getWidth();
                 }
                 mUndoBarView.setLayoutParams(params);
-                if (convView != null) {
-                    mUndoBarView.show(true, mActivity.getActivityContext(), op, mAccount,
-                        convList.getAnimatedAdapter());
-                }
+                mUndoBarView.show(true, mActivity.getActivityContext(), op, mAccount,
+                    convList.getAnimatedAdapter());
                 break;
         }
     }
diff --git a/src/com/android/mail/ui/TwoPaneLayout.java b/src/com/android/mail/ui/TwoPaneLayout.java
index 146a608..a3ec263 100644
--- a/src/com/android/mail/ui/TwoPaneLayout.java
+++ b/src/com/android/mail/ui/TwoPaneLayout.java
@@ -534,6 +534,10 @@
         mListPaint.setAntiAlias(true);
     }
 
+    public View getConversationView() {
+        return mConversationView;
+    }
+
     private boolean isAnimatingFade() {
         return mAnimatingFade;
     }