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.content.res.Resources;
23import android.database.Cursor;
24import android.database.DataSetObserver;
25import android.os.Bundle;
26import android.os.Parcelable;
27import android.support.v4.view.ViewPager;
28import android.view.ViewGroup;
29
30import com.android.mail.providers.Account;
31import com.android.mail.providers.Conversation;
32import com.android.mail.providers.Folder;
33import com.android.mail.providers.FolderObserver;
34import com.android.mail.providers.UIProvider;
35import com.android.mail.ui.AbstractConversationViewFragment;
36import com.android.mail.ui.ActivityController;
37import com.android.mail.ui.ConversationViewFragment;
38import com.android.mail.ui.SecureConversationViewFragment;
39import com.android.mail.utils.FragmentStatePagerAdapter2;
40import com.android.mail.utils.LogUtils;
41
42public class ConversationPagerAdapter extends FragmentStatePagerAdapter2
43        implements ViewPager.OnPageChangeListener {
44
45    private final DataSetObserver mListObserver = new ListObserver();
46    private final FolderObserver mFolderObserver = new FolderObserver() {
47        @Override
48        public void onChanged(Folder newFolder) {
49            notifyDataSetChanged();
50        }
51    };
52    private ActivityController mController;
53    private final Bundle mCommonFragmentArgs;
54    private final Conversation mInitialConversation;
55    private final Account mAccount;
56    private final Folder mFolder;
57    /**
58     * In singleton mode, this adapter ignores the cursor contents and size, and acts as if the
59     * data set size is exactly size=1, with {@link #getDefaultConversation()} at position 0.
60     */
61    private boolean mSingletonMode = false;
62    /**
63     * Similar to singleton mode, but once enabled, detached mode is permanent for this adapter.
64     */
65    private boolean mDetachedMode = false;
66    /**
67     * True iff we are in the process of handling a dataset change.
68     */
69    private boolean mInDataSetChange = false;
70    /**
71     * Need to keep this around to look up pager title strings.
72     */
73    private Resources mResources;
74    /**
75     * This isn't great to create a circular dependency, but our usage of {@link #getPageTitle(int)}
76     * requires knowing which page is the currently visible to dynamically name offscreen pages
77     * "newer" and "older". And {@link #setPrimaryItem(ViewGroup, int, Object)} does not work well
78     * because it isn't updated as often as {@link ViewPager#getCurrentItem()} is.
79     * <p>
80     * We must be careful to null out this reference when the pager and adapter are decoupled to
81     * minimize dangling references.
82     */
83    private ViewPager mPager;
84    private boolean mSanitizedHtml;
85
86    private boolean mStopListeningMode = false;
87
88    /**
89     * After {@link #stopListening()} is called, this contains the last-known count of this adapter.
90     * We keep this around and use it in lieu of the Cursor's true count until imminent destruction
91     * to satisfy two opposing requirements:
92     * <ol>
93     * <li>The ViewPager always likes to know about all dataset changes via notifyDatasetChanged.
94     * <li>Destructive changes during pager destruction (e.g. mode transition from conversation mode
95     * to list mode) must be ignored, or else ViewPager will shift focus onto a neighboring
96     * conversation and <b>mark it read</b>.
97     * </ol>
98     *
99     */
100    private int mLastKnownCount;
101
102    private static final String LOG_TAG = ConversationPagerController.LOG_TAG;
103
104    private static final String BUNDLE_DETACHED_MODE =
105            ConversationPagerAdapter.class.getName() + "-detachedmode";
106
107    public ConversationPagerAdapter(Resources res, FragmentManager fm, Account account,
108            Folder folder, Conversation initialConversation) {
109        super(fm, false /* enableSavedStates */);
110        mResources = res;
111        mCommonFragmentArgs = AbstractConversationViewFragment.makeBasicArgs(account);
112        mInitialConversation = initialConversation;
113        mAccount = account;
114        mFolder = folder;
115        mSanitizedHtml = mAccount.supportsCapability
116                (UIProvider.AccountCapabilities.SANITIZED_HTML);
117    }
118
119    public boolean matches(Account account, Folder folder) {
120        return mAccount != null && mFolder != null && mAccount.matches(account)
121                && mFolder.equals(folder);
122    }
123
124    public void setSingletonMode(boolean enabled) {
125        if (mSingletonMode != enabled) {
126            mSingletonMode = enabled;
127            notifyDataSetChanged();
128        }
129    }
130
131    public boolean isSingletonMode() {
132        return mSingletonMode;
133    }
134
135    public boolean isDetached() {
136        return mDetachedMode;
137    }
138
139    /**
140     * Returns true if singleton mode or detached mode have been enabled, or if the current cursor
141     * is null.
142     * @param cursor the current conversation cursor (obtained through {@link #getCursor()}.
143     * @return
144     */
145    public boolean isPagingDisabled(Cursor cursor) {
146        return mSingletonMode || mDetachedMode || cursor == null;
147    }
148
149    private ConversationCursor getCursor() {
150        if (mDetachedMode) {
151            // In detached mode, the pager is decoupled from the cursor. Nothing should rely on the
152            // cursor at this point.
153            return null;
154        }
155        if (mController == null) {
156            // Happens when someone calls setActivityController(null) on us. This is done in
157            // ConversationPagerController.stopListening() to indicate that the Conversation View
158            // is going away *very* soon.
159            LogUtils.i(LOG_TAG, "Pager adapter has a null controller. If the conversation view"
160                    + " is going away, this is fine.  Otherwise, the state is inconsistent");
161            return null;
162        }
163
164        return mController.getConversationListCursor();
165    }
166
167    @Override
168    public Fragment getItem(int position) {
169        final Conversation c;
170        final ConversationCursor cursor = getCursor();
171
172        if (isPagingDisabled(cursor)) {
173            // cursor-less adapter is a size-1 cursor that points to mInitialConversation.
174            // sanity-check
175            if (position != 0) {
176                LogUtils.wtf(LOG_TAG, "pager cursor is null and position is non-zero: %d",
177                        position);
178            }
179            c = getDefaultConversation();
180            c.position = 0;
181        } else {
182            if (!cursor.moveToPosition(position)) {
183                LogUtils.wtf(LOG_TAG, "unable to seek to ConversationCursor pos=%d (%s)", position,
184                        cursor);
185                return null;
186            }
187            cursor.notifyUIPositionChange();
188            c = cursor.getConversation();
189            c.position = position;
190        }
191        final AbstractConversationViewFragment f = getConversationViewFragment(c);
192        LogUtils.d(LOG_TAG, "IN PagerAdapter.getItem, frag=%s conv=%s this=%s", f, c, this);
193        return f;
194    }
195
196    private AbstractConversationViewFragment getConversationViewFragment(Conversation c) {
197        if (mSanitizedHtml) {
198            return ConversationViewFragment.newInstance(mCommonFragmentArgs, c);
199        } else {
200            return SecureConversationViewFragment.newInstance(mCommonFragmentArgs, c);
201        }
202    }
203
204    @Override
205    public int getCount() {
206        if (mStopListeningMode) {
207            if (LogUtils.isLoggable(LOG_TAG, LogUtils.DEBUG)) {
208                final Cursor cursor = getCursor();
209                LogUtils.d(LOG_TAG,
210                        "IN CPA.getCount stopListeningMode, returning lastKnownCount=%d."
211                        + " cursor=%s real count=%s", mLastKnownCount, cursor,
212                        (cursor != null) ? cursor.getCount() : "N/A");
213            }
214            return mLastKnownCount;
215        }
216
217        final Cursor cursor = getCursor();
218        if (isPagingDisabled(cursor)) {
219            LogUtils.d(LOG_TAG, "IN CPA.getCount, returning 1 (effective singleton). cursor=%s",
220                    cursor);
221            return 1;
222        }
223        return cursor.getCount();
224    }
225
226    @Override
227    public int getItemPosition(Object item) {
228        if (!(item instanceof AbstractConversationViewFragment)) {
229            LogUtils.wtf(LOG_TAG, "getItemPosition received unexpected item: %s", item);
230        }
231
232        final AbstractConversationViewFragment fragment = (AbstractConversationViewFragment) item;
233        return getConversationPosition(fragment.getConversation());
234    }
235
236    @Override
237    public void setPrimaryItem(ViewGroup container, int position, Object object) {
238        LogUtils.d(LOG_TAG, "IN PagerAdapter.setPrimaryItem, pos=%d, frag=%s", position,
239                object);
240        super.setPrimaryItem(container, position, object);
241    }
242
243    @Override
244    public Parcelable saveState() {
245        LogUtils.d(LOG_TAG, "IN PagerAdapter.saveState. this=%s", this);
246        Bundle state = (Bundle) super.saveState(); // superclass uses a Bundle
247        if (state == null) {
248            state = new Bundle();
249        }
250        state.putBoolean(BUNDLE_DETACHED_MODE, mDetachedMode);
251        return state;
252    }
253
254    @Override
255    public void restoreState(Parcelable state, ClassLoader loader) {
256        super.restoreState(state, loader);
257        if (state != null) {
258            Bundle b = (Bundle) state;
259            b.setClassLoader(loader);
260            final boolean detached = b.getBoolean(BUNDLE_DETACHED_MODE);
261            setDetachedMode(detached);
262        }
263        LogUtils.d(LOG_TAG, "OUT PagerAdapter.restoreState. this=%s", this);
264    }
265
266    private void setDetachedMode(boolean detached) {
267        if (mDetachedMode == detached) {
268            return;
269        }
270        mDetachedMode = detached;
271        if (mDetachedMode) {
272            mController.setDetachedMode();
273        }
274        notifyDataSetChanged();
275    }
276
277    @Override
278    public String toString() {
279        final StringBuilder sb = new StringBuilder(super.toString());
280        sb.setLength(sb.length() - 1);
281        sb.append(" detachedMode=");
282        sb.append(mDetachedMode);
283        sb.append(" singletonMode=");
284        sb.append(mSingletonMode);
285        sb.append(" mController=");
286        sb.append(mController);
287        sb.append(" mPager=");
288        sb.append(mPager);
289        sb.append(" mStopListening=");
290        sb.append(mStopListeningMode);
291        sb.append(" mLastKnownCount=");
292        sb.append(mLastKnownCount);
293        sb.append(" cursor=");
294        sb.append(getCursor());
295        sb.append("}");
296        return sb.toString();
297    }
298
299    @Override
300    public void notifyDataSetChanged() {
301        if (mInDataSetChange) {
302            LogUtils.i(LOG_TAG, "CPA ignoring dataset change generated during dataset change");
303            return;
304        }
305
306        mInDataSetChange = true;
307        // If we are in detached mode, changes to the cursor are of no interest to us, but they may
308        // be to parent classes.
309
310        // when the currently visible item disappears from the dataset:
311        //   if the new version of the currently visible item has zero messages:
312        //     notify the list controller so it can handle this 'current conversation gone' case
313        //     (by backing out of conversation mode)
314        //   else
315        //     'detach' the conversation view from the cursor, keeping the current item as-is but
316        //     disabling swipe (effectively the same as singleton mode)
317        if (mController != null && !mDetachedMode && mPager != null) {
318            final Conversation currConversation = mController.getCurrentConversation();
319            final int pos = getConversationPosition(currConversation);
320            final ConversationCursor cursor = getCursor();
321            if (pos == POSITION_NONE && cursor != null && currConversation != null) {
322                // enable detached mode and do no more here. the fragment itself will figure out
323                // if the conversation is empty (using message list cursor) and back out if needed.
324                setDetachedMode(true);
325                LogUtils.i(LOG_TAG, "CPA: current conv is gone, reverting to detached mode. c=%s",
326                        currConversation.uri);
327
328                final int currentItem = mPager.getCurrentItem();
329
330                final AbstractConversationViewFragment fragment =
331                        (AbstractConversationViewFragment) getFragmentAt(currentItem);
332
333                if (fragment != null) {
334                    fragment.onDetachedModeEntered();
335                } else {
336                    LogUtils.e(LOG_TAG,
337                            "CPA: notifyDataSetChanged: fragment null, current item: %d",
338                            currentItem);
339                }
340            } else {
341                // notify unaffected fragment items of the change, so they can re-render
342                // (the change may have been to the labels for a single conversation, for example)
343                final AbstractConversationViewFragment frag = (cursor == null) ? null :
344                        (AbstractConversationViewFragment) getFragmentAt(pos);
345                if (frag != null && cursor.moveToPosition(pos) && frag.isUserVisible()) {
346                    // reload what we think is in the current position.
347                    final Conversation conv = cursor.getConversation();
348                    conv.position = pos;
349                    frag.onConversationUpdated(conv);
350                    mController.setCurrentConversation(conv);
351                }
352            }
353        } else {
354            LogUtils.d(LOG_TAG, "in CPA.notifyDataSetChanged, doing nothing. this=%s", this);
355        }
356
357        super.notifyDataSetChanged();
358        mInDataSetChange = false;
359    }
360
361    @Override
362    public void setItemVisible(Fragment item, boolean visible) {
363        super.setItemVisible(item, visible);
364        final AbstractConversationViewFragment fragment = (AbstractConversationViewFragment) item;
365        fragment.setExtraUserVisibleHint(visible);
366    }
367
368    private Conversation getDefaultConversation() {
369        Conversation c = (mController != null) ? mController.getCurrentConversation() : null;
370        if (c == null) {
371            c = mInitialConversation;
372        }
373        return c;
374    }
375
376    public int getConversationPosition(Conversation conv) {
377        if (conv == null) {
378            return POSITION_NONE;
379        }
380
381        final ConversationCursor cursor = getCursor();
382        if (isPagingDisabled(cursor)) {
383            final Conversation def = getDefaultConversation();
384            if (!conv.equals(def)) {
385                LogUtils.d(LOG_TAG, "unable to find conversation in singleton mode. c=%s def=%s",
386                        conv, def);
387                return POSITION_NONE;
388            }
389            LogUtils.d(LOG_TAG, "in CPA.getConversationPosition returning 0, conv=%s this=%s",
390                    conv, this);
391            return 0;
392        }
393
394        // cursor is guaranteed to be non-null because isPagingDisabled() above checks for null
395        // cursor.
396
397        int result = POSITION_NONE;
398        final int pos = cursor.getConversationPosition(conv.id);
399        if (pos >= 0) {
400            LogUtils.d(LOG_TAG, "pager adapter found repositioned convo %s at pos=%d",
401                    conv, pos);
402            result = pos;
403        }
404
405        LogUtils.d(LOG_TAG, "in CPA.getConversationPosition (normal), conv=%s pos=%s this=%s",
406                conv, result, this);
407        return result;
408    }
409
410    public void setPager(ViewPager pager) {
411        if (mPager != null) {
412            mPager.setOnPageChangeListener(null);
413        }
414        mPager = pager;
415        if (mPager != null) {
416            mPager.setOnPageChangeListener(this);
417        }
418    }
419
420    public void setActivityController(ActivityController controller) {
421        boolean wasNull = (mController == null);
422        if (mController != null && !mStopListeningMode) {
423            mController.unregisterConversationListObserver(mListObserver);
424            mController.unregisterFolderObserver(mFolderObserver);
425        }
426        mController = controller;
427        if (mController != null && !mStopListeningMode) {
428            mController.registerConversationListObserver(mListObserver);
429            mFolderObserver.initialize(mController);
430            if (!wasNull) {
431                notifyDataSetChanged();
432            }
433        } else {
434            // We're being torn down; do not notify.
435            // Let the pager controller manage pager lifecycle.
436        }
437    }
438
439    /**
440     * See {@link ConversationPagerController#stopListening()}.
441     */
442    public void stopListening() {
443        if (mStopListeningMode) {
444            // Do nothing since we're already in stop listening mode.  This avoids repeated
445            // unregister observer calls.
446            return;
447        }
448
449        // disable the observer, but save off the current count, in case the Pager asks for it
450        // from now until imminent destruction
451
452        if (mController != null) {
453            mController.unregisterConversationListObserver(mListObserver);
454            mFolderObserver.unregisterAndDestroy();
455        }
456        mLastKnownCount = getCount();
457        mStopListeningMode = true;
458        LogUtils.d(LOG_TAG, "CPA.stopListening, this=%s", this);
459    }
460
461    @Override
462    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
463        // no-op
464    }
465
466    @Override
467    public void onPageSelected(int position) {
468        if (mController == null) {
469            return;
470        }
471        final ConversationCursor cursor = getCursor();
472        if (cursor == null || !cursor.moveToPosition(position)) {
473            // No valid cursor or it doesn't have the position we want. Bail.
474            return;
475        }
476        final Conversation c = cursor.getConversation();
477        c.position = position;
478        LogUtils.d(LOG_TAG, "pager adapter setting current conv: %s", c);
479        mController.setCurrentConversation(c);
480    }
481
482    @Override
483    public void onPageScrollStateChanged(int state) {
484        // no-op
485    }
486
487    // update the pager dataset as the Controller's cursor changes
488    private class ListObserver extends DataSetObserver {
489        @Override
490        public void onChanged() {
491            notifyDataSetChanged();
492        }
493        @Override
494        public void onInvalidated() {
495        }
496    }
497
498}
499