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 
ConversationPagerAdapter(Context context, FragmentManager fm, Account account, Folder folder, Conversation initialConversation)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 
matches(Account account, Folder folder)150     public boolean matches(Account account, Folder folder) {
151         return mAccount != null && mFolder != null && mAccount.matches(account)
152                 && mFolder.equals(folder);
153     }
154 
setSingletonMode(boolean enabled)155     public void setSingletonMode(boolean enabled) {
156         if (mSingletonMode != enabled) {
157             mSingletonMode = enabled;
158             notifyDataSetChanged();
159         }
160     }
161 
isSingletonMode()162     public boolean isSingletonMode() {
163         return mSingletonMode;
164     }
165 
isDetached()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      */
isPagingDisabled(Cursor cursor)176     public boolean isPagingDisabled(Cursor cursor) {
177         return mSingletonMode || mDetachedMode || cursor == null;
178     }
179 
getCursor()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
getItem(int position)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 
getConversationViewFragment(Conversation c)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
getCount()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
getItemPosition(Object item)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
setPrimaryItem(ViewGroup container, int position, Object object)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
saveState()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
restoreState(Parcelable state, ClassLoader loader)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      */
killRestoredFragments()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 
setDetachedMode(boolean detached)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
toString()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
notifyDataSetChanged()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
setItemVisible(Fragment item, boolean visible)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 
getDefaultConversation()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 
getConversationPosition(Conversation conv)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 
setPager(ViewPager pager)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 
setActivityController(ActivityController controller)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      */
stopListening()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 
enablePageChangeListener(boolean enable)540     public void enablePageChangeListener(boolean enable) {
541         mPageChangeListenerEnabled = enable;
542     }
543 
544     @Override
onPageScrolled(int position, float positionOffset, int positionOffsetPixels)545     public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
546         // no-op
547     }
548 
549     @Override
onPageSelected(int position)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
onPageScrollStateChanged(int state)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
onChanged()573         public void onChanged() {
574             notifyDataSetChanged();
575         }
576         @Override
onInvalidated()577         public void onInvalidated() {
578         }
579     }
580 
581 }
582