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
18package com.android.mail.browse;
19
20import android.app.Fragment;
21import android.app.FragmentManager;
22import android.app.FragmentTransaction;
23import android.content.Context;
24import android.database.Cursor;
25import android.database.DataSetObserver;
26import android.os.Bundle;
27import android.os.Parcelable;
28import android.support.v4.view.ViewPager;
29import android.view.ViewGroup;
30
31import com.android.mail.preferences.MailPrefs;
32import com.android.mail.providers.Account;
33import com.android.mail.providers.Conversation;
34import com.android.mail.providers.Folder;
35import com.android.mail.providers.FolderObserver;
36import com.android.mail.providers.UIProvider;
37import com.android.mail.ui.AbstractConversationViewFragment;
38import com.android.mail.ui.ActivityController;
39import com.android.mail.ui.ConversationViewFragment;
40import com.android.mail.ui.SecureConversationViewFragment;
41import com.android.mail.ui.TwoPaneController;
42import com.android.mail.utils.FragmentStatePagerAdapter2;
43import com.android.mail.utils.HtmlSanitizer;
44import com.android.mail.utils.LogUtils;
45
46public 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