Home | History | Annotate | Download | only in browse
      1 /*
      2  * Copyright (C) 2012 Google Inc.
      3  * Licensed to The Android Open Source Project.
      4  *
      5  * Licensed under the Apache License, Version 2.0 (the "License");
      6  * you may not use this file except in compliance with the License.
      7  * You may obtain a copy of the License at
      8  *
      9  *      http://www.apache.org/licenses/LICENSE-2.0
     10  *
     11  * Unless required by applicable law or agreed to in writing, software
     12  * distributed under the License is distributed on an "AS IS" BASIS,
     13  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     14  * See the License for the specific language governing permissions and
     15  * limitations under the License.
     16  */
     17 
     18 package com.android.mail.browse;
     19 
     20 import android.app.Fragment;
     21 import android.app.FragmentManager;
     22 import android.app.FragmentTransaction;
     23 import android.content.Context;
     24 import android.database.Cursor;
     25 import android.database.DataSetObserver;
     26 import android.os.Bundle;
     27 import android.os.Parcelable;
     28 import android.support.v4.view.ViewPager;
     29 import android.view.ViewGroup;
     30 
     31 import com.android.mail.preferences.MailPrefs;
     32 import com.android.mail.providers.Account;
     33 import com.android.mail.providers.Conversation;
     34 import com.android.mail.providers.Folder;
     35 import com.android.mail.providers.FolderObserver;
     36 import com.android.mail.providers.UIProvider;
     37 import com.android.mail.ui.AbstractConversationViewFragment;
     38 import com.android.mail.ui.ActivityController;
     39 import com.android.mail.ui.ConversationViewFragment;
     40 import com.android.mail.ui.SecureConversationViewFragment;
     41 import com.android.mail.ui.TwoPaneController;
     42 import com.android.mail.utils.FragmentStatePagerAdapter2;
     43 import com.android.mail.utils.HtmlSanitizer;
     44 import com.android.mail.utils.LogUtils;
     45 
     46 public class ConversationPagerAdapter extends FragmentStatePagerAdapter2
     47         implements ViewPager.OnPageChangeListener {
     48 
     49     private final DataSetObserver mListObserver = new ListObserver();
     50     private final FolderObserver mFolderObserver = new FolderObserver() {
     51         @Override
     52         public void onChanged(Folder newFolder) {
     53             notifyDataSetChanged();
     54         }
     55     };
     56     private ActivityController mController;
     57     private final Bundle mCommonFragmentArgs;
     58     private final Conversation mInitialConversation;
     59     private final Account mAccount;
     60     private final Folder mFolder;
     61     /**
     62      * In singleton mode, this adapter ignores the cursor contents and size, and acts as if the
     63      * data set size is exactly size=1, with {@link #getDefaultConversation()} at position 0.
     64      */
     65     private boolean mSingletonMode = false;
     66     /**
     67      * Similar to singleton mode, but once enabled, detached mode is permanent for this adapter.
     68      */
     69     private boolean mDetachedMode = false;
     70     /**
     71      * True iff we are in the process of handling a dataset change.
     72      */
     73     private boolean mInDataSetChange = false;
     74 
     75     private Context mContext;
     76     /**
     77      * This isn't great to create a circular dependency, but our usage of {@link #getPageTitle(int)}
     78      * requires knowing which page is the currently visible to dynamically name offscreen pages
     79      * "newer" and "older". And {@link #setPrimaryItem(ViewGroup, int, Object)} does not work well
     80      * because it isn't updated as often as {@link ViewPager#getCurrentItem()} is.
     81      * <p>
     82      * We must be careful to null out this reference when the pager and adapter are decoupled to
     83      * minimize dangling references.
     84      */
     85     private ViewPager mPager;
     86 
     87     /**
     88      * <tt>true</tt> indicates the server has already sanitized all HTML email from this account.
     89      */
     90     private boolean mServerSanitizedHtml;
     91 
     92     /**
     93      * <tt>true</tt> indicates the client is permitted to sanitize all HTML email for this account.
     94      */
     95     private boolean mClientSanitizedHtml;
     96 
     97     private boolean mStopListeningMode = false;
     98 
     99     /**
    100      * After {@link #stopListening()} is called, this contains the last-known count of this adapter.
    101      * We keep this around and use it in lieu of the Cursor's true count until imminent destruction
    102      * to satisfy two opposing requirements:
    103      * <ol>
    104      * <li>The ViewPager always likes to know about all dataset changes via notifyDatasetChanged.
    105      * <li>Destructive changes during pager destruction (e.g. mode transition from conversation mode
    106      * to list mode) must be ignored, or else ViewPager will shift focus onto a neighboring
    107      * conversation and <b>mark it read</b>.
    108      * </ol>
    109      *
    110      */
    111     private int mLastKnownCount;
    112 
    113     /**
    114      * Once this adapter is connected to a ViewPager's saved state (from a previous
    115      * {@link #saveState()}), this field keeps the state around in case it later needs to be used
    116      * to find and kill page fragments.
    117      */
    118     private Bundle mRestoredState;
    119 
    120     private final FragmentManager mFragmentManager;
    121 
    122     private boolean mPageChangeListenerEnabled;
    123 
    124     private static final String LOG_TAG = ConversationPagerController.LOG_TAG;
    125 
    126     private static final String BUNDLE_DETACHED_MODE =
    127             ConversationPagerAdapter.class.getName() + "-detachedmode";
    128     /**
    129      * This is the bundle key prefix for the saved pager fragments as stashed by the parent class.
    130      * See the implementation of {@link FragmentStatePagerAdapter2#saveState()}. This assumes that
    131      * value!!!
    132      */
    133     private static final String BUNDLE_FRAGMENT_PREFIX = "f";
    134 
    135     public ConversationPagerAdapter(Context context, FragmentManager fm, Account account,
    136             Folder folder, Conversation initialConversation) {
    137         super(fm, false /* enableSavedStates */);
    138         mContext = context;
    139         mFragmentManager = fm;
    140         mCommonFragmentArgs = AbstractConversationViewFragment.makeBasicArgs(account);
    141         mInitialConversation = initialConversation;
    142         mAccount = account;
    143         mFolder = folder;
    144         mServerSanitizedHtml =
    145                 mAccount.supportsCapability(UIProvider.AccountCapabilities.SERVER_SANITIZED_HTML);
    146         mClientSanitizedHtml =
    147                 mAccount.supportsCapability(UIProvider.AccountCapabilities.CLIENT_SANITIZED_HTML);
    148     }
    149 
    150     public boolean matches(Account account, Folder folder) {
    151         return mAccount != null && mFolder != null && mAccount.matches(account)
    152                 && mFolder.equals(folder);
    153     }
    154 
    155     public void setSingletonMode(boolean enabled) {
    156         if (mSingletonMode != enabled) {
    157             mSingletonMode = enabled;
    158             notifyDataSetChanged();
    159         }
    160     }
    161 
    162     public boolean isSingletonMode() {
    163         return mSingletonMode;
    164     }
    165 
    166     public boolean isDetached() {
    167         return mDetachedMode;
    168     }
    169 
    170     /**
    171      * Returns true if singleton mode or detached mode have been enabled, or if the current cursor
    172      * is null.
    173      * @param cursor the current conversation cursor (obtained through {@link #getCursor()}.
    174      * @return
    175      */
    176     public boolean isPagingDisabled(Cursor cursor) {
    177         return mSingletonMode || mDetachedMode || cursor == null;
    178     }
    179 
    180     private ConversationCursor getCursor() {
    181         if (mDetachedMode) {
    182             // In detached mode, the pager is decoupled from the cursor. Nothing should rely on the
    183             // cursor at this point.
    184             return null;
    185         }
    186         if (mController == null) {
    187             // Happens when someone calls setActivityController(null) on us. This is done in
    188             // ConversationPagerController.stopListening() to indicate that the Conversation View
    189             // is going away *very* soon.
    190             LogUtils.i(LOG_TAG, "Pager adapter has a null controller. If the conversation view"
    191                     + " is going away, this is fine.  Otherwise, the state is inconsistent");
    192             return null;
    193         }
    194 
    195         return mController.getConversationListCursor();
    196     }
    197 
    198     @Override
    199     public Fragment getItem(int position) {
    200         final Conversation c;
    201         final ConversationCursor cursor = getCursor();
    202 
    203         if (isPagingDisabled(cursor)) {
    204             // cursor-less adapter is a size-1 cursor that points to mInitialConversation.
    205             // sanity-check
    206             if (position != 0) {
    207                 LogUtils.wtf(LOG_TAG, "pager cursor is null and position is non-zero: %d",
    208                         position);
    209             }
    210             c = getDefaultConversation();
    211             c.position = 0;
    212         } else {
    213             if (!cursor.moveToPosition(position)) {
    214                 LogUtils.wtf(LOG_TAG, "unable to seek to ConversationCursor pos=%d (%s)", position,
    215                         cursor);
    216                 return null;
    217             }
    218             cursor.notifyUIPositionChange();
    219             c = cursor.getConversation();
    220             c.position = position;
    221         }
    222         final AbstractConversationViewFragment f = getConversationViewFragment(c);
    223         LogUtils.d(LOG_TAG, "IN PagerAdapter.getItem, frag=%s conv=%s this=%s", f, c, this);
    224         return f;
    225     }
    226 
    227     private AbstractConversationViewFragment getConversationViewFragment(Conversation c) {
    228         // if Html email bodies are already sanitized by the mail server, scripting can be enabled
    229         if (mServerSanitizedHtml) {
    230             return ConversationViewFragment.newInstance(mCommonFragmentArgs, c);
    231         }
    232 
    233         // if this client is permitted to sanitize emails for this account, attempt to do so
    234         if (mClientSanitizedHtml) {
    235             // if the version of the Html Sanitizer meets or exceeds the required version, the
    236             // results of the sanitizer can be trusted and scripting can be enabled
    237             final MailPrefs mailPrefs = MailPrefs.get(mContext);
    238             if (HtmlSanitizer.VERSION >= mailPrefs.getRequiredSanitizerVersionNumber()) {
    239                 return ConversationViewFragment.newInstance(mCommonFragmentArgs, c);
    240             }
    241         }
    242 
    243         // otherwise we do not enable scripting
    244         return SecureConversationViewFragment.newInstance(mCommonFragmentArgs, c);
    245     }
    246 
    247     @Override
    248     public int getCount() {
    249         if (mStopListeningMode) {
    250             if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
    251                 final Cursor cursor = getCursor();
    252                 LogUtils.d(LOG_TAG,
    253                         "IN CPA.getCount stopListeningMode, returning lastKnownCount=%d."
    254                         + " cursor=%s real count=%s", mLastKnownCount, cursor,
    255                         (cursor != null) ? cursor.getCount() : "N/A");
    256             }
    257             return mLastKnownCount;
    258         }
    259 
    260         final Cursor cursor = getCursor();
    261         if (isPagingDisabled(cursor)) {
    262             LogUtils.d(LOG_TAG, "IN CPA.getCount, returning 1 (effective singleton). cursor=%s",
    263                     cursor);
    264             return 1;
    265         }
    266         return cursor.getCount();
    267     }
    268 
    269     @Override
    270     public int getItemPosition(Object item) {
    271         if (!(item instanceof AbstractConversationViewFragment)) {
    272             LogUtils.wtf(LOG_TAG, "getItemPosition received unexpected item: %s", item);
    273         }
    274 
    275         final AbstractConversationViewFragment fragment = (AbstractConversationViewFragment) item;
    276         return getConversationPosition(fragment.getConversation());
    277     }
    278 
    279     @Override
    280     public void setPrimaryItem(ViewGroup container, int position, Object object) {
    281         LogUtils.d(LOG_TAG, "IN PagerAdapter.setPrimaryItem, pos=%d, frag=%s", position,
    282                 object);
    283         super.setPrimaryItem(container, position, object);
    284     }
    285 
    286     @Override
    287     public Parcelable saveState() {
    288         LogUtils.d(LOG_TAG, "IN PagerAdapter.saveState. this=%s", this);
    289         Bundle state = (Bundle) super.saveState(); // superclass uses a Bundle
    290         if (state == null) {
    291             state = new Bundle();
    292         }
    293         state.putBoolean(BUNDLE_DETACHED_MODE, mDetachedMode);
    294         return state;
    295     }
    296 
    297     @Override
    298     public void restoreState(Parcelable state, ClassLoader loader) {
    299         super.restoreState(state, loader);
    300         if (state != null) {
    301             Bundle b = (Bundle) state;
    302             b.setClassLoader(loader);
    303             final boolean detached = b.getBoolean(BUNDLE_DETACHED_MODE);
    304             setDetachedMode(detached);
    305 
    306             // save off the bundle in case it later needs to be consulted for fragments-to-kill
    307             mRestoredState = b;
    308         }
    309         LogUtils.d(LOG_TAG, "OUT PagerAdapter.restoreState. this=%s", this);
    310     }
    311 
    312     /**
    313      * Part of an inelegant dance to clean up restored fragments after realizing
    314      * we don't want the ViewPager around after all in 2-pane. See docs for
    315      * {@link ConversationPagerController#killRestoredFragments()} and
    316      * {@link TwoPaneController#restoreConversation}.
    317      */
    318     public void killRestoredFragments() {
    319         if (mRestoredState == null) {
    320             return;
    321         }
    322 
    323         FragmentTransaction ft = null;
    324         for (String key : mRestoredState.keySet()) {
    325             // WARNING: this code assumes implementation details in
    326             // FragmentStatePagerAdapter2#restoreState
    327             if (!key.startsWith(BUNDLE_FRAGMENT_PREFIX)) {
    328                 continue;
    329             }
    330             final Fragment f = mFragmentManager.getFragment(mRestoredState, key);
    331             if (f != null) {
    332                 if (ft == null) {
    333                     ft = mFragmentManager.beginTransaction();
    334                 }
    335                 ft.remove(f);
    336             }
    337         }
    338         if (ft != null) {
    339             ft.commitAllowingStateLoss();
    340             mFragmentManager.executePendingTransactions();
    341         }
    342         mRestoredState = null;
    343     }
    344 
    345     private void setDetachedMode(boolean detached) {
    346         if (mDetachedMode == detached) {
    347             return;
    348         }
    349         mDetachedMode = detached;
    350         if (mDetachedMode) {
    351             mController.setDetachedMode();
    352         }
    353         notifyDataSetChanged();
    354     }
    355 
    356     @Override
    357     public String toString() {
    358         final StringBuilder sb = new StringBuilder(super.toString());
    359         sb.setLength(sb.length() - 1);
    360         sb.append(" detachedMode=");
    361         sb.append(mDetachedMode);
    362         sb.append(" singletonMode=");
    363         sb.append(mSingletonMode);
    364         sb.append(" mController=");
    365         sb.append(mController);
    366         sb.append(" mPager=");
    367         sb.append(mPager);
    368         sb.append(" mStopListening=");
    369         sb.append(mStopListeningMode);
    370         sb.append(" mLastKnownCount=");
    371         sb.append(mLastKnownCount);
    372         sb.append(" cursor=");
    373         sb.append(getCursor());
    374         sb.append("}");
    375         return sb.toString();
    376     }
    377 
    378     @Override
    379     public void notifyDataSetChanged() {
    380         if (mInDataSetChange) {
    381             LogUtils.i(LOG_TAG, "CPA ignoring dataset change generated during dataset change");
    382             return;
    383         }
    384 
    385         mInDataSetChange = true;
    386         // If we are in detached mode, changes to the cursor are of no interest to us, but they may
    387         // be to parent classes.
    388 
    389         // when the currently visible item disappears from the dataset:
    390         //   if the new version of the currently visible item has zero messages:
    391         //     notify the list controller so it can handle this 'current conversation gone' case
    392         //     (by backing out of conversation mode)
    393         //   else
    394         //     'detach' the conversation view from the cursor, keeping the current item as-is but
    395         //     disabling swipe (effectively the same as singleton mode)
    396         if (mController != null && !mDetachedMode && mPager != null) {
    397             final Conversation currConversation = mController.getCurrentConversation();
    398             final int pos = getConversationPosition(currConversation);
    399             final ConversationCursor cursor = getCursor();
    400             if (pos == POSITION_NONE && cursor != null && currConversation != null) {
    401                 // enable detached mode and do no more here. the fragment itself will figure out
    402                 // if the conversation is empty (using message list cursor) and back out if needed.
    403                 setDetachedMode(true);
    404                 LogUtils.i(LOG_TAG, "CPA: current conv is gone, reverting to detached mode. c=%s",
    405                         currConversation.uri);
    406 
    407                 final int currentItem = mPager.getCurrentItem();
    408 
    409                 final AbstractConversationViewFragment fragment =
    410                         (AbstractConversationViewFragment) getFragmentAt(currentItem);
    411 
    412                 if (fragment != null) {
    413                     fragment.onDetachedModeEntered();
    414                 } else {
    415                     LogUtils.e(LOG_TAG,
    416                             "CPA: notifyDataSetChanged: fragment null, current item: %d",
    417                             currentItem);
    418                 }
    419             } else {
    420                 // notify unaffected fragment items of the change, so they can re-render
    421                 // (the change may have been to the labels for a single conversation, for example)
    422                 final AbstractConversationViewFragment frag = (cursor == null) ? null :
    423                         (AbstractConversationViewFragment) getFragmentAt(pos);
    424                 if (frag != null && cursor.moveToPosition(pos) && frag.isUserVisible()) {
    425                     // reload what we think is in the current position.
    426                     final Conversation conv = cursor.getConversation();
    427                     conv.position = pos;
    428                     frag.onConversationUpdated(conv);
    429                     mController.setCurrentConversation(conv);
    430                 }
    431             }
    432         } else {
    433             LogUtils.d(LOG_TAG, "in CPA.notifyDataSetChanged, doing nothing. this=%s", this);
    434         }
    435 
    436         super.notifyDataSetChanged();
    437         mInDataSetChange = false;
    438     }
    439 
    440     @Override
    441     public void setItemVisible(Fragment item, boolean visible) {
    442         super.setItemVisible(item, visible);
    443         final AbstractConversationViewFragment fragment = (AbstractConversationViewFragment) item;
    444         fragment.setExtraUserVisibleHint(visible);
    445     }
    446 
    447     private Conversation getDefaultConversation() {
    448         Conversation c = (mController != null) ? mController.getCurrentConversation() : null;
    449         if (c == null) {
    450             c = mInitialConversation;
    451         }
    452         return c;
    453     }
    454 
    455     public int getConversationPosition(Conversation conv) {
    456         if (conv == null) {
    457             return POSITION_NONE;
    458         }
    459 
    460         final ConversationCursor cursor = getCursor();
    461         if (isPagingDisabled(cursor)) {
    462             final Conversation def = getDefaultConversation();
    463             if (!conv.equals(def)) {
    464                 LogUtils.d(LOG_TAG, "unable to find conversation in singleton mode. c=%s def=%s",
    465                         conv, def);
    466                 return POSITION_NONE;
    467             }
    468             LogUtils.d(LOG_TAG, "in CPA.getConversationPosition returning 0, conv=%s this=%s",
    469                     conv, this);
    470             return 0;
    471         }
    472 
    473         // cursor is guaranteed to be non-null because isPagingDisabled() above checks for null
    474         // cursor.
    475 
    476         int result = POSITION_NONE;
    477         final int pos = cursor.getConversationPosition(conv.id);
    478         if (pos >= 0) {
    479             LogUtils.d(LOG_TAG, "pager adapter found repositioned convo %s at pos=%d",
    480                     conv, pos);
    481             result = pos;
    482         }
    483 
    484         LogUtils.d(LOG_TAG, "in CPA.getConversationPosition (normal), conv=%s pos=%s this=%s",
    485                 conv, result, this);
    486         return result;
    487     }
    488 
    489     public void setPager(ViewPager pager) {
    490         if (mPager != null) {
    491             mPager.setOnPageChangeListener(null);
    492         }
    493         mPager = pager;
    494         if (mPager != null) {
    495             mPager.setOnPageChangeListener(this);
    496         }
    497     }
    498 
    499     public void setActivityController(ActivityController controller) {
    500         boolean wasNull = (mController == null);
    501         if (mController != null && !mStopListeningMode) {
    502             mController.unregisterConversationListObserver(mListObserver);
    503             mController.unregisterFolderObserver(mFolderObserver);
    504         }
    505         mController = controller;
    506         if (mController != null && !mStopListeningMode) {
    507             mController.registerConversationListObserver(mListObserver);
    508             mFolderObserver.initialize(mController);
    509             if (!wasNull) {
    510                 notifyDataSetChanged();
    511             }
    512         } else {
    513             // We're being torn down; do not notify.
    514             // Let the pager controller manage pager lifecycle.
    515         }
    516     }
    517 
    518     /**
    519      * See {@link ConversationPagerController#stopListening()}.
    520      */
    521     public void stopListening() {
    522         if (mStopListeningMode) {
    523             // Do nothing since we're already in stop listening mode.  This avoids repeated
    524             // unregister observer calls.
    525             return;
    526         }
    527 
    528         // disable the observer, but save off the current count, in case the Pager asks for it
    529         // from now until imminent destruction
    530 
    531         if (mController != null) {
    532             mController.unregisterConversationListObserver(mListObserver);
    533             mFolderObserver.unregisterAndDestroy();
    534         }
    535         mLastKnownCount = getCount();
    536         mStopListeningMode = true;
    537         LogUtils.d(LOG_TAG, "CPA.stopListening, this=%s", this);
    538     }
    539 
    540     public void enablePageChangeListener(boolean enable) {
    541         mPageChangeListenerEnabled = enable;
    542     }
    543 
    544     @Override
    545     public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
    546         // no-op
    547     }
    548 
    549     @Override
    550     public void onPageSelected(int position) {
    551         if (mController == null || !mPageChangeListenerEnabled) {
    552             return;
    553         }
    554         final ConversationCursor cursor = getCursor();
    555         if (cursor == null || !cursor.moveToPosition(position)) {
    556             // No valid cursor or it doesn't have the position we want. Bail.
    557             return;
    558         }
    559         final Conversation c = cursor.getConversation();
    560         c.position = position;
    561         LogUtils.d(LOG_TAG, "pager adapter setting current conv: %s", c);
    562         mController.onConversationViewSwitched(c);
    563     }
    564 
    565     @Override
    566     public void onPageScrollStateChanged(int state) {
    567         // no-op
    568     }
    569 
    570     // update the pager dataset as the Controller's cursor changes
    571     private class ListObserver extends DataSetObserver {
    572         @Override
    573         public void onChanged() {
    574             notifyDataSetChanged();
    575         }
    576         @Override
    577         public void onInvalidated() {
    578         }
    579     }
    580 
    581 }
    582