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.content.res.Resources; 23 import android.database.Cursor; 24 import android.database.DataSetObserver; 25 import android.os.Bundle; 26 import android.os.Parcelable; 27 import android.support.v4.view.ViewPager; 28 import android.view.ViewGroup; 29 30 import com.android.mail.providers.Account; 31 import com.android.mail.providers.Conversation; 32 import com.android.mail.providers.Folder; 33 import com.android.mail.providers.FolderObserver; 34 import com.android.mail.providers.UIProvider; 35 import com.android.mail.ui.AbstractConversationViewFragment; 36 import com.android.mail.ui.ActivityController; 37 import com.android.mail.ui.ConversationViewFragment; 38 import com.android.mail.ui.SecureConversationViewFragment; 39 import com.android.mail.utils.FragmentStatePagerAdapter2; 40 import com.android.mail.utils.LogUtils; 41 42 public 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