/* * Copyright (C) 2012 Google Inc. * Licensed to The Android Open Source Project. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.mail.browse; import android.app.Fragment; import android.app.FragmentManager; import android.app.FragmentTransaction; import android.content.Context; import android.database.Cursor; import android.database.DataSetObserver; import android.os.Bundle; import android.os.Parcelable; import android.support.v4.view.ViewPager; import android.view.ViewGroup; import com.android.mail.preferences.MailPrefs; import com.android.mail.providers.Account; import com.android.mail.providers.Conversation; import com.android.mail.providers.Folder; import com.android.mail.providers.FolderObserver; import com.android.mail.providers.UIProvider; import com.android.mail.ui.AbstractConversationViewFragment; import com.android.mail.ui.ActivityController; import com.android.mail.ui.ConversationViewFragment; import com.android.mail.ui.SecureConversationViewFragment; import com.android.mail.ui.TwoPaneController; import com.android.mail.utils.FragmentStatePagerAdapter2; import com.android.mail.utils.HtmlSanitizer; import com.android.mail.utils.LogUtils; public class ConversationPagerAdapter extends FragmentStatePagerAdapter2 implements ViewPager.OnPageChangeListener { private final DataSetObserver mListObserver = new ListObserver(); private final FolderObserver mFolderObserver = new FolderObserver() { @Override public void onChanged(Folder newFolder) { notifyDataSetChanged(); } }; private ActivityController mController; private final Bundle mCommonFragmentArgs; private final Conversation mInitialConversation; private final Account mAccount; private final Folder mFolder; /** * In singleton mode, this adapter ignores the cursor contents and size, and acts as if the * data set size is exactly size=1, with {@link #getDefaultConversation()} at position 0. */ private boolean mSingletonMode = false; /** * Similar to singleton mode, but once enabled, detached mode is permanent for this adapter. */ private boolean mDetachedMode = false; /** * True iff we are in the process of handling a dataset change. */ private boolean mInDataSetChange = false; private Context mContext; /** * This isn't great to create a circular dependency, but our usage of {@link #getPageTitle(int)} * requires knowing which page is the currently visible to dynamically name offscreen pages * "newer" and "older". And {@link #setPrimaryItem(ViewGroup, int, Object)} does not work well * because it isn't updated as often as {@link ViewPager#getCurrentItem()} is. *

* We must be careful to null out this reference when the pager and adapter are decoupled to * minimize dangling references. */ private ViewPager mPager; /** * true indicates the server has already sanitized all HTML email from this account. */ private boolean mServerSanitizedHtml; /** * true indicates the client is permitted to sanitize all HTML email for this account. */ private boolean mClientSanitizedHtml; private boolean mStopListeningMode = false; /** * After {@link #stopListening()} is called, this contains the last-known count of this adapter. * We keep this around and use it in lieu of the Cursor's true count until imminent destruction * to satisfy two opposing requirements: *

    *
  1. The ViewPager always likes to know about all dataset changes via notifyDatasetChanged. *
  2. Destructive changes during pager destruction (e.g. mode transition from conversation mode * to list mode) must be ignored, or else ViewPager will shift focus onto a neighboring * conversation and mark it read. *
* */ private int mLastKnownCount; /** * Once this adapter is connected to a ViewPager's saved state (from a previous * {@link #saveState()}), this field keeps the state around in case it later needs to be used * to find and kill page fragments. */ private Bundle mRestoredState; private final FragmentManager mFragmentManager; private boolean mPageChangeListenerEnabled; private static final String LOG_TAG = ConversationPagerController.LOG_TAG; private static final String BUNDLE_DETACHED_MODE = ConversationPagerAdapter.class.getName() + "-detachedmode"; /** * This is the bundle key prefix for the saved pager fragments as stashed by the parent class. * See the implementation of {@link FragmentStatePagerAdapter2#saveState()}. This assumes that * value!!! */ private static final String BUNDLE_FRAGMENT_PREFIX = "f"; public ConversationPagerAdapter(Context context, FragmentManager fm, Account account, Folder folder, Conversation initialConversation) { super(fm, false /* enableSavedStates */); mContext = context; mFragmentManager = fm; mCommonFragmentArgs = AbstractConversationViewFragment.makeBasicArgs(account); mInitialConversation = initialConversation; mAccount = account; mFolder = folder; mServerSanitizedHtml = mAccount.supportsCapability(UIProvider.AccountCapabilities.SERVER_SANITIZED_HTML); mClientSanitizedHtml = mAccount.supportsCapability(UIProvider.AccountCapabilities.CLIENT_SANITIZED_HTML); } public boolean matches(Account account, Folder folder) { return mAccount != null && mFolder != null && mAccount.matches(account) && mFolder.equals(folder); } public void setSingletonMode(boolean enabled) { if (mSingletonMode != enabled) { mSingletonMode = enabled; notifyDataSetChanged(); } } public boolean isSingletonMode() { return mSingletonMode; } public boolean isDetached() { return mDetachedMode; } /** * Returns true if singleton mode or detached mode have been enabled, or if the current cursor * is null. * @param cursor the current conversation cursor (obtained through {@link #getCursor()}. * @return */ public boolean isPagingDisabled(Cursor cursor) { return mSingletonMode || mDetachedMode || cursor == null; } private ConversationCursor getCursor() { if (mDetachedMode) { // In detached mode, the pager is decoupled from the cursor. Nothing should rely on the // cursor at this point. return null; } if (mController == null) { // Happens when someone calls setActivityController(null) on us. This is done in // ConversationPagerController.stopListening() to indicate that the Conversation View // is going away *very* soon. LogUtils.i(LOG_TAG, "Pager adapter has a null controller. If the conversation view" + " is going away, this is fine. Otherwise, the state is inconsistent"); return null; } return mController.getConversationListCursor(); } @Override public Fragment getItem(int position) { final Conversation c; final ConversationCursor cursor = getCursor(); if (isPagingDisabled(cursor)) { // cursor-less adapter is a size-1 cursor that points to mInitialConversation. // sanity-check if (position != 0) { LogUtils.wtf(LOG_TAG, "pager cursor is null and position is non-zero: %d", position); } c = getDefaultConversation(); c.position = 0; } else { if (!cursor.moveToPosition(position)) { LogUtils.wtf(LOG_TAG, "unable to seek to ConversationCursor pos=%d (%s)", position, cursor); return null; } cursor.notifyUIPositionChange(); c = cursor.getConversation(); c.position = position; } final AbstractConversationViewFragment f = getConversationViewFragment(c); LogUtils.d(LOG_TAG, "IN PagerAdapter.getItem, frag=%s conv=%s this=%s", f, c, this); return f; } private AbstractConversationViewFragment getConversationViewFragment(Conversation c) { // if Html email bodies are already sanitized by the mail server, scripting can be enabled if (mServerSanitizedHtml) { return ConversationViewFragment.newInstance(mCommonFragmentArgs, c); } // if this client is permitted to sanitize emails for this account, attempt to do so if (mClientSanitizedHtml) { // if the version of the Html Sanitizer meets or exceeds the required version, the // results of the sanitizer can be trusted and scripting can be enabled final MailPrefs mailPrefs = MailPrefs.get(mContext); if (HtmlSanitizer.VERSION >= mailPrefs.getRequiredSanitizerVersionNumber()) { return ConversationViewFragment.newInstance(mCommonFragmentArgs, c); } } // otherwise we do not enable scripting return SecureConversationViewFragment.newInstance(mCommonFragmentArgs, c); } @Override public int getCount() { if (mStopListeningMode) { if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) { final Cursor cursor = getCursor(); LogUtils.d(LOG_TAG, "IN CPA.getCount stopListeningMode, returning lastKnownCount=%d." + " cursor=%s real count=%s", mLastKnownCount, cursor, (cursor != null) ? cursor.getCount() : "N/A"); } return mLastKnownCount; } final Cursor cursor = getCursor(); if (isPagingDisabled(cursor)) { LogUtils.d(LOG_TAG, "IN CPA.getCount, returning 1 (effective singleton). cursor=%s", cursor); return 1; } return cursor.getCount(); } @Override public int getItemPosition(Object item) { if (!(item instanceof AbstractConversationViewFragment)) { LogUtils.wtf(LOG_TAG, "getItemPosition received unexpected item: %s", item); } final AbstractConversationViewFragment fragment = (AbstractConversationViewFragment) item; return getConversationPosition(fragment.getConversation()); } @Override public void setPrimaryItem(ViewGroup container, int position, Object object) { LogUtils.d(LOG_TAG, "IN PagerAdapter.setPrimaryItem, pos=%d, frag=%s", position, object); super.setPrimaryItem(container, position, object); } @Override public Parcelable saveState() { LogUtils.d(LOG_TAG, "IN PagerAdapter.saveState. this=%s", this); Bundle state = (Bundle) super.saveState(); // superclass uses a Bundle if (state == null) { state = new Bundle(); } state.putBoolean(BUNDLE_DETACHED_MODE, mDetachedMode); return state; } @Override public void restoreState(Parcelable state, ClassLoader loader) { super.restoreState(state, loader); if (state != null) { Bundle b = (Bundle) state; b.setClassLoader(loader); final boolean detached = b.getBoolean(BUNDLE_DETACHED_MODE); setDetachedMode(detached); // save off the bundle in case it later needs to be consulted for fragments-to-kill mRestoredState = b; } LogUtils.d(LOG_TAG, "OUT PagerAdapter.restoreState. this=%s", this); } /** * Part of an inelegant dance to clean up restored fragments after realizing * we don't want the ViewPager around after all in 2-pane. See docs for * {@link ConversationPagerController#killRestoredFragments()} and * {@link TwoPaneController#restoreConversation}. */ public void killRestoredFragments() { if (mRestoredState == null) { return; } FragmentTransaction ft = null; for (String key : mRestoredState.keySet()) { // WARNING: this code assumes implementation details in // FragmentStatePagerAdapter2#restoreState if (!key.startsWith(BUNDLE_FRAGMENT_PREFIX)) { continue; } final Fragment f = mFragmentManager.getFragment(mRestoredState, key); if (f != null) { if (ft == null) { ft = mFragmentManager.beginTransaction(); } ft.remove(f); } } if (ft != null) { ft.commitAllowingStateLoss(); mFragmentManager.executePendingTransactions(); } mRestoredState = null; } private void setDetachedMode(boolean detached) { if (mDetachedMode == detached) { return; } mDetachedMode = detached; if (mDetachedMode) { mController.setDetachedMode(); } notifyDataSetChanged(); } @Override public String toString() { final StringBuilder sb = new StringBuilder(super.toString()); sb.setLength(sb.length() - 1); sb.append(" detachedMode="); sb.append(mDetachedMode); sb.append(" singletonMode="); sb.append(mSingletonMode); sb.append(" mController="); sb.append(mController); sb.append(" mPager="); sb.append(mPager); sb.append(" mStopListening="); sb.append(mStopListeningMode); sb.append(" mLastKnownCount="); sb.append(mLastKnownCount); sb.append(" cursor="); sb.append(getCursor()); sb.append("}"); return sb.toString(); } @Override public void notifyDataSetChanged() { if (mInDataSetChange) { LogUtils.i(LOG_TAG, "CPA ignoring dataset change generated during dataset change"); return; } mInDataSetChange = true; // If we are in detached mode, changes to the cursor are of no interest to us, but they may // be to parent classes. // when the currently visible item disappears from the dataset: // if the new version of the currently visible item has zero messages: // notify the list controller so it can handle this 'current conversation gone' case // (by backing out of conversation mode) // else // 'detach' the conversation view from the cursor, keeping the current item as-is but // disabling swipe (effectively the same as singleton mode) if (mController != null && !mDetachedMode && mPager != null) { final Conversation currConversation = mController.getCurrentConversation(); final int pos = getConversationPosition(currConversation); final ConversationCursor cursor = getCursor(); if (pos == POSITION_NONE && cursor != null && currConversation != null) { // enable detached mode and do no more here. the fragment itself will figure out // if the conversation is empty (using message list cursor) and back out if needed. setDetachedMode(true); LogUtils.i(LOG_TAG, "CPA: current conv is gone, reverting to detached mode. c=%s", currConversation.uri); final int currentItem = mPager.getCurrentItem(); final AbstractConversationViewFragment fragment = (AbstractConversationViewFragment) getFragmentAt(currentItem); if (fragment != null) { fragment.onDetachedModeEntered(); } else { LogUtils.e(LOG_TAG, "CPA: notifyDataSetChanged: fragment null, current item: %d", currentItem); } } else { // notify unaffected fragment items of the change, so they can re-render // (the change may have been to the labels for a single conversation, for example) final AbstractConversationViewFragment frag = (cursor == null) ? null : (AbstractConversationViewFragment) getFragmentAt(pos); if (frag != null && cursor.moveToPosition(pos) && frag.isUserVisible()) { // reload what we think is in the current position. final Conversation conv = cursor.getConversation(); conv.position = pos; frag.onConversationUpdated(conv); mController.setCurrentConversation(conv); } } } else { LogUtils.d(LOG_TAG, "in CPA.notifyDataSetChanged, doing nothing. this=%s", this); } super.notifyDataSetChanged(); mInDataSetChange = false; } @Override public void setItemVisible(Fragment item, boolean visible) { super.setItemVisible(item, visible); final AbstractConversationViewFragment fragment = (AbstractConversationViewFragment) item; fragment.setExtraUserVisibleHint(visible); } private Conversation getDefaultConversation() { Conversation c = (mController != null) ? mController.getCurrentConversation() : null; if (c == null) { c = mInitialConversation; } return c; } public int getConversationPosition(Conversation conv) { if (conv == null) { return POSITION_NONE; } final ConversationCursor cursor = getCursor(); if (isPagingDisabled(cursor)) { final Conversation def = getDefaultConversation(); if (!conv.equals(def)) { LogUtils.d(LOG_TAG, "unable to find conversation in singleton mode. c=%s def=%s", conv, def); return POSITION_NONE; } LogUtils.d(LOG_TAG, "in CPA.getConversationPosition returning 0, conv=%s this=%s", conv, this); return 0; } // cursor is guaranteed to be non-null because isPagingDisabled() above checks for null // cursor. int result = POSITION_NONE; final int pos = cursor.getConversationPosition(conv.id); if (pos >= 0) { LogUtils.d(LOG_TAG, "pager adapter found repositioned convo %s at pos=%d", conv, pos); result = pos; } LogUtils.d(LOG_TAG, "in CPA.getConversationPosition (normal), conv=%s pos=%s this=%s", conv, result, this); return result; } public void setPager(ViewPager pager) { if (mPager != null) { mPager.setOnPageChangeListener(null); } mPager = pager; if (mPager != null) { mPager.setOnPageChangeListener(this); } } public void setActivityController(ActivityController controller) { boolean wasNull = (mController == null); if (mController != null && !mStopListeningMode) { mController.unregisterConversationListObserver(mListObserver); mController.unregisterFolderObserver(mFolderObserver); } mController = controller; if (mController != null && !mStopListeningMode) { mController.registerConversationListObserver(mListObserver); mFolderObserver.initialize(mController); if (!wasNull) { notifyDataSetChanged(); } } else { // We're being torn down; do not notify. // Let the pager controller manage pager lifecycle. } } /** * See {@link ConversationPagerController#stopListening()}. */ public void stopListening() { if (mStopListeningMode) { // Do nothing since we're already in stop listening mode. This avoids repeated // unregister observer calls. return; } // disable the observer, but save off the current count, in case the Pager asks for it // from now until imminent destruction if (mController != null) { mController.unregisterConversationListObserver(mListObserver); mFolderObserver.unregisterAndDestroy(); } mLastKnownCount = getCount(); mStopListeningMode = true; LogUtils.d(LOG_TAG, "CPA.stopListening, this=%s", this); } public void enablePageChangeListener(boolean enable) { mPageChangeListenerEnabled = enable; } @Override public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { // no-op } @Override public void onPageSelected(int position) { if (mController == null || !mPageChangeListenerEnabled) { return; } final ConversationCursor cursor = getCursor(); if (cursor == null || !cursor.moveToPosition(position)) { // No valid cursor or it doesn't have the position we want. Bail. return; } final Conversation c = cursor.getConversation(); c.position = position; LogUtils.d(LOG_TAG, "pager adapter setting current conv: %s", c); mController.onConversationViewSwitched(c); } @Override public void onPageScrollStateChanged(int state) { // no-op } // update the pager dataset as the Controller's cursor changes private class ListObserver extends DataSetObserver { @Override public void onChanged() { notifyDataSetChanged(); } @Override public void onInvalidated() { } } }