1 /* 2 * Copyright (C) 2017 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.contentpager.content; 18 19 import static androidx.core.util.Preconditions.checkArgument; 20 import static androidx.core.util.Preconditions.checkState; 21 22 import android.content.ContentResolver; 23 import android.database.CrossProcessCursor; 24 import android.database.Cursor; 25 import android.database.CursorWindow; 26 import android.database.CursorWrapper; 27 import android.net.Uri; 28 import android.os.Build; 29 import android.os.Bundle; 30 import android.os.CancellationSignal; 31 import android.os.OperationCanceledException; 32 import android.util.Log; 33 34 import androidx.annotation.GuardedBy; 35 import androidx.annotation.IntDef; 36 import androidx.annotation.MainThread; 37 import androidx.annotation.NonNull; 38 import androidx.annotation.Nullable; 39 import androidx.annotation.RequiresPermission; 40 import androidx.annotation.VisibleForTesting; 41 import androidx.annotation.WorkerThread; 42 import androidx.collection.LruCache; 43 44 import java.lang.annotation.Retention; 45 import java.lang.annotation.RetentionPolicy; 46 import java.util.HashSet; 47 import java.util.Set; 48 49 /** 50 * {@link ContentPager} provides support for loading "paged" data on a background thread 51 * using the {@link ContentResolver} framework. This provides an effective compatibility 52 * layer for the ContentResolver "paging" support added in Android O. Those Android O changes, 53 * like this class, help reduce or eliminate the occurrence of expensive inter-process 54 * shared memory operations (aka "CursorWindow swaps") happening on the UI thread when 55 * working with remote providers. 56 * 57 * <p>The list of terms used in this document: 58 * 59 * <ol>"The provider" is a {@link android.content.ContentProvider} supplying data identified 60 * by a specific content {@link Uri}. A provider is the source of data, and for the sake of 61 * this documents, the provider resides in a remote process. 62 63 * <ol>"supports paging" A provider supports paging when it returns a pre-paged {@link Cursor} 64 * that honors the paging contract. See @link ContentResolver#QUERY_ARG_OFFSET} and 65 * {@link ContentResolver#QUERY_ARG_LIMIT} for details on the contract. 66 67 * <ol>"CursorWindow swaps" The process by which new data is loaded into a shared memory 68 * via a CursorWindow instance. This is a prominent contributor to UI jank in applications 69 * that use Cursor as backing data for UI elements like {@code RecyclerView}. 70 * 71 * <p><b>Details</b> 72 * 73 * <p>Data will be loaded from a content uri in one of two ways, depending on the runtime 74 * environment and if the provider supports paging. 75 * 76 * <li>If the system is Android O and greater and the provider supports paging, the Cursor 77 * will be returned, effectively unmodified, to a {@link ContentCallback} supplied by 78 * your application. 79 * 80 * <li>If the system is less than Android O or the provider does not support paging, the 81 * loader will fetch an unpaged Cursor from the provider. The unpaged Cursor will be held 82 * by the ContentPager, and data will be copied into a new cursor in a background thread. 83 * The new cursor will be returned to a {@link ContentCallback} supplied by your application. 84 * 85 * <p>In either cases, when an application employs this library it can generally assume 86 * that there will be no CursorWindow swap. But picking the right limit for records can 87 * help reduce or even eliminate some heavy lifting done to guard against swaps. 88 * 89 * <p>How do we avoid that entirely? 90 * 91 * <p><b>Picking a reasonable item limit</b> 92 * 93 * <p>Authors are encouraged to experiment with limits using real data and the widest column 94 * projection they'll use in their app. The total number of records that will fit into shared 95 * memory varies depending on multiple factors. 96 * 97 * <li>The number of columns being requested in the cursor projection. Limit the number 98 * of columns, to reduce the size of each row. 99 * <li>The size of the data in each column. 100 * <li>the Cursor type. 101 * 102 * <p>If the cursor is running in-process, there may be no need for paging. Depending on 103 * the Cursor implementation chosen there may be no shared memory/CursorWindow in use. 104 * NOTE: If the provider is running in your process, you should implement paging support 105 * inorder to make your app run fast and to consume the fewest resources possible. 106 * 107 * <p>In common cases where there is a low volume (in the hundreds) of records in the dataset 108 * being queried, all of the data should easily fit in shared memory. A debugger can be handy 109 * to understand with greater accuracy how many results can fit in shared memory. Inspect 110 * the Cursor object returned from a call to 111 * {@link ContentResolver#query(Uri, String[], String, String[], String)}. If the underlying 112 * type is a {@link android.database.CrossProcessCursor} or 113 * {@link android.database.AbstractWindowedCursor} it'll have a {@link CursorWindow} field. 114 * Check {@link CursorWindow#getNumRows()}. If getNumRows returns less than 115 * {@link Cursor#getCount}, then you've found something close to the max rows that'll 116 * fit in a page. If the data in row is expected to be relatively stable in size, reduce 117 * row count by 15-20% to get a reasonable max page size. 118 * 119 * <p><b>What if the limit I guessed was wrong?</b> 120 121 * <p>The library includes safeguards that protect against situations where an author 122 * specifies a record limit that exceeds the number of rows accessible without a CursorWindow swap. 123 * In such a circumstance, the Cursor will be adapted to report a count ({Cursor#getCount}) 124 * that reflects only records available without CursorWindow swap. But this involves 125 * extra work that can be eliminated with a correct limit. 126 * 127 * <p>In addition to adjusted coujnt, {@link #EXTRA_SUGGESTED_LIMIT} will be included 128 * in cursor extras. When EXTRA_SUGGESTED_LIMIT is present in extras, the client should 129 * strongly consider using this value as the limit for subsequent queries as doing so should 130 * help avoid the ned to wrap pre-paged cursors. 131 * 132 * <p><b>Lifecycle and cleanup</b> 133 * 134 * <p>Cursors resulting from queries are owned by the requesting client. So they must be closed 135 * by the client at the appropriate time. 136 * 137 * <p>However, the library retains an internal cache of content that needs to be cleaned up. 138 * In order to cleanup, call {@link #reset()}. 139 * 140 * <p><b>Projections</b> 141 * 142 * <p>Note that projection is ignored when determining the identity of a query. When 143 * adding or removing projection, clients should call {@link #reset()} to clear 144 * cached data. 145 */ 146 public class ContentPager { 147 148 @VisibleForTesting 149 static final String CURSOR_DISPOSITION = "androidx.appcompat.widget.CURSOR_DISPOSITION"; 150 151 @IntDef(value = { 152 ContentPager.CURSOR_DISPOSITION_COPIED, 153 ContentPager.CURSOR_DISPOSITION_PAGED, 154 ContentPager.CURSOR_DISPOSITION_REPAGED, 155 ContentPager.CURSOR_DISPOSITION_WRAPPED 156 }) 157 @Retention(RetentionPolicy.SOURCE) 158 public @interface CursorDisposition {} 159 160 /** The cursor size exceeded page size. A new cursor with with page data was created. */ 161 public static final int CURSOR_DISPOSITION_COPIED = 1; 162 163 /** 164 * The cursor was provider paged. 165 */ 166 public static final int CURSOR_DISPOSITION_PAGED = 2; 167 168 /** The cursor was pre-paged, but total size was larger than CursorWindow size. */ 169 public static final int CURSOR_DISPOSITION_REPAGED = 3; 170 171 /** 172 * The cursor was not pre-paged, but total size was smaller than page size. 173 * Cursor wrapped to supply data in extras only. 174 */ 175 public static final int CURSOR_DISPOSITION_WRAPPED = 4; 176 177 /** @see ContentResolver#EXTRA_HONORED_ARGS */ 178 public static final String EXTRA_HONORED_ARGS = ContentResolver.EXTRA_HONORED_ARGS; 179 180 /** @see ContentResolver#EXTRA_TOTAL_COUNT */ 181 public static final String EXTRA_TOTAL_COUNT = ContentResolver.EXTRA_TOTAL_COUNT; 182 183 /** @see ContentResolver#QUERY_ARG_OFFSET */ 184 public static final String QUERY_ARG_OFFSET = ContentResolver.QUERY_ARG_OFFSET; 185 186 /** @see ContentResolver#QUERY_ARG_LIMIT */ 187 public static final String QUERY_ARG_LIMIT = ContentResolver.QUERY_ARG_LIMIT; 188 189 /** Denotes the requested limit, if the limit was not-honored. */ 190 public static final String EXTRA_REQUESTED_LIMIT = "android-support:extra-ignored-limit"; 191 192 /** Specifies a limit likely to fit in CursorWindow limit. */ 193 public static final String EXTRA_SUGGESTED_LIMIT = "android-support:extra-suggested-limit"; 194 195 private static final boolean DEBUG = false; 196 private static final String TAG = "ContentPager"; 197 private static final int DEFAULT_CURSOR_CACHE_SIZE = 1; 198 199 private final QueryRunner mQueryRunner; 200 private final QueryRunner.Callback mQueryCallback; 201 private final ContentResolver mResolver; 202 private final Object mContentLock = new Object(); 203 private final @GuardedBy("mContentLock") Set<Query> mActiveQueries = new HashSet<>(); 204 private final @GuardedBy("mContentLock") CursorCache mCursorCache; 205 206 private final Stats mStats = new Stats(); 207 208 /** 209 * Creates a new ContentPager with a default cursor cache size of 1. 210 */ ContentPager(ContentResolver resolver, QueryRunner queryRunner)211 public ContentPager(ContentResolver resolver, QueryRunner queryRunner) { 212 this(resolver, queryRunner, DEFAULT_CURSOR_CACHE_SIZE); 213 } 214 215 /** 216 * Creates a new ContentPager. 217 * 218 * @param cursorCacheSize Specifies the size of the unpaged cursor cache. If you will 219 * only be querying a single content Uri, 1 is sufficient. If you wish to use 220 * a single ContentPager for queries against several independent Uris this number 221 * should be increased to reflect that. Remember that adding or modifying a 222 * query argument creates a new Uri. 223 * @param resolver The content resolver to use when performing queries. 224 * @param queryRunner The query running to use. This provides a means of executing 225 * queries on a background thread. 226 */ ContentPager( @onNull ContentResolver resolver, @NonNull QueryRunner queryRunner, int cursorCacheSize)227 public ContentPager( 228 @NonNull ContentResolver resolver, 229 @NonNull QueryRunner queryRunner, 230 int cursorCacheSize) { 231 232 checkArgument(resolver != null, "'resolver' argument cannot be null."); 233 checkArgument(queryRunner != null, "'queryRunner' argument cannot be null."); 234 checkArgument(cursorCacheSize > 0, "'cursorCacheSize' argument must be greater than 0."); 235 236 mResolver = resolver; 237 mQueryRunner = queryRunner; 238 mQueryCallback = new QueryRunner.Callback() { 239 240 @WorkerThread 241 @Override 242 public @Nullable Cursor runQueryInBackground(Query query) { 243 return loadContentInBackground(query); 244 } 245 246 @MainThread 247 @Override 248 public void onQueryFinished(Query query, Cursor cursor) { 249 ContentPager.this.onCursorReady(query, cursor); 250 } 251 }; 252 253 mCursorCache = new CursorCache(cursorCacheSize); 254 } 255 256 /** 257 * Initiates loading of content. 258 * For details on all params but callback, see 259 * {@link ContentResolver#query(Uri, String[], Bundle, CancellationSignal)}. 260 * 261 * @param uri The URI, using the content:// scheme, for the content to retrieve. 262 * @param projection A list of which columns to return. Passing null will return 263 * the default project as determined by the provider. This can be inefficient, 264 * so it is best to supply a projection. 265 * @param queryArgs A Bundle containing any arguments to the query. 266 * @param cancellationSignal A signal to cancel the operation in progress, or null if none. 267 * If the operation is canceled, then {@link OperationCanceledException} will be thrown 268 * when the query is executed. 269 * @param callback The callback that will receive the query results. 270 * 271 * @return A Query object describing the query. 272 */ 273 @MainThread query( @onNull @equiresPermission.Read Uri uri, @Nullable String[] projection, @NonNull Bundle queryArgs, @Nullable CancellationSignal cancellationSignal, @NonNull ContentCallback callback)274 public @NonNull Query query( 275 @NonNull @RequiresPermission.Read Uri uri, 276 @Nullable String[] projection, 277 @NonNull Bundle queryArgs, 278 @Nullable CancellationSignal cancellationSignal, 279 @NonNull ContentCallback callback) { 280 281 checkArgument(uri != null, "'uri' argument cannot be null."); 282 checkArgument(queryArgs != null, "'queryArgs' argument cannot be null."); 283 checkArgument(callback != null, "'callback' argument cannot be null."); 284 285 Query query = new Query(uri, projection, queryArgs, cancellationSignal, callback); 286 287 if (DEBUG) Log.d(TAG, "Handling query: " + query); 288 289 if (!mQueryRunner.isRunning(query)) { 290 synchronized (mContentLock) { 291 mActiveQueries.add(query); 292 } 293 mQueryRunner.query(query, mQueryCallback); 294 } 295 296 return query; 297 } 298 299 /** 300 * Clears any cached data. This method must be called in order to cleanup runtime state 301 * (like cursors). 302 */ 303 @MainThread reset()304 public void reset() { 305 synchronized (mContentLock) { 306 if (DEBUG) Log.d(TAG, "Clearing un-paged cursor cache."); 307 mCursorCache.evictAll(); 308 309 for (Query query : mActiveQueries) { 310 if (DEBUG) Log.d(TAG, "Canceling running query: " + query); 311 mQueryRunner.cancel(query); 312 query.cancel(); 313 } 314 315 mActiveQueries.clear(); 316 } 317 } 318 319 @WorkerThread loadContentInBackground(Query query)320 private Cursor loadContentInBackground(Query query) { 321 if (DEBUG) Log.v(TAG, "Loading cursor for query: " + query); 322 mStats.increment(Stats.EXTRA_TOTAL_QUERIES); 323 324 synchronized (mContentLock) { 325 // We have a existing unpaged-cursor for this query. Instead of running a new query 326 // via ContentResolver, we'll just copy results from that. 327 // This is the "compat" behavior. 328 if (mCursorCache.hasEntry(query.getUri())) { 329 if (DEBUG) Log.d(TAG, "Found unpaged results in cache for: " + query); 330 return createPagedCursor(query); 331 } 332 } 333 334 // We don't have an unpaged query, so we run the query using ContentResolver. 335 // It may be that no query for this URI has ever been run, so no unpaged 336 // results have been saved. Or, it may be the the provider supports paging 337 // directly, and is returning a pre-paged result set...so no unpaged 338 // cursor will ever be set. 339 Cursor cursor = query.run(mResolver); 340 mStats.increment(Stats.EXTRA_RESOLVED_QUERIES); 341 342 // for the window. If so, communicate the overflow back to the client. 343 if (cursor == null) { 344 Log.e(TAG, "Query resulted in null cursor. " + query); 345 return null; 346 } 347 348 if (isProviderPaged(cursor)) { 349 return processProviderPagedCursor(query, cursor); 350 } 351 352 // Cache the unpaged results so we can generate pages from them on subsequent queries. 353 synchronized (mContentLock) { 354 mCursorCache.put(query.getUri(), cursor); 355 return createPagedCursor(query); 356 } 357 } 358 359 @WorkerThread 360 @GuardedBy("mContentLock") createPagedCursor(Query query)361 private Cursor createPagedCursor(Query query) { 362 Cursor unpaged = mCursorCache.get(query.getUri()); 363 checkState(unpaged != null, "No un-paged cursor in cache, or can't retrieve it."); 364 365 mStats.increment(Stats.EXTRA_COMPAT_PAGED); 366 367 if (DEBUG) Log.d(TAG, "Synthesizing cursor for page: " + query); 368 int count = Math.min(query.getLimit(), unpaged.getCount()); 369 370 // don't wander off the end of the cursor. 371 if (query.getOffset() + query.getLimit() > unpaged.getCount()) { 372 count = unpaged.getCount() % query.getLimit(); 373 } 374 375 if (DEBUG) Log.d(TAG, "Cursor count: " + count); 376 377 Cursor result = null; 378 // If the cursor isn't advertising support for paging, but is in-fact smaller 379 // than the page size requested, we just decorate the cursor with paging data, 380 // and wrap it without copy. 381 if (query.getOffset() == 0 && unpaged.getCount() < query.getLimit()) { 382 result = new CursorView( 383 unpaged, unpaged.getCount(), CURSOR_DISPOSITION_WRAPPED); 384 } else { 385 // This creates an in-memory copy of the data that fits the requested page. 386 // ContentObservers registered on InMemoryCursor are directly registered 387 // on the unpaged cursor. 388 result = new InMemoryCursor( 389 unpaged, query.getOffset(), count, CURSOR_DISPOSITION_COPIED); 390 } 391 392 mStats.includeStats(result.getExtras()); 393 return result; 394 } 395 396 @WorkerThread processProviderPagedCursor(Query query, Cursor cursor)397 private @Nullable Cursor processProviderPagedCursor(Query query, Cursor cursor) { 398 399 CursorWindow window = getWindow(cursor); 400 int windowSize = cursor.getCount(); 401 if (window != null) { 402 if (DEBUG) Log.d(TAG, "Returning provider-paged cursor."); 403 windowSize = window.getNumRows(); 404 } 405 406 // Android O paging APIs are *all* about avoiding CursorWindow swaps, 407 // because the swaps need to happen on the UI thread in jank-inducing ways. 408 // But, the APIs don't *guarantee* that no window-swapping will happen 409 // when traversing a cursor. 410 // 411 // Here in the support lib, we can guarantee there is no window swapping 412 // by detecting mismatches between requested sizes and window sizes. 413 // When a mismatch is detected we can return a cursor that reports 414 // a size bounded by its CursorWindow size, and includes a suggested 415 // size to use for subsequent queries. 416 417 if (DEBUG) Log.d(TAG, "Cursor window overflow detected. Returning re-paged cursor."); 418 419 int disposition = (cursor.getCount() <= windowSize) 420 ? CURSOR_DISPOSITION_PAGED 421 : CURSOR_DISPOSITION_REPAGED; 422 423 Cursor result = new CursorView(cursor, windowSize, disposition); 424 Bundle extras = result.getExtras(); 425 426 // If the orig cursor reports a size larger than the window, suggest a better limit. 427 if (cursor.getCount() > windowSize) { 428 extras.putInt(EXTRA_REQUESTED_LIMIT, query.getLimit()); 429 extras.putInt(EXTRA_SUGGESTED_LIMIT, (int) (windowSize * .85)); 430 } 431 432 mStats.increment(Stats.EXTRA_PROVIDER_PAGED); 433 mStats.includeStats(extras); 434 return result; 435 } 436 getWindow(Cursor cursor)437 private CursorWindow getWindow(Cursor cursor) { 438 if (cursor instanceof CursorWrapper) { 439 return getWindow(((CursorWrapper) cursor).getWrappedCursor()); 440 } 441 if (cursor instanceof CrossProcessCursor) { 442 return ((CrossProcessCursor) cursor).getWindow(); 443 } 444 // TODO: Any other ways we can find/access windows? 445 return null; 446 } 447 448 // Called in the foreground when the cursor is ready for the client. 449 @MainThread onCursorReady(Query query, Cursor cursor)450 private void onCursorReady(Query query, Cursor cursor) { 451 synchronized (mContentLock) { 452 mActiveQueries.remove(query); 453 } 454 455 query.getCallback().onCursorReady(query, cursor); 456 } 457 458 /** 459 * @return true if the cursor extras contains all of the signs of being paged. 460 * Technically we could also check SDK version since facilities for paging 461 * were added in SDK 26, but if it looks like a duck and talks like a duck 462 * itsa duck (especially if it helps with testing). 463 */ 464 @WorkerThread isProviderPaged(Cursor cursor)465 private boolean isProviderPaged(Cursor cursor) { 466 Bundle extras = cursor.getExtras(); 467 extras = extras != null ? extras : Bundle.EMPTY; 468 String[] honoredArgs = extras.getStringArray(EXTRA_HONORED_ARGS); 469 470 return (extras.containsKey(EXTRA_TOTAL_COUNT) 471 && honoredArgs != null 472 && contains(honoredArgs, QUERY_ARG_OFFSET) 473 && contains(honoredArgs, QUERY_ARG_LIMIT)); 474 } 475 contains(T[] array, T value)476 private static <T> boolean contains(T[] array, T value) { 477 for (T element : array) { 478 if (value.equals(element)) { 479 return true; 480 } 481 } 482 return false; 483 } 484 485 /** 486 * @return Bundle populated with existing extras (if any) as well as 487 * all usefule paging related extras. 488 */ buildExtras( @ullable Bundle extras, int recordCount, @CursorDisposition int cursorDisposition)489 static Bundle buildExtras( 490 @Nullable Bundle extras, int recordCount, @CursorDisposition int cursorDisposition) { 491 492 if (extras == null || extras == Bundle.EMPTY) { 493 extras = new Bundle(); 494 } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 495 extras = extras.deepCopy(); 496 } 497 // else we modify cursor extras directly, cuz that's our only choice. 498 499 extras.putInt(CURSOR_DISPOSITION, cursorDisposition); 500 if (!extras.containsKey(EXTRA_TOTAL_COUNT)) { 501 extras.putInt(EXTRA_TOTAL_COUNT, recordCount); 502 } 503 504 if (!extras.containsKey(EXTRA_HONORED_ARGS)) { 505 extras.putStringArray(EXTRA_HONORED_ARGS, new String[]{ 506 ContentPager.QUERY_ARG_OFFSET, 507 ContentPager.QUERY_ARG_LIMIT 508 }); 509 } 510 511 return extras; 512 } 513 514 /** 515 * Builds a Bundle with offset and limit values suitable for with 516 * {@link #query(Uri, String[], Bundle, CancellationSignal, ContentCallback)}. 517 * 518 * @param offset must be greater than or equal to 0. 519 * @param limit can be any value. Only values greater than or equal to 0 are respected. 520 * If any other value results in no upper limit on results. Note that a well 521 * behaved client should probably supply a reasonable limit. See class 522 * documentation on how to select a limit. 523 * 524 * @return Mutable Bundle pre-populated with offset and limits vales. 525 */ createArgs(int offset, int limit)526 public static @NonNull Bundle createArgs(int offset, int limit) { 527 checkArgument(offset >= 0); 528 Bundle args = new Bundle(); 529 args.putInt(ContentPager.QUERY_ARG_OFFSET, offset); 530 args.putInt(ContentPager.QUERY_ARG_LIMIT, limit); 531 return args; 532 } 533 534 /** 535 * Callback by which a client receives results of a query. 536 */ 537 public interface ContentCallback { 538 /** 539 * Called when paged cursor is ready. Null, if query failed. 540 * @param query The query having been executed. 541 * @param cursor the query results. Null if query couldn't be executed. 542 */ 543 @MainThread onCursorReady(@onNull Query query, @Nullable Cursor cursor)544 void onCursorReady(@NonNull Query query, @Nullable Cursor cursor); 545 } 546 547 /** 548 * Provides support for adding extras to a cursor. This is necessary 549 * as a cursor returning an extras Bundle that is either Bundle.EMPTY 550 * or null, cannot have information added to the cursor. On SDKs earlier 551 * than M, there is no facility to replace the Bundle. 552 */ 553 private static final class CursorView extends CursorWrapper { 554 private final Bundle mExtras; 555 private final int mSize; 556 CursorView(Cursor delegate, int size, @CursorDisposition int disposition)557 CursorView(Cursor delegate, int size, @CursorDisposition int disposition) { 558 super(delegate); 559 mSize = size; 560 561 mExtras = buildExtras(delegate.getExtras(), delegate.getCount(), disposition); 562 } 563 564 @Override getCount()565 public int getCount() { 566 return mSize; 567 } 568 569 @Override getExtras()570 public Bundle getExtras() { 571 return mExtras; 572 } 573 } 574 575 /** 576 * LruCache holding at most {@code maxSize} cursors. Once evicted a cursor 577 * is immediately closed. The only cursor's held in this cache are 578 * unpaged results. For this purpose the cache is keyed by the URI, 579 * not the entire query. Cursors that are pre-paged by the provider 580 * are never cached. 581 */ 582 private static final class CursorCache extends LruCache<Uri, Cursor> { CursorCache(int maxSize)583 CursorCache(int maxSize) { 584 super(maxSize); 585 } 586 587 @WorkerThread 588 @Override entryRemoved( boolean evicted, Uri uri, Cursor oldCursor, Cursor newCursor)589 protected void entryRemoved( 590 boolean evicted, Uri uri, Cursor oldCursor, Cursor newCursor) { 591 if (!oldCursor.isClosed()) { 592 oldCursor.close(); 593 } 594 } 595 596 /** @return true if an entry is present for the Uri. */ 597 @WorkerThread 598 @GuardedBy("mContentLock") hasEntry(Uri uri)599 boolean hasEntry(Uri uri) { 600 return get(uri) != null; 601 } 602 } 603 604 /** 605 * Implementations of this interface provide the mechanism 606 * for execution of queries off the UI thread. 607 */ 608 public interface QueryRunner { 609 /** 610 * Execute a query. 611 * @param query The query that will be run. This value should be handed 612 * back to the callback when ready to run in the background. 613 * @param callback The callback that should be called to both execute 614 * the query (in the background) and to receive the results 615 * (in the foreground). 616 */ query(@onNull Query query, @NonNull Callback callback)617 void query(@NonNull Query query, @NonNull Callback callback); 618 619 /** 620 * @param query The query in question. 621 * @return true if the query is already running. 622 */ isRunning(@onNull Query query)623 boolean isRunning(@NonNull Query query); 624 625 /** 626 * Attempt to cancel a (presumably) running query. 627 * @param query The query in question. 628 */ cancel(@onNull Query query)629 void cancel(@NonNull Query query); 630 631 /** 632 * Callback that receives a cursor once a query as been executed on the Runner. 633 */ 634 interface Callback { 635 /** 636 * Method called on background thread where actual query is executed. This is provided 637 * by ContentPager. 638 * @param query The query to be executed. 639 */ runQueryInBackground(@onNull Query query)640 @Nullable Cursor runQueryInBackground(@NonNull Query query); 641 642 /** 643 * Called on main thread when query has completed. 644 * @param query The completed query. 645 * @param cursor The results in Cursor form. Null if not successfully completed. 646 */ onQueryFinished(@onNull Query query, @Nullable Cursor cursor)647 void onQueryFinished(@NonNull Query query, @Nullable Cursor cursor); 648 } 649 } 650 651 static final class Stats { 652 653 /** Identifes the total number of queries handled by ContentPager. */ 654 static final String EXTRA_TOTAL_QUERIES = "android-support:extra-total-queries"; 655 656 /** Identifes the number of queries handled by content resolver. */ 657 static final String EXTRA_RESOLVED_QUERIES = "android-support:extra-resolved-queries"; 658 659 /** Identifes the number of pages produced by way of copying. */ 660 static final String EXTRA_COMPAT_PAGED = "android-support:extra-compat-paged"; 661 662 /** Identifes the number of pages produced directly by a page-supporting provider. */ 663 static final String EXTRA_PROVIDER_PAGED = "android-support:extra-provider-paged"; 664 665 // simple stats objects tracking paged result handling. 666 private int mTotalQueries; 667 private int mResolvedQueries; 668 private int mCompatPaged; 669 private int mProviderPaged; 670 increment(String prop)671 private void increment(String prop) { 672 switch (prop) { 673 case EXTRA_TOTAL_QUERIES: 674 ++mTotalQueries; 675 break; 676 677 case EXTRA_RESOLVED_QUERIES: 678 ++mResolvedQueries; 679 break; 680 681 case EXTRA_COMPAT_PAGED: 682 ++mCompatPaged; 683 break; 684 685 case EXTRA_PROVIDER_PAGED: 686 ++mProviderPaged; 687 break; 688 689 default: 690 throw new IllegalArgumentException("Unknown property: " + prop); 691 } 692 } 693 reset()694 private void reset() { 695 mTotalQueries = 0; 696 mResolvedQueries = 0; 697 mCompatPaged = 0; 698 mProviderPaged = 0; 699 } 700 includeStats(Bundle bundle)701 void includeStats(Bundle bundle) { 702 bundle.putInt(EXTRA_TOTAL_QUERIES, mTotalQueries); 703 bundle.putInt(EXTRA_RESOLVED_QUERIES, mResolvedQueries); 704 bundle.putInt(EXTRA_COMPAT_PAGED, mCompatPaged); 705 bundle.putInt(EXTRA_PROVIDER_PAGED, mProviderPaged); 706 } 707 } 708 } 709