/*
* 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:
*
* - The ViewPager always likes to know about all dataset changes via notifyDatasetChanged.
*
- 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() {
}
}
}