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 androidx.viewpager.widget.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