Content paging compat + Load cursors in background.
Adds support for loading cursors in background.
Synthesize paged cursors from unpaged results when paging is requested.
Try to be more efficient than MatrixCursor: InMemoryCursor, avoids a lot of auto-boxing associated with building MatrixCursor.
Adds a very basic demo app that pages through content (but with buttons, not in response to scrolling).
Test: Added tests for new classes. They're awesome!
Change-Id: I024ee0d4a79b87816efd6ad8091bf6de7daf6995
diff --git a/content/Android.mk b/content/Android.mk
new file mode 100644
index 0000000..eff8215
--- /dev/null
+++ b/content/Android.mk
@@ -0,0 +1,29 @@
+# Copyright (C) 2017 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+LOCAL_PATH := $(call my-dir)
+
+include $(CLEAR_VARS)
+LOCAL_USE_AAPT2 := true
+LOCAL_MODULE := android-support-content
+LOCAL_SDK_VERSION := $(SUPPORT_CURRENT_SDK_VERSION)
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res
+LOCAL_SHARED_ANDROID_LIBRARIES := \
+ android-support-compat \
+ android-support-annotations
+LOCAL_JAR_EXCLUDE_FILES := none
+LOCAL_JAVA_LANGUAGE_VERSION := 1.7
+LOCAL_AAPT_FLAGS := --add-javadoc-annotation doconly
+include $(BUILD_STATIC_JAVA_LIBRARY)
diff --git a/content/AndroidManifest.xml b/content/AndroidManifest.xml
new file mode 100644
index 0000000..5f2a29e
--- /dev/null
+++ b/content/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2017 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="android.support.content">
+ <uses-sdk android:minSdkVersion="14" />
+ <application>
+ <meta-data android:name="android.support.VERSION" android:value="${support-version}" />
+ </application>
+</manifest>
diff --git a/content/build.gradle b/content/build.gradle
new file mode 100644
index 0000000..955e679
--- /dev/null
+++ b/content/build.gradle
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http: *www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+apply plugin: android.support.SupportLibraryPlugin
+archivesBaseName = 'support-content'
+
+android {
+ defaultConfig {
+ minSdkVersion 14
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ }
+
+ sourceSets {
+ main.java.srcDirs = ['src']
+ main.res.srcDir 'res'
+ }
+}
+
+dependencies {
+ compile project(':support-annotations')
+ compile project(':support-compat')
+
+ androidTestCompile libs.junit
+ androidTestCompile (libs.test_runner) {
+ exclude module: 'support-annotations'
+ }
+ androidTestCompile (libs.espresso_core) {
+ exclude module: 'support-annotations'
+ }
+}
+
+supportLibrary {
+ name 'Android Support Content'
+ inceptionYear '2017'
+ description 'Library providing support for paging across content exposed via a ContentProvider. Use of this library allows a client to avoid expensive interprocess "cursor window swaps" on the UI thread.'
+}
diff --git a/content/lint-baseline.xml b/content/lint-baseline.xml
new file mode 100644
index 0000000..172bbf6
--- /dev/null
+++ b/content/lint-baseline.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<issues format="4" by="lint 2.4.0-alpha6">
+
+</issues>
diff --git a/content/src/android/support/content/ContentPager.java b/content/src/android/support/content/ContentPager.java
new file mode 100644
index 0000000..71cb832
--- /dev/null
+++ b/content/src/android/support/content/ContentPager.java
@@ -0,0 +1,707 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static android.support.v4.util.Preconditions.checkArgument;
+import static android.support.v4.util.Preconditions.checkState;
+
+import android.content.ContentResolver;
+import android.database.CrossProcessCursor;
+import android.database.Cursor;
+import android.database.CursorWindow;
+import android.database.CursorWrapper;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.OperationCanceledException;
+import android.support.annotation.GuardedBy;
+import android.support.annotation.IntDef;
+import android.support.annotation.MainThread;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.RequiresPermission;
+import android.support.annotation.VisibleForTesting;
+import android.support.annotation.WorkerThread;
+import android.support.v4.util.LruCache;
+import android.util.Log;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * {@link ContentPager} provides support for loading "paged" data on a background thread
+ * using the {@link ContentResolver} framework. This provides an effective compatibility
+ * layer for the ContentResolver "paging" support added in Android O. Those Android O changes,
+ * like this class, help reduce or eliminate the occurrence of expensive inter-process
+ * shared memory operations (aka "CursorWindow swaps") happening on the UI thread when
+ * working with remote providers.
+ *
+ * <p>The list of terms used in this document:
+ *
+ * <ol>"The provider" is a {@link android.content.ContentProvider} supplying data identified
+ * by a specific content {@link Uri}. A provider is the source of data, and for the sake of
+ * this documents, the provider resides in a remote process.
+
+ * <ol>"supports paging" A provider supports paging when it returns a pre-paged {@link Cursor}
+ * that honors the paging contract. See @link ContentResolver#QUERY_ARG_OFFSET} and
+ * {@link ContentResolver#QUERY_ARG_LIMIT} for details on the contract.
+
+ * <ol>"CursorWindow swaps" The process by which new data is loaded into a shared memory
+ * via a CursorWindow instance. This is a prominent contributor to UI jank in applications
+ * that use Cursor as backing data for UI elements like {@code RecyclerView}.
+ *
+ * <p><b>Details</b>
+ *
+ * <p>Data will be loaded from a content uri in one of two ways, depending on the runtime
+ * environment and if the provider supports paging.
+ *
+ * <li>If the system is Android O and greater and the provider supports paging, the Cursor
+ * will be returned, effectively unmodified, to a {@link ContentCallback} supplied by
+ * your application.
+ *
+ * <li>If the system is less than Android O or the provider does not support paging, the
+ * loader will fetch an unpaged Cursor from the provider. The unpaged Cursor will be held
+ * by the ContentPager, and data will be copied into a new cursor in a background thread.
+ * The new cursor will be returned to a {@link ContentCallback} supplied by your application.
+ *
+ * <p>In either cases, when an application employs this library it can generally assume
+ * that there will be no CursorWindow swap. But picking the right limit for records can
+ * help reduce or even eliminate some heavy lifting done to guard against swaps.
+ *
+ * <p>How do we avoid that entirely?
+ *
+ * <p><b>Picking a reasonable item limit</b>
+ *
+ * <p>Authors are encouraged to experiment with limits using real data and the widest column
+ * projection they'll use in their app. The total number of records that will fit into shared
+ * memory varies depending on multiple factors.
+ *
+ * <li>The number of columns being requested in the cursor projection. Limit the number
+ * of columns, to reduce the size of each row.
+ * <li>The size of the data in each column.
+ * <li>the Cursor type.
+ *
+ * <p>If the cursor is running in-process, there may be no need for paging. Depending on
+ * the Cursor implementation chosen there may be no shared memory/CursorWindow in use.
+ * NOTE: If the provider is running in your process, you should implement paging support
+ * inorder to make your app run fast and to consume the fewest resources possible.
+ *
+ * <p>In common cases where there is a low volume (in the hundreds) of records in the dataset
+ * being queried, all of the data should easily fit in shared memory. A debugger can be handy
+ * to understand with greater accuracy how many results can fit in shared memory. Inspect
+ * the Cursor object returned from a call to
+ * {@link ContentResolver#query(Uri, String[], String, String[], String)}. If the underlying
+ * type is a {@link android.database.CrossProcessCursor} or
+ * {@link android.database.AbstractWindowedCursor} it'll have a {@link CursorWindow} field.
+ * Check {@link CursorWindow#getNumRows()}. If getNumRows returns less than
+ * {@link Cursor#getCount}, then you've found something close to the max rows that'll
+ * fit in a page. If the data in row is expected to be relatively stable in size, reduce
+ * row count by 15-20% to get a reasonable max page size.
+ *
+ * <p><b>What if the limit I guessed was wrong?</b>
+
+ * <p>The library includes safeguards that protect against situations where an author
+ * specifies a record limit that exceeds the number of rows accessible without a CursorWindow swap.
+ * In such a circumstance, the Cursor will be adapted to report a count ({Cursor#getCount})
+ * that reflects only records available without CursorWindow swap. But this involves
+ * extra work that can be eliminated with a correct limit.
+ *
+ * <p>In addition to adjusted coujnt, {@link #EXTRA_SUGGESTED_LIMIT} will be included
+ * in cursor extras. When EXTRA_SUGGESTED_LIMIT is present in extras, the client should
+ * strongly consider using this value as the limit for subsequent queries as doing so should
+ * help avoid the ned to wrap pre-paged cursors.
+ *
+ * <p><b>Lifecycle and cleanup</b>
+ *
+ * <p>Cursors resulting from queries are owned by the requesting client. So they must be closed
+ * by the client at the appropriate time.
+ *
+ * <p>However, the library retains an internal cache of content that needs to be cleaned up.
+ * In order to cleanup, call {@link #reset()}.
+ *
+ * <p><b>Projections</b>
+ *
+ * <p>Note that projection is ignored when determining the identity of a query. When
+ * adding or removing projection, clients should call {@link #reset()} to clear
+ * cached data.
+ */
+public class ContentPager {
+
+ @VisibleForTesting
+ static final String CURSOR_DISPOSITION = "android.support.v7.widget.CURSOR_DISPOSITION";
+
+ @IntDef(value = {
+ ContentPager.CURSOR_DISPOSITION_COPIED,
+ ContentPager.CURSOR_DISPOSITION_PAGED,
+ ContentPager.CURSOR_DISPOSITION_REPAGED,
+ ContentPager.CURSOR_DISPOSITION_WRAPPED
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ public @interface CursorDisposition {}
+
+ /** The cursor size exceeded page size. A new cursor with with page data was created. */
+ public static final int CURSOR_DISPOSITION_COPIED = 1;
+
+ /**
+ * The cursor was provider paged.
+ */
+ public static final int CURSOR_DISPOSITION_PAGED = 2;
+
+ /** The cursor was pre-paged, but total size was larger than CursorWindow size. */
+ public static final int CURSOR_DISPOSITION_REPAGED = 3;
+
+ /**
+ * The cursor was not pre-paged, but total size was smaller than page size.
+ * Cursor wrapped to supply data in extras only.
+ */
+ public static final int CURSOR_DISPOSITION_WRAPPED = 4;
+
+ /** @see ContentResolver#EXTRA_HONORED_ARGS */
+ public static final String EXTRA_HONORED_ARGS = ContentResolver.EXTRA_HONORED_ARGS;
+
+ /** @see ContentResolver#EXTRA_TOTAL_COUNT */
+ public static final String EXTRA_TOTAL_COUNT = ContentResolver.EXTRA_TOTAL_COUNT;
+
+ /** @see ContentResolver#QUERY_ARG_OFFSET */
+ public static final String QUERY_ARG_OFFSET = ContentResolver.QUERY_ARG_OFFSET;
+
+ /** @see ContentResolver#QUERY_ARG_LIMIT */
+ public static final String QUERY_ARG_LIMIT = ContentResolver.QUERY_ARG_LIMIT;
+
+ /** Denotes the requested limit, if the limit was not-honored. */
+ public static final String EXTRA_REQUESTED_LIMIT = "android-support:extra-ignored-limit";
+
+ /** Specifies a limit likely to fit in CursorWindow limit. */
+ public static final String EXTRA_SUGGESTED_LIMIT = "android-support:extra-suggested-limit";
+
+ private static final boolean DEBUG = false;
+ private static final String TAG = "ContentPager";
+ private static final int DEFAULT_CURSOR_CACHE_SIZE = 1;
+
+ private final QueryRunner mQueryRunner;
+ private final QueryRunner.Callback mQueryCallback;
+ private final ContentResolver mResolver;
+ private final Object mContentLock = new Object();
+ private final @GuardedBy("mContentLock") Set<Query> mActiveQueries = new HashSet<>();
+ private final @GuardedBy("mContentLock") CursorCache mCursorCache;
+
+ private final Stats mStats = new Stats();
+
+ /**
+ * Creates a new ContentPager with a default cursor cache size of 1.
+ */
+ public ContentPager(ContentResolver resolver, QueryRunner queryRunner) {
+ this(resolver, queryRunner, DEFAULT_CURSOR_CACHE_SIZE);
+ }
+
+ /**
+ * Creates a new ContentPager.
+ *
+ * @param cursorCacheSize Specifies the size of the unpaged cursor cache. If you will
+ * only be querying a single content Uri, 1 is sufficient. If you wish to use
+ * a single ContentPager for queries against several independent Uris this number
+ * should be increased to reflect that. Remember that adding or modifying a
+ * query argument creates a new Uri.
+ * @param resolver The content resolver to use when performing queries.
+ * @param queryRunner The query running to use. This provides a means of executing
+ * queries on a background thread.
+ */
+ public ContentPager(
+ @NonNull ContentResolver resolver,
+ @NonNull QueryRunner queryRunner,
+ int cursorCacheSize) {
+
+ checkArgument(resolver != null, "'resolver' argument cannot be null.");
+ checkArgument(queryRunner != null, "'queryRunner' argument cannot be null.");
+ checkArgument(cursorCacheSize > 0, "'cursorCacheSize' argument must be greater than 0.");
+
+ mResolver = resolver;
+ mQueryRunner = queryRunner;
+ mQueryCallback = new QueryRunner.Callback() {
+
+ @WorkerThread
+ @Override
+ public @Nullable Cursor runQueryInBackground(Query query) {
+ return loadContentInBackground(query);
+ }
+
+ @MainThread
+ @Override
+ public void onQueryFinished(Query query, Cursor cursor) {
+ ContentPager.this.onCursorReady(query, cursor);
+ }
+ };
+
+ mCursorCache = new CursorCache(cursorCacheSize);
+ }
+
+ /**
+ * Initiates loading of content.
+ * For details on all params but callback, see
+ * {@link ContentResolver#query(Uri, String[], Bundle, CancellationSignal)}.
+ *
+ * @param uri The URI, using the content:// scheme, for the content to retrieve.
+ * @param projection A list of which columns to return. Passing null will return
+ * the default project as determined by the provider. This can be inefficient,
+ * so it is best to supply a projection.
+ * @param queryArgs A Bundle containing any arguments to the query.
+ * @param cancellationSignal A signal to cancel the operation in progress, or null if none.
+ * If the operation is canceled, then {@link OperationCanceledException} will be thrown
+ * when the query is executed.
+ * @param callback The callback that will receive the query results.
+ *
+ * @return A Query object describing the query.
+ */
+ @MainThread
+ public @NonNull Query query(
+ @NonNull @RequiresPermission.Read Uri uri,
+ @Nullable String[] projection,
+ @NonNull Bundle queryArgs,
+ @Nullable CancellationSignal cancellationSignal,
+ @NonNull ContentCallback callback) {
+
+ checkArgument(uri != null, "'uri' argument cannot be null.");
+ checkArgument(queryArgs != null, "'queryArgs' argument cannot be null.");
+ checkArgument(callback != null, "'callback' argument cannot be null.");
+
+ Query query = new Query(uri, projection, queryArgs, cancellationSignal, callback);
+
+ if (DEBUG) Log.d(TAG, "Handling query: " + query);
+
+ if (!mQueryRunner.isRunning(query)) {
+ synchronized (mContentLock) {
+ mActiveQueries.add(query);
+ }
+ mQueryRunner.query(query, mQueryCallback);
+ }
+
+ return query;
+ }
+
+ /**
+ * Clears any cached data. This method must be called in order to cleanup runtime state
+ * (like cursors).
+ */
+ @MainThread
+ public void reset() {
+ synchronized (mContentLock) {
+ if (DEBUG) Log.d(TAG, "Clearing un-paged cursor cache.");
+ mCursorCache.evictAll();
+
+ for (Query query : mActiveQueries) {
+ if (DEBUG) Log.d(TAG, "Canceling running query: " + query);
+ mQueryRunner.cancel(query);
+ query.cancel();
+ }
+
+ mActiveQueries.clear();
+ }
+ }
+
+ @WorkerThread
+ private Cursor loadContentInBackground(Query query) {
+ if (DEBUG) Log.v(TAG, "Loading cursor for query: " + query);
+ mStats.increment(Stats.EXTRA_TOTAL_QUERIES);
+
+ synchronized (mContentLock) {
+ // We have a existing unpaged-cursor for this query. Instead of running a new query
+ // via ContentResolver, we'll just copy results from that.
+ // This is the "compat" behavior.
+ if (mCursorCache.hasEntry(query.getUri())) {
+ if (DEBUG) Log.d(TAG, "Found unpaged results in cache for: " + query);
+ return createPagedCursor(query);
+ }
+ }
+
+ // We don't have an unpaged query, so we run the query using ContentResolver.
+ // It may be that no query for this URI has ever been run, so no unpaged
+ // results have been saved. Or, it may be the the provider supports paging
+ // directly, and is returning a pre-paged result set...so no unpaged
+ // cursor will ever be set.
+ Cursor cursor = query.run(mResolver);
+ mStats.increment(Stats.EXTRA_RESOLVED_QUERIES);
+
+ // for the window. If so, communicate the overflow back to the client.
+ if (cursor == null) {
+ Log.e(TAG, "Query resulted in null cursor. " + query);
+ return null;
+ }
+
+ if (isProviderPaged(cursor)) {
+ return processProviderPagedCursor(query, cursor);
+ }
+
+ // Cache the unpaged results so we can generate pages from them on subsequent queries.
+ synchronized (mContentLock) {
+ mCursorCache.put(query.getUri(), cursor);
+ return createPagedCursor(query);
+ }
+ }
+
+ @WorkerThread
+ @GuardedBy("mContentLock")
+ private Cursor createPagedCursor(Query query) {
+ Cursor unpaged = mCursorCache.get(query.getUri());
+ checkState(unpaged != null, "No un-paged cursor in cache, or can't retrieve it.");
+
+ mStats.increment(Stats.EXTRA_COMPAT_PAGED);
+
+ if (DEBUG) Log.d(TAG, "Synthesizing cursor for page: " + query);
+ int count = Math.min(query.getLimit(), unpaged.getCount());
+
+ // don't wander off the end of the cursor.
+ if (query.getOffset() + query.getLimit() > unpaged.getCount()) {
+ count = unpaged.getCount() % query.getLimit();
+ }
+
+ if (DEBUG) Log.d(TAG, "Cursor count: " + count);
+
+ Cursor result = null;
+ // If the cursor isn't advertising support for paging, but is in-fact smaller
+ // than the page size requested, we just decorate the cursor with paging data,
+ // and wrap it without copy.
+ if (query.getOffset() == 0 && unpaged.getCount() < query.getLimit()) {
+ result = new CursorView(
+ unpaged, unpaged.getCount(), CURSOR_DISPOSITION_WRAPPED);
+ } else {
+ // This creates an in-memory copy of the data that fits the requested page.
+ // ContentObservers registered on InMemoryCursor are directly registered
+ // on the unpaged cursor.
+ result = new InMemoryCursor(
+ unpaged, query.getOffset(), count, CURSOR_DISPOSITION_COPIED);
+ }
+
+ mStats.includeStats(result.getExtras());
+ return result;
+ }
+
+ @WorkerThread
+ private @Nullable Cursor processProviderPagedCursor(Query query, Cursor cursor) {
+
+ CursorWindow window = getWindow(cursor);
+ int windowSize = cursor.getCount();
+ if (window != null) {
+ if (DEBUG) Log.d(TAG, "Returning provider-paged cursor.");
+ windowSize = window.getNumRows();
+ }
+
+ // Android O paging APIs are *all* about avoiding CursorWindow swaps,
+ // because the swaps need to happen on the UI thread in jank-inducing ways.
+ // But, the APIs don't *guarantee* that no window-swapping will happen
+ // when traversing a cursor.
+ //
+ // Here in the support lib, we can guarantee there is no window swapping
+ // by detecting mismatches between requested sizes and window sizes.
+ // When a mismatch is detected we can return a cursor that reports
+ // a size bounded by its CursorWindow size, and includes a suggested
+ // size to use for subsequent queries.
+
+ if (DEBUG) Log.d(TAG, "Cursor window overflow detected. Returning re-paged cursor.");
+
+ int disposition = (cursor.getCount() <= windowSize)
+ ? CURSOR_DISPOSITION_PAGED
+ : CURSOR_DISPOSITION_REPAGED;
+
+ Cursor result = new CursorView(cursor, windowSize, disposition);
+ Bundle extras = result.getExtras();
+
+ // If the orig cursor reports a size larger than the window, suggest a better limit.
+ if (cursor.getCount() > windowSize) {
+ extras.putInt(EXTRA_REQUESTED_LIMIT, query.getLimit());
+ extras.putInt(EXTRA_SUGGESTED_LIMIT, (int) (windowSize * .85));
+ }
+
+ mStats.increment(Stats.EXTRA_PROVIDER_PAGED);
+ mStats.includeStats(extras);
+ return result;
+ }
+
+ private CursorWindow getWindow(Cursor cursor) {
+ if (cursor instanceof CursorWrapper) {
+ return getWindow(((CursorWrapper) cursor).getWrappedCursor());
+ }
+ if (cursor instanceof CrossProcessCursor) {
+ return ((CrossProcessCursor) cursor).getWindow();
+ }
+ // TODO: Any other ways we can find/access windows?
+ return null;
+ }
+
+ // Called in the foreground when the cursor is ready for the client.
+ @MainThread
+ private void onCursorReady(Query query, Cursor cursor) {
+ synchronized (mContentLock) {
+ mActiveQueries.remove(query);
+ }
+
+ query.getCallback().onCursorReady(query, cursor);
+ }
+
+ /**
+ * @return true if the cursor extras contains all of the signs of being paged.
+ * Technically we could also check SDK version since facilities for paging
+ * were added in SDK 26, but if it looks like a duck and talks like a duck
+ * itsa duck (especially if it helps with testing).
+ */
+ @WorkerThread
+ private boolean isProviderPaged(Cursor cursor) {
+ Bundle extras = cursor.getExtras();
+ extras = extras != null ? extras : Bundle.EMPTY;
+ String[] honoredArgs = extras.getStringArray(EXTRA_HONORED_ARGS);
+
+ return (extras.containsKey(EXTRA_TOTAL_COUNT)
+ && honoredArgs != null
+ && contains(honoredArgs, QUERY_ARG_OFFSET)
+ && contains(honoredArgs, QUERY_ARG_LIMIT));
+ }
+
+ private static <T> boolean contains(T[] array, T value) {
+ for (T element : array) {
+ if (value.equals(element)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * @return Bundle populated with existing extras (if any) as well as
+ * all usefule paging related extras.
+ */
+ static Bundle buildExtras(
+ @Nullable Bundle extras, int recordCount, @CursorDisposition int cursorDisposition) {
+
+ if (extras == null || extras == Bundle.EMPTY) {
+ extras = new Bundle();
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ extras = extras.deepCopy();
+ }
+ // else we modify cursor extras directly, cuz that's our only choice.
+
+ extras.putInt(CURSOR_DISPOSITION, cursorDisposition);
+ if (!extras.containsKey(EXTRA_TOTAL_COUNT)) {
+ extras.putInt(EXTRA_TOTAL_COUNT, recordCount);
+ }
+
+ if (!extras.containsKey(EXTRA_HONORED_ARGS)) {
+ extras.putStringArray(EXTRA_HONORED_ARGS, new String[]{
+ ContentPager.QUERY_ARG_OFFSET,
+ ContentPager.QUERY_ARG_LIMIT
+ });
+ }
+
+ return extras;
+ }
+
+ /**
+ * Builds a Bundle with offset and limit values suitable for with
+ * {@link #query(Uri, String[], Bundle, CancellationSignal, ContentCallback)}.
+ *
+ * @param offset must be greater than or equal to 0.
+ * @param limit can be any value. Only values greater than or equal to 0 are respected.
+ * If any other value results in no upper limit on results. Note that a well
+ * behaved client should probably supply a reasonable limit. See class
+ * documentation on how to select a limit.
+ *
+ * @return Mutable Bundle pre-populated with offset and limits vales.
+ */
+ public static @NonNull Bundle createArgs(int offset, int limit) {
+ checkArgument(offset >= 0);
+ Bundle args = new Bundle();
+ args.putInt(ContentPager.QUERY_ARG_OFFSET, offset);
+ args.putInt(ContentPager.QUERY_ARG_LIMIT, limit);
+ return args;
+ }
+
+ /**
+ * Callback by which a client receives results of a query.
+ */
+ public interface ContentCallback {
+ /**
+ * Called when paged cursor is ready. Null, if query failed.
+ * @param query The query having been executed.
+ * @param cursor the query results. Null if query couldn't be executed.
+ */
+ @MainThread
+ void onCursorReady(@NonNull Query query, @Nullable Cursor cursor);
+ }
+
+ /**
+ * Provides support for adding extras to a cursor. This is necessary
+ * as a cursor returning an extras Bundle that is either Bundle.EMPTY
+ * or null, cannot have information added to the cursor. On SDKs earlier
+ * than M, there is no facility to replace the Bundle.
+ */
+ private static final class CursorView extends CursorWrapper {
+ private final Bundle mExtras;
+ private final int mSize;
+
+ CursorView(Cursor delegate, int size, @CursorDisposition int disposition) {
+ super(delegate);
+ mSize = size;
+
+ mExtras = buildExtras(delegate.getExtras(), delegate.getCount(), disposition);
+ }
+
+ @Override
+ public int getCount() {
+ return mSize;
+ }
+
+ @Override
+ public Bundle getExtras() {
+ return mExtras;
+ }
+ }
+
+ /**
+ * LruCache holding at most {@code maxSize} cursors. Once evicted a cursor
+ * is immediately closed. The only cursor's held in this cache are
+ * unpaged results. For this purpose the cache is keyed by the URI,
+ * not the entire query. Cursors that are pre-paged by the provider
+ * are never cached.
+ */
+ private static final class CursorCache extends LruCache<Uri, Cursor> {
+ CursorCache(int maxSize) {
+ super(maxSize);
+ }
+
+ @WorkerThread
+ @Override
+ protected void entryRemoved(
+ boolean evicted, Uri uri, Cursor oldCursor, Cursor newCursor) {
+ if (!oldCursor.isClosed()) {
+ oldCursor.close();
+ }
+ }
+
+ /** @return true if an entry is present for the Uri. */
+ @WorkerThread
+ @GuardedBy("mContentLock")
+ boolean hasEntry(Uri uri) {
+ return get(uri) != null;
+ }
+ }
+
+ /**
+ * Implementations of this interface provide the mechanism
+ * for execution of queries off the UI thread.
+ */
+ public interface QueryRunner {
+ /**
+ * Execute a query.
+ * @param query The query that will be run. This value should be handed
+ * back to the callback when ready to run in the background.
+ * @param callback The callback that should be called to both execute
+ * the query (in the background) and to receive the results
+ * (in the foreground).
+ */
+ void query(@NonNull Query query, @NonNull Callback callback);
+
+ /**
+ * @param query The query in question.
+ * @return true if the query is already running.
+ */
+ boolean isRunning(@NonNull Query query);
+
+ /**
+ * Attempt to cancel a (presumably) running query.
+ * @param query The query in question.
+ */
+ void cancel(@NonNull Query query);
+
+ /**
+ * Callback that receives a cursor once a query as been executed on the Runner.
+ */
+ interface Callback {
+ /**
+ * Method called on background thread where actual query is executed. This is provided
+ * by ContentPager.
+ * @param query The query to be executed.
+ */
+ @Nullable Cursor runQueryInBackground(@NonNull Query query);
+
+ /**
+ * Called on main thread when query has completed.
+ * @param query The completed query.
+ * @param cursor The results in Cursor form. Null if not successfully completed.
+ */
+ void onQueryFinished(@NonNull Query query, @Nullable Cursor cursor);
+ }
+ }
+
+ static final class Stats {
+
+ /** Identifes the total number of queries handled by ContentPager. */
+ static final String EXTRA_TOTAL_QUERIES = "android-support:extra-total-queries";
+
+ /** Identifes the number of queries handled by content resolver. */
+ static final String EXTRA_RESOLVED_QUERIES = "android-support:extra-resolved-queries";
+
+ /** Identifes the number of pages produced by way of copying. */
+ static final String EXTRA_COMPAT_PAGED = "android-support:extra-compat-paged";
+
+ /** Identifes the number of pages produced directly by a page-supporting provider. */
+ static final String EXTRA_PROVIDER_PAGED = "android-support:extra-provider-paged";
+
+ // simple stats objects tracking paged result handling.
+ private int mTotalQueries;
+ private int mResolvedQueries;
+ private int mCompatPaged;
+ private int mProviderPaged;
+
+ private void increment(String prop) {
+ switch (prop) {
+ case EXTRA_TOTAL_QUERIES:
+ ++mTotalQueries;
+ break;
+
+ case EXTRA_RESOLVED_QUERIES:
+ ++mResolvedQueries;
+ break;
+
+ case EXTRA_COMPAT_PAGED:
+ ++mCompatPaged;
+ break;
+
+ case EXTRA_PROVIDER_PAGED:
+ ++mProviderPaged;
+ break;
+
+ default:
+ throw new IllegalArgumentException("Unknown property: " + prop);
+ }
+ }
+
+ private void reset() {
+ mTotalQueries = 0;
+ mResolvedQueries = 0;
+ mCompatPaged = 0;
+ mProviderPaged = 0;
+ }
+
+ void includeStats(Bundle bundle) {
+ bundle.putInt(EXTRA_TOTAL_QUERIES, mTotalQueries);
+ bundle.putInt(EXTRA_RESOLVED_QUERIES, mResolvedQueries);
+ bundle.putInt(EXTRA_COMPAT_PAGED, mCompatPaged);
+ bundle.putInt(EXTRA_PROVIDER_PAGED, mProviderPaged);
+ }
+ }
+}
diff --git a/content/src/android/support/content/InMemoryCursor.java b/content/src/android/support/content/InMemoryCursor.java
new file mode 100644
index 0000000..097709a
--- /dev/null
+++ b/content/src/android/support/content/InMemoryCursor.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static android.support.v4.util.Preconditions.checkArgument;
+
+import android.database.AbstractCursor;
+import android.database.ContentObserver;
+import android.database.Cursor;
+import android.database.CursorIndexOutOfBoundsException;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.RestrictTo;
+
+/**
+ * A {@link Cursor} implementation that stores information in-memory, in a type-safe fashion.
+ * Values are stored, when possible, as primitives to avoid the need for the autoboxing (as is
+ * necessary when working with MatrixCursor).
+ *
+ * <p>Unlike {@link android.database.MatrixCursor}, this cursor is not mutable at runtime.
+ * It exists solely as a destination for data copied by {@link ContentPager} from a source
+ * Cursor when a page is being synthesized. It is not anticipated at this time that this
+ * will be useful outside of this package. As such it is immutable once constructed.
+ * @hide
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+final class InMemoryCursor extends AbstractCursor {
+
+ private static final int NUM_TYPES = 5;
+
+ private final String[] mColumnNames;
+ private final int mRowCount;
+
+ // This is an index of column, by type. Maps back to position
+ // in native array.
+ // E.g. if we have columns typed like [string, int, int, string, int, int]
+ // the values in this index will be:
+ // mTypedColumnIndex[string][0] == 0
+ // mTypedColumnIndex[int][1] == 0
+ // mTypedColumnIndex[int][2] == 1
+ // mTypedColumnIndex[string][3] == 1
+ // mTypedColumnIndex[int][4] == 2
+ // mTypedColumnIndex[int][4] == 3
+ // This allows us to calculate the number of cells by type in a row
+ // which, in turn, allows us to calculate the size of the primitive storage arrays.
+ // We also use this information to lookup a particular typed value given
+ // the row offset and column offset.
+ private final int[][] mTypedColumnIndex;
+
+ // simple index to column to type.
+ private final int[] mColumnType;
+
+ // count of number of columns by type.
+ private final int[] mColumnTypeCount;
+
+ private final Bundle mExtras;
+
+ private final ObserverRelay mObserverRelay;
+
+ // Row data decomposed by type.
+ private long[] mLongs;
+ private double[] mDoubles;
+ private byte[][] mBlobs;
+ private String[] mStrings;
+
+ /**
+ * @param cursor source of data to copy. Ownership is reserved to the called, meaning
+ * we won't ever close it.
+ */
+ InMemoryCursor(Cursor cursor, int offset, int length, int disposition) {
+ checkArgument(offset < cursor.getCount());
+
+ // NOTE: The cursor could simply be saved to a field, but we choose to wrap
+ // in a dedicated relay class to avoid hanging directly onto a reference
+ // to the cursor...so future authors are not enticed to think there's
+ // a live link between the delegate cursor and this cursor.
+ mObserverRelay = new ObserverRelay(cursor);
+
+ mColumnNames = cursor.getColumnNames();
+ mRowCount = Math.min(length, cursor.getCount() - offset);
+ int numColumns = cursor.getColumnCount();
+
+ mExtras = ContentPager.buildExtras(cursor.getExtras(), cursor.getCount(), disposition);
+
+ mColumnType = new int[numColumns];
+ mTypedColumnIndex = new int[NUM_TYPES][numColumns];
+ mColumnTypeCount = new int[NUM_TYPES];
+
+ if (!cursor.moveToFirst()) {
+ throw new RuntimeException("Can't position cursor to first row.");
+ }
+
+ for (int col = 0; col < numColumns; col++) {
+ int type = cursor.getType(col);
+ mColumnType[col] = type;
+ mTypedColumnIndex[type][col] = mColumnTypeCount[type]++;
+ }
+
+ mLongs = new long[mRowCount * mColumnTypeCount[FIELD_TYPE_INTEGER]];
+ mDoubles = new double[mRowCount * mColumnTypeCount[FIELD_TYPE_FLOAT]];
+ mBlobs = new byte[mRowCount * mColumnTypeCount[FIELD_TYPE_BLOB]][];
+ mStrings = new String[mRowCount * mColumnTypeCount[FIELD_TYPE_STRING]];
+
+ for (int row = 0; row < mRowCount; row++) {
+ if (!cursor.moveToPosition(offset + row)) {
+ throw new RuntimeException("Unable to position cursor.");
+ }
+
+ // Now copy data from the row into primitive arrays.
+ for (int col = 0; col < mColumnType.length; col++) {
+ int type = mColumnType[col];
+ int position = getCellPosition(row, col, type);
+
+ switch(type) {
+ case FIELD_TYPE_NULL:
+ throw new UnsupportedOperationException("Not implemented.");
+ case FIELD_TYPE_INTEGER:
+ mLongs[position] = cursor.getLong(col);
+ break;
+ case FIELD_TYPE_FLOAT:
+ mDoubles[position] = cursor.getDouble(col);
+ break;
+ case FIELD_TYPE_BLOB:
+ mBlobs[position] = cursor.getBlob(col);
+ break;
+ case FIELD_TYPE_STRING:
+ mStrings[position] = cursor.getString(col);
+ break;
+ }
+ }
+ }
+ }
+
+ @Override
+ public Bundle getExtras() {
+ return mExtras;
+ }
+
+ // Returns the "cell" position for a specific row+column+type.
+ private int getCellPosition(int row, int col, int type) {
+ return (row * mColumnTypeCount[type]) + mTypedColumnIndex[type][col];
+ }
+
+ @Override
+ public int getCount() {
+ return mRowCount;
+ }
+
+ @Override
+ public String[] getColumnNames() {
+ return mColumnNames;
+ }
+
+ @Override
+ public short getShort(int column) {
+ checkValidColumn(column);
+ checkValidPosition();
+ return (short) mLongs[getCellPosition(getPosition(), column, FIELD_TYPE_INTEGER)];
+ }
+
+ @Override
+ public int getInt(int column) {
+ checkValidColumn(column);
+ checkValidPosition();
+ return (int) mLongs[getCellPosition(getPosition(), column, FIELD_TYPE_INTEGER)];
+ }
+
+ @Override
+ public long getLong(int column) {
+ checkValidColumn(column);
+ checkValidPosition();
+ return mLongs[getCellPosition(getPosition(), column, FIELD_TYPE_INTEGER)];
+ }
+
+ @Override
+ public float getFloat(int column) {
+ checkValidColumn(column);
+ checkValidPosition();
+ return (float) mDoubles[getCellPosition(getPosition(), column, FIELD_TYPE_FLOAT)];
+ }
+
+ @Override
+ public double getDouble(int column) {
+ checkValidColumn(column);
+ checkValidPosition();
+ return mDoubles[getCellPosition(getPosition(), column, FIELD_TYPE_FLOAT)];
+ }
+
+ @Override
+ public byte[] getBlob(int column) {
+ checkValidColumn(column);
+ checkValidPosition();
+ return mBlobs[getCellPosition(getPosition(), column, FIELD_TYPE_BLOB)];
+ }
+
+ @Override
+ public String getString(int column) {
+ checkValidColumn(column);
+ checkValidPosition();
+ return mStrings[getCellPosition(getPosition(), column, FIELD_TYPE_STRING)];
+ }
+
+ @Override
+ public int getType(int column) {
+ checkValidColumn(column);
+ return mColumnType[column];
+ }
+
+ @Override
+ public boolean isNull(int column) {
+ checkValidColumn(column);
+ switch (mColumnType[column]) {
+ case FIELD_TYPE_STRING:
+ return getString(column) != null;
+ case FIELD_TYPE_BLOB:
+ return getBlob(column) != null;
+ default:
+ return false;
+ }
+ }
+
+ private void checkValidPosition() {
+ if (getPosition() < 0) {
+ throw new CursorIndexOutOfBoundsException("Before first row.");
+ }
+ if (getPosition() >= mRowCount) {
+ throw new CursorIndexOutOfBoundsException("After last row.");
+ }
+ }
+
+ private void checkValidColumn(int column) {
+ if (column < 0 || column >= mColumnNames.length) {
+ throw new CursorIndexOutOfBoundsException(
+ "Requested column: " + column + ", # of columns: " + mColumnNames.length);
+ }
+ }
+
+ @Override
+ public void registerContentObserver(ContentObserver observer) {
+ mObserverRelay.registerContentObserver(observer);
+ }
+
+ @Override
+ public void unregisterContentObserver(ContentObserver observer) {
+ mObserverRelay.unregisterContentObserver(observer);
+ }
+
+ private static class ObserverRelay extends ContentObserver {
+ private final Cursor mCursor;
+
+ ObserverRelay(Cursor cursor) {
+ super(new Handler(Looper.getMainLooper()));
+ mCursor = cursor;
+ }
+
+ void registerContentObserver(ContentObserver observer) {
+ mCursor.registerContentObserver(observer);
+ }
+
+ void unregisterContentObserver(ContentObserver observer) {
+ mCursor.unregisterContentObserver(observer);
+ }
+ }
+}
diff --git a/content/src/android/support/content/LoaderQueryRunner.java b/content/src/android/support/content/LoaderQueryRunner.java
new file mode 100644
index 0000000..800307b
--- /dev/null
+++ b/content/src/android/support/content/LoaderQueryRunner.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static android.support.v4.util.Preconditions.checkArgument;
+
+import android.app.LoaderManager;
+import android.app.LoaderManager.LoaderCallbacks;
+import android.content.Context;
+import android.content.Loader;
+import android.database.Cursor;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.util.Log;
+
+/**
+ * A {@link ContentPager.QueryRunner} that executes queries using a {@link LoaderManager}.
+ * Use this when preparing {@link ContentPager} to run in an Activity or Fragment scope.
+ */
+public final class LoaderQueryRunner implements ContentPager.QueryRunner {
+
+ private static final boolean DEBUG = false;
+ private static final String TAG = "LoaderQueryRunner";
+ private static final String CONTENT_URI_KEY = "contentUri";
+
+ private final Context mContext;
+ private final LoaderManager mLoaderMgr;
+
+ public LoaderQueryRunner(@NonNull Context context, @NonNull LoaderManager loaderMgr) {
+ mContext = context;
+ mLoaderMgr = loaderMgr;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked") // feels spurious. But can't commit line :80 w/o this.
+ public void query(final @NonNull Query query, @NonNull final Callback callback) {
+ if (DEBUG) Log.d(TAG, "Handling query: " + query);
+
+ LoaderCallbacks callbacks = new LoaderCallbacks<Cursor>() {
+ @Override
+ public Loader<Cursor> onCreateLoader(final int id, final Bundle args) {
+ if (DEBUG) Log.i(TAG, "Loading results for query: " + query);
+ checkArgument(id == query.getId(), "Id doesn't match query id.");
+
+ return new android.content.CursorLoader(mContext) {
+ @Override
+ public Cursor loadInBackground() {
+ return callback.runQueryInBackground(query);
+ }
+ };
+ }
+
+ @Override
+ public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
+ if (DEBUG) Log.i(TAG, "Finished loading: " + query);
+ mLoaderMgr.destroyLoader(query.getId());
+ callback.onQueryFinished(query, cursor);
+ }
+
+ @Override
+ public void onLoaderReset(Loader<Cursor> loader) {
+ if (DEBUG) Log.w(TAG, "Ignoring loader reset for query: " + query);
+ }
+ };
+
+ mLoaderMgr.restartLoader(query.getId(), null, callbacks);
+ }
+
+ @Override
+ public boolean isRunning(@NonNull Query query) {
+ Loader<Cursor> loader = mLoaderMgr.getLoader(query.getId());
+ return loader != null && loader.isStarted();
+ // Hmm, when exactly would the loader not be started? Does it imply that it will
+ // be starting at some point?
+ }
+
+ @Override
+ public void cancel(@NonNull Query query) {
+ mLoaderMgr.destroyLoader(query.getId());
+ }
+}
diff --git a/content/src/android/support/content/Query.java b/content/src/android/support/content/Query.java
new file mode 100644
index 0000000..a5d1ee5
--- /dev/null
+++ b/content/src/android/support/content/Query.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static android.support.v4.util.Preconditions.checkArgument;
+
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+import android.util.Log;
+
+import java.util.Arrays;
+
+/**
+ * Encapsulates information related to calling {@link ContentResolver#query},
+ * including the logic determining the best query method to call.
+ */
+public final class Query {
+
+ private static final boolean DEBUG = true;
+ private static final String TAG = "Query";
+
+ private final Uri mUri;
+ private final @Nullable String[] mProjection;
+ private final Bundle mQueryArgs;
+
+ private final int mId;
+ private final int mOffset;
+ private final int mLimit;
+
+ private final CancellationSignal mCancellationSignal;
+ private final ContentPager.ContentCallback mCallback;
+
+ @VisibleForTesting
+ Query(
+ @NonNull Uri uri,
+ @Nullable String[] projection,
+ @NonNull Bundle args,
+ @Nullable CancellationSignal cancellationSignal,
+ @NonNull ContentPager.ContentCallback callback) {
+
+ checkArgument(uri != null);
+ checkArgument(args != null);
+ checkArgument(callback != null);
+
+ this.mUri = uri;
+ this.mProjection = projection;
+ this.mQueryArgs = args;
+ this.mCancellationSignal = cancellationSignal;
+ this.mCallback = callback;
+
+ this.mOffset = args.getInt(ContentPager.QUERY_ARG_OFFSET, -1);
+ this.mLimit = args.getInt(ContentPager.QUERY_ARG_LIMIT, -1);
+
+ // NOTE: We omit mProjection and other details from ID. If a client wishes
+ // to request a page with a different mProjection or sorting, they should
+ // wait for first request to finish. Same goes for mCallback.
+ this.mId = uri.hashCode() << 16 | (mOffset | (mLimit << 8));
+
+ checkArgument(mOffset >= 0); // mOffset must be set, mLimit is optional.
+ }
+
+ /**
+ * @return the id for this query. Derived from Uri as well as paging arguments.
+ */
+ public int getId() {
+ return mId;
+ }
+
+ /**
+ * @return the Uri.
+ */
+ public @NonNull Uri getUri() {
+ return mUri;
+ }
+
+ /**
+ * @return the offset.
+ */
+ public int getOffset() {
+ return mOffset;
+ }
+
+ /**
+ * @return the limit.
+ */
+ public int getLimit() {
+ return mLimit;
+ }
+
+ @NonNull ContentPager.ContentCallback getCallback() {
+ return mCallback;
+ }
+
+ @Nullable Cursor run(@NonNull ContentResolver resolver) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ return resolver.query(
+ mUri,
+ mProjection,
+ mQueryArgs,
+ mCancellationSignal);
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ if (DEBUG) Log.d(TAG, "Falling back to pre-O query method.");
+ return resolver.query(
+ mUri,
+ mProjection,
+ null,
+ null,
+ null,
+ mCancellationSignal);
+ }
+
+ if (DEBUG) Log.d(TAG, "Falling back to pre-jellybean query method.");
+ return resolver.query(
+ mUri,
+ mProjection,
+ null,
+ null,
+ null);
+ }
+
+ void cancel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
+ if (mCancellationSignal != null && !mCancellationSignal.isCanceled()) {
+ if (DEBUG) {
+ Log.d(TAG, "Attemping to cancel query provider processings: " + this);
+ }
+ mCancellationSignal.cancel();
+ }
+ }
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+
+ if (this == obj) {
+ return true;
+ }
+
+ if (!(obj instanceof Query)) {
+ return false;
+ }
+
+ Query other = (Query) obj;
+
+ return mId == other.mId
+ && mUri.equals(other.mUri)
+ && mOffset == other.mOffset
+ && mLimit == other.mLimit;
+ }
+
+ @Override
+ public int hashCode() {
+ return getId();
+ }
+
+ @Override
+ public String toString() {
+ return "Query{"
+ + "id:" + mId
+ + " uri:" + mUri
+ + " projection:" + Arrays.toString(mProjection)
+ + " offset:" + mOffset
+ + " limit:" + mLimit
+ + " cancellationSignal:" + mCancellationSignal
+ + " callback:" + mCallback
+ + "}";
+ }
+}
diff --git a/content/tests/AndroidManifest.xml b/content/tests/AndroidManifest.xml
new file mode 100644
index 0000000..4a815c4
--- /dev/null
+++ b/content/tests/AndroidManifest.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ ~ Copyright (C) 2017 The Android Open Source Project
+ ~
+ ~ Licensed under the Apache License, Version 2.0 (the "License");
+ ~ you may not use this file except in compliance with the License.
+ ~ You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="android.support.content.test">
+ <uses-sdk android:minSdkVersion="14" />
+
+ <application android:supportsRtl="true">
+ <activity android:name="android.support.content.TestActivity" />
+
+ <!-- Must be run in-process for some of the test instrumentation to work correctly -->
+ <provider android:name="android.support.content.TestContentProvider"
+ android:authorities="android.support.content.test.testpagingprovider"
+ android:multiprocess="false" />
+ </application>
+</manifest>
diff --git a/content/tests/NO_DOCS b/content/tests/NO_DOCS
new file mode 100644
index 0000000..4dad694
--- /dev/null
+++ b/content/tests/NO_DOCS
@@ -0,0 +1,17 @@
+# Copyright (C) 2017 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+Having this file, named NO_DOCS, in a directory will prevent
+Android javadocs from being generated for java files under
+the directory. This is especially useful for test projects.
diff --git a/content/tests/java/android/support/content/ContentPagerTest.java b/content/tests/java/android/support/content/ContentPagerTest.java
new file mode 100644
index 0000000..fd682c6
--- /dev/null
+++ b/content/tests/java/android/support/content/ContentPagerTest.java
@@ -0,0 +1,431 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static android.support.content.ContentPager.createArgs;
+import static android.support.content.TestContentProvider.PAGED_URI;
+import static android.support.content.TestContentProvider.PAGED_WINDOWED_URI;
+import static android.support.content.TestContentProvider.UNPAGED_URI;
+
+import static junit.framework.Assert.assertEquals;
+import static junit.framework.Assert.assertNotNull;
+import static junit.framework.Assert.assertTrue;
+
+import static org.junit.Assert.assertFalse;
+
+import android.app.Activity;
+import android.content.ContentResolver;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.support.annotation.Nullable;
+import android.support.content.ContentPager.ContentCallback;
+import android.support.test.filters.MediumTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class ContentPagerTest {
+
+ private ContentResolver mResolver;
+ private TestQueryRunner mRunner;
+ private TestContentCallback mCallback;
+ private ContentPager mPager;
+
+ @Rule
+ public ActivityTestRule<Activity> mActivityRule = new ActivityTestRule(TestActivity.class);
+
+ @Before
+ public void setUp() {
+ mRunner = new TestQueryRunner();
+ mResolver = mActivityRule.getActivity().getContentResolver();
+ mCallback = new TestContentCallback();
+ mPager = new ContentPager(mResolver, mRunner);
+ }
+
+ @Test
+ public void testRelaysProviderPagedResults() throws Throwable {
+ int offset = 0;
+ int limit = 10;
+
+ // NOTE: Paging on Android O is accompolished by way of ContentResolver#query that
+ // accepts a Bundle. That means on older platforms we either have to cook up
+ // a special way of paging that doesn't use the bundle (that's what we do here)
+ // or we simply skip testing how we deal with provider paged results.
+ Uri uriWithTestPagingData = TestContentProvider.forcePagingSpec(PAGED_URI, offset, limit);
+
+ Query query = mPager.query(
+ uriWithTestPagingData,
+ null,
+ createArgs(offset, limit),
+ null,
+ mCallback);
+
+ mCallback.assertNumPagesLoaded(1);
+ mCallback.assertPageLoaded(query);
+ Cursor cursor = mCallback.getCursor(query);
+ Bundle extras = cursor.getExtras();
+
+ assertExpectedRecords(cursor, query.getOffset());
+
+ assertEquals(
+ ContentPager.CURSOR_DISPOSITION_PAGED,
+ extras.getInt(ContentPager.CURSOR_DISPOSITION, -1));
+
+ assertEquals(
+ TestContentProvider.DEFAULT_RECORD_COUNT,
+ extras.getInt(ContentResolver.EXTRA_TOTAL_COUNT));
+
+ assertHasHonoredArgs(
+ extras,
+ ContentResolver.QUERY_ARG_LIMIT,
+ ContentResolver.QUERY_ARG_OFFSET);
+
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_PROVIDER_PAGED));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_RESOLVED_QUERIES));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_TOTAL_QUERIES));
+ }
+
+ @Test
+ public void testLimitsPagedResultsToWindowSize() throws Throwable {
+ int offset = 0;
+ int limit = 10;
+
+ // NOTE: Paging on Android O is accompolished by way of ContentResolver#query that
+ // accepts a Bundle. That means on older platforms we either have to cook up
+ // a special way of paging that doesn't use the bundle (that's what we do here)
+ // or we simply skip testing how we deal with provider paged results.
+ Uri uriWithTestPagingData = TestContentProvider.forcePagingSpec(
+ PAGED_WINDOWED_URI, offset, limit);
+
+ Query query = mPager.query(
+ uriWithTestPagingData,
+ null,
+ createArgs(offset, limit),
+ null,
+ mCallback);
+
+ mCallback.assertNumPagesLoaded(1);
+ mCallback.assertPageLoaded(query);
+ Cursor cursor = mCallback.getCursor(query);
+ Bundle extras = cursor.getExtras();
+
+ assertExpectedRecords(cursor, query.getOffset());
+
+ assertEquals(
+ ContentPager.CURSOR_DISPOSITION_REPAGED,
+ extras.getInt(ContentPager.CURSOR_DISPOSITION, -1));
+
+ assertEquals(
+ TestContentProvider.DEFAULT_RECORD_COUNT,
+ extras.getInt(ContentResolver.EXTRA_TOTAL_COUNT));
+
+
+ assertEquals(limit, extras.getInt(ContentPager.EXTRA_REQUESTED_LIMIT));
+
+ assertEquals(7, extras.getInt(ContentPager.EXTRA_SUGGESTED_LIMIT));
+
+ assertHasHonoredArgs(
+ extras,
+ ContentResolver.QUERY_ARG_LIMIT,
+ ContentResolver.QUERY_ARG_OFFSET);
+
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_PROVIDER_PAGED));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_RESOLVED_QUERIES));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_TOTAL_QUERIES));
+ }
+
+ @Test
+ public void testAdaptsUnpagedToPaged() throws Throwable {
+ Query query = mPager.query(
+ UNPAGED_URI,
+ null,
+ createArgs(0, 10),
+ null,
+ mCallback);
+
+ mCallback.assertNumPagesLoaded(1);
+ mCallback.assertPageLoaded(query);
+ Cursor cursor = mCallback.getCursor(query);
+ Bundle extras = cursor.getExtras();
+
+ assertExpectedRecords(cursor, query.getOffset());
+
+ assertEquals(
+ ContentPager.CURSOR_DISPOSITION_COPIED,
+ extras.getInt(ContentPager.CURSOR_DISPOSITION));
+
+ assertEquals(
+ TestContentProvider.DEFAULT_RECORD_COUNT,
+ extras.getInt(ContentResolver.EXTRA_TOTAL_COUNT));
+
+ assertHasHonoredArgs(
+ extras,
+ ContentResolver.QUERY_ARG_LIMIT,
+ ContentResolver.QUERY_ARG_OFFSET);
+
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_COMPAT_PAGED));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_RESOLVED_QUERIES));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_TOTAL_QUERIES));
+ }
+
+ @Test
+ public void testCachesUnpagedCursor() throws Throwable {
+ mPager.query(
+ UNPAGED_URI,
+ null,
+ createArgs(0, 10),
+ null,
+ mCallback);
+
+ mPager.query(
+ UNPAGED_URI,
+ null,
+ createArgs(10, 10),
+ null,
+ mCallback);
+
+ // Rerun the same query as the first...extra exercise to ensure we can return
+ // to previously loaded results.
+ Query query = mPager.query(
+ UNPAGED_URI,
+ null,
+ createArgs(0, 10),
+ null,
+ mCallback);
+
+ mCallback.assertNumPagesLoaded(3);
+ Cursor cursor = mCallback.getCursor(query);
+ Bundle extras = cursor.getExtras();
+
+ assertEquals(
+ 3,
+ extras.getInt(ContentPager.Stats.EXTRA_COMPAT_PAGED));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_RESOLVED_QUERIES));
+ assertEquals(
+ 3,
+ extras.getInt(ContentPager.Stats.EXTRA_TOTAL_QUERIES));
+ }
+
+ @Test
+ public void testWrapsCursorsThatJustHappenToFitInPageRange() throws Throwable {
+
+ // NOTE: Paging on Android O is accompolished by way of ContentResolver#query that
+ // accepts a Bundle. That means on older platforms we either have to cook up
+ // a special way of paging that doesn't use the bundle (that's what we do here)
+ // or we simply skip testing how we deal with provider paged results.
+ Uri uri = TestContentProvider.forceRecordCount(UNPAGED_URI, 22);
+
+ Query query = mPager.query(
+ uri,
+ null,
+ createArgs(0, 44),
+ null,
+ mCallback);
+
+ mCallback.assertNumPagesLoaded(1);
+ // mCallback.assertPageLoaded(pageId);
+ mCallback.assertPageLoaded(query);
+ Cursor cursor = mCallback.getCursor(query);
+ Bundle extras = cursor.getExtras();
+
+ assertExpectedRecords(cursor, query.getOffset());
+
+ assertEquals(
+ ContentPager.CURSOR_DISPOSITION_WRAPPED,
+ extras.getInt(ContentPager.CURSOR_DISPOSITION));
+
+ assertEquals(
+ 22,
+ extras.getInt(ContentResolver.EXTRA_TOTAL_COUNT));
+
+ assertHasHonoredArgs(
+ extras,
+ ContentResolver.QUERY_ARG_LIMIT,
+ ContentResolver.QUERY_ARG_OFFSET);
+
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_COMPAT_PAGED));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_RESOLVED_QUERIES));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_TOTAL_QUERIES));
+ }
+
+ @Test
+ public void testCorrectlyCopiesRecords_EndOfResults() throws Throwable {
+ // finally, check the last page.
+ int limit = 100;
+ // This will be the size of the last page. Should be 67.
+ int leftOvers = TestContentProvider.DEFAULT_RECORD_COUNT % limit;
+ int offset = TestContentProvider.DEFAULT_RECORD_COUNT - leftOvers;
+
+ Query query = mPager.query(
+ UNPAGED_URI,
+ null,
+ createArgs(offset, limit),
+ null,
+ mCallback);
+
+ mCallback.assertNumPagesLoaded(1);
+ mCallback.assertPageLoaded(query);
+ Cursor cursor = mCallback.getCursor(query);
+ assertEquals(leftOvers, cursor.getCount());
+ Bundle extras = cursor.getExtras();
+
+ assertExpectedRecords(cursor, query.getOffset());
+
+ assertEquals(
+ ContentPager.CURSOR_DISPOSITION_COPIED,
+ extras.getInt(ContentPager.CURSOR_DISPOSITION));
+
+ assertHasHonoredArgs(
+ extras,
+ ContentResolver.QUERY_ARG_LIMIT,
+ ContentResolver.QUERY_ARG_OFFSET);
+
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_COMPAT_PAGED));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_RESOLVED_QUERIES));
+ assertEquals(
+ 1,
+ extras.getInt(ContentPager.Stats.EXTRA_TOTAL_QUERIES));
+ }
+
+ @Test
+ public void testCancelsRunningQueriesOnReset() throws Throwable {
+ mRunner.runQuery = false;
+ Query query = mPager.query(
+ UNPAGED_URI,
+ null,
+ createArgs(0, 10),
+ null,
+ mCallback);
+
+ assertTrue(mRunner.isRunning(query));
+
+ mPager.reset();
+
+ assertFalse(mRunner.isRunning(query));
+ }
+
+ @Test
+ public void testRelaysContentChangeNotificationsOnPagedCursors() throws Throwable {
+
+ TestContentObserver observer = new TestContentObserver(
+ new Handler(Looper.getMainLooper()));
+ observer.expectNotifications(1);
+
+ Query query = mPager.query(
+ UNPAGED_URI,
+ null,
+ createArgs(10, 99),
+ null,
+ mCallback);
+
+ Cursor cursor = mCallback.getCursor(query);
+ cursor.registerContentObserver(observer);
+
+ mResolver.notifyChange(UNPAGED_URI, null);
+
+ assertTrue(observer.mNotifiedLatch.await(1000, TimeUnit.MILLISECONDS));
+ }
+
+ private void assertExpectedRecords(Cursor cursor, int offset) {
+ for (int row = 0; row < cursor.getCount(); row++) {
+ assertTrue(cursor.moveToPosition(row));
+ int unpagedRow = offset + row;
+ for (int column = 0; column < cursor.getColumnCount(); column++) {
+ TestContentProvider.assertExpectedCellValue(cursor, unpagedRow, column);
+ }
+ }
+ }
+
+ private static void assertHasHonoredArgs(Bundle extras, String... expectedArgs) {
+ List<String> honored = Arrays.asList(
+ extras.getStringArray(ContentResolver.EXTRA_HONORED_ARGS));
+
+ for (String arg : expectedArgs) {
+ assertTrue(honored.contains(arg));
+ }
+ }
+
+ private static final class TestContentCallback implements ContentCallback {
+
+ private int mPagesLoaded;
+ private Map<Query, Cursor> mCursors = new HashMap<>();
+
+ @Override
+ public void onCursorReady(Query query, Cursor cursor) {
+ mPagesLoaded++;
+ mCursors.put(query, cursor);
+ }
+
+ private void assertPageLoaded(Query query) {
+ assertTrue(mCursors.containsKey(query));
+ assertNotNull(mCursors.get(query));
+ }
+
+ private void assertNumPagesLoaded(int expected) {
+ assertEquals(expected, mPagesLoaded);
+ }
+
+ private @Nullable Cursor getCursor(Query query) {
+ return mCursors.get(query);
+ }
+ }
+}
diff --git a/content/tests/java/android/support/content/LoaderQueryRunnerTest.java b/content/tests/java/android/support/content/LoaderQueryRunnerTest.java
new file mode 100644
index 0000000..a0ddd9a
--- /dev/null
+++ b/content/tests/java/android/support/content/LoaderQueryRunnerTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static android.support.content.ContentPager.createArgs;
+import static android.support.content.TestContentProvider.UNPAGED_URI;
+
+import android.app.Activity;
+import android.database.Cursor;
+import android.os.Looper;
+import android.support.content.ContentPager.ContentCallback;
+import android.support.content.ContentPager.QueryRunner;
+import android.support.test.filters.MediumTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class LoaderQueryRunnerTest {
+
+ @Rule
+ public ActivityTestRule<Activity> mActivityRule = new ActivityTestRule(TestActivity.class);
+
+ private Activity mActivity;
+ private QueryRunner mRunner;
+ private TestQueryCallback mCallback;
+
+ @Before
+ public void setUp() {
+ mActivity = mActivityRule.getActivity();
+ mRunner = new LoaderQueryRunner(mActivity, mActivity.getLoaderManager());
+ mCallback = new TestQueryCallback();
+ }
+
+ @Test
+ public void testRunsQuery() throws Throwable {
+ int offset = 0;
+ int limit = 10;
+
+ // Note: For some when running this test via tradefed (vs gradle) this
+ // looper setup code doesn't work when run *in setUp*. Works fine in Gradle.
+ // So this test fails when run on treehugger or run via tradefed.
+ // To work around that issue we prepare the looper here.
+ //
+ // "Wait!" you say, why do you need to prepare a looper? We're using
+ // a CursorLoader under the hoods which deep down creates an handler
+ // to listen for content changes. That's not critical to our test
+ // since we're waiting on results w/ latches, but we need to avoid the error.
+ if (Looper.myLooper() == null) {
+ Looper.prepare();
+ }
+
+ ContentCallback dummyContentCallback = new ContentCallback() {
+ @Override
+ public void onCursorReady(Query query, Cursor cursor) {
+ // Nothing to see here. Move along.
+ }
+ };
+ Query query = new Query(
+ UNPAGED_URI,
+ null,
+ createArgs(offset, limit),
+ null,
+ dummyContentCallback);
+
+ mCallback.reset(1);
+ mRunner.query(query, mCallback);
+
+ mCallback.waitFor(10);
+ mCallback.assertQueried(query.getId());
+ mCallback.assertReceivedContent(UNPAGED_URI, query.getId());
+ }
+}
diff --git a/content/tests/java/android/support/content/QueryTest.java b/content/tests/java/android/support/content/QueryTest.java
new file mode 100644
index 0000000..8943874
--- /dev/null
+++ b/content/tests/java/android/support/content/QueryTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static junit.framework.Assert.assertTrue;
+
+import android.database.Cursor;
+import android.net.Uri;
+import android.support.content.ContentPager.ContentCallback;
+import android.support.test.filters.MediumTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@MediumTest
+@RunWith(AndroidJUnit4.class)
+public class QueryTest {
+
+ private static final Uri URI_HAMMY = Uri.parse("content://hammy");
+ private static final Uri URI_CHEESY = Uri.parse("content://cheesy");
+
+ private static final ContentCallback sCallback = new ContentCallback() {
+ @Override
+ public void onCursorReady(Query query, Cursor cursor) {
+ // nothing to see here. Move along.
+ }
+ };
+
+ @Test
+ public void testDistinctIdsForDifferentUris() throws Throwable {
+ Query queryA = new Query(
+ URI_HAMMY,
+ null,
+ ContentPager.createArgs(0, 10),
+ null,
+ sCallback);
+
+ Query queryB = new Query(
+ URI_CHEESY,
+ null,
+ ContentPager.createArgs(0, 10),
+ null,
+ sCallback);
+
+ assertDistinctIds(queryA, queryB);
+ }
+
+ @Test
+ public void testDistinctIdsForDifferentPagingArgs() throws Throwable {
+ Query queryA = new Query(
+ URI_HAMMY,
+ null,
+ ContentPager.createArgs(0, 10),
+ null,
+ sCallback);
+
+ Query queryB = new Query(
+ URI_HAMMY,
+ null,
+ ContentPager.createArgs(10, 10),
+ null,
+ sCallback);
+
+ assertDistinctIds(queryA, queryB);
+ }
+
+ private void assertDistinctIds(Query a, Query b) {
+ String msg = String.format(
+ "id A (%d) and id B (%d) are equal, but should not be.",
+ a.getId(),
+ b.getId());
+ assertTrue(msg, a.getId() != b.getId());
+ }
+}
diff --git a/content/tests/java/android/support/content/TestActivity.java b/content/tests/java/android/support/content/TestActivity.java
new file mode 100644
index 0000000..dc85c44
--- /dev/null
+++ b/content/tests/java/android/support/content/TestActivity.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import android.app.Activity;
+
+/**
+ * TestActivity.
+ */
+public class TestActivity extends Activity {
+}
diff --git a/content/tests/java/android/support/content/TestContentObserver.java b/content/tests/java/android/support/content/TestContentObserver.java
new file mode 100644
index 0000000..be0004b
--- /dev/null
+++ b/content/tests/java/android/support/content/TestContentObserver.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import android.database.ContentObserver;
+import android.net.Uri;
+import android.os.Handler;
+import android.support.annotation.VisibleForTesting;
+
+import java.util.concurrent.CountDownLatch;
+
+final class TestContentObserver extends ContentObserver {
+
+ @VisibleForTesting
+ public CountDownLatch mNotifiedLatch;
+
+ TestContentObserver(Handler handler) {
+ super(handler);
+ }
+
+ void expectNotifications(int count) {
+ mNotifiedLatch = new CountDownLatch(count);
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ mNotifiedLatch.countDown();
+ }
+
+ @Override
+ public void onChange(boolean selfChange, Uri uri) {
+ mNotifiedLatch.countDown();
+ }
+}
diff --git a/content/tests/java/android/support/content/TestContentProvider.java b/content/tests/java/android/support/content/TestContentProvider.java
new file mode 100644
index 0000000..e474240
--- /dev/null
+++ b/content/tests/java/android/support/content/TestContentProvider.java
@@ -0,0 +1,396 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static junit.framework.Assert.assertEquals;
+
+import android.content.ContentProvider;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.database.AbstractWindowedCursor;
+import android.database.Cursor;
+import android.database.CursorWindow;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+
+/**
+ * A stub data paging provider used for testing of paging support.
+ * Ignores client supplied projections.
+ */
+public final class TestContentProvider extends ContentProvider {
+
+ public static final String AUTHORITY = "android.support.content.test.testpagingprovider";
+
+ public static final String UNPAGED_PATH = "/un-paged";
+ public static final String PAGED_PATH = "/paged";
+ public static final String PAGED_WINDOWED_PATH = PAGED_PATH + "/windowed";
+
+ public static final Uri UNPAGED_URI = new Uri.Builder()
+ .scheme("content")
+ .authority(AUTHORITY)
+ .path(UNPAGED_PATH)
+ .build();
+ public static final Uri PAGED_URI = new Uri.Builder()
+ .scheme("content")
+ .authority(AUTHORITY)
+ .path(PAGED_PATH)
+ .build();
+ public static final Uri PAGED_WINDOWED_URI = new Uri.Builder()
+ .scheme("content")
+ .authority(AUTHORITY)
+ .path(PAGED_WINDOWED_PATH)
+ .build();
+
+ public static final String COLUMN_POS = "ColumnPos";
+ public static final String COLUMN_A = "ColumnA";
+ public static final String COLUMN_B = "ColumnB";
+ public static final String COLUMN_C = "ColumnC";
+ public static final String COLUMN_D = "ColumnD";
+ public static final String[] PROJECTION = {
+ COLUMN_POS,
+ COLUMN_A,
+ COLUMN_B,
+ COLUMN_C,
+ COLUMN_D
+ };
+
+ @VisibleForTesting
+ public static final String RECORD_COUNT = "test-record-count";
+
+ @VisibleForTesting
+ public static final int DEFAULT_RECORD_COUNT = 567;
+
+ private static final String TAG = "TestPagingProvider";
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Override
+ public Cursor query(
+ Uri uri, @Nullable String[] projection, String selection, String[] selectionArgs,
+ String sortOrder) {
+ return query(uri, projection, null, null);
+ }
+
+ @Override
+ public Cursor query(Uri uri, String[] ignored, Bundle queryArgs,
+ CancellationSignal cancellationSignal) {
+
+ queryArgs = queryArgs != null ? queryArgs : Bundle.EMPTY;
+
+ int recordCount = getIntValue(RECORD_COUNT, queryArgs, uri, DEFAULT_RECORD_COUNT);
+ if (recordCount < 0) {
+ throw new RuntimeException("Recordset size must be >= 0");
+ }
+
+ Cursor cursor = null;
+ switch (uri.getPath()) {
+ case UNPAGED_PATH:
+ cursor = buildUnpagedResults(recordCount);
+ break;
+ case PAGED_PATH:
+ cursor = buildPagedResults(uri, queryArgs, recordCount);
+ break;
+ case PAGED_WINDOWED_PATH:
+ cursor = buildPagedWindowedResults(uri, queryArgs, recordCount);
+ break;
+ default:
+ throw new IllegalArgumentException("Unrecognized path: " + uri.getPath());
+ }
+
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+
+ return cursor;
+ }
+
+ /**
+ * Return a int value specified in Bundle key, Uri query arg, or fallback default value.
+ */
+ private static int getIntValue(String key, Bundle queryArgs, Uri uri, int defaultValue) {
+ int value = queryArgs.getInt(key, Integer.MIN_VALUE);
+ if (value != Integer.MIN_VALUE) {
+ return value;
+ }
+
+ @Nullable String argValue = uri.getQueryParameter(key);
+ if (argValue != null) {
+ try {
+ return Integer.parseInt(argValue);
+ } catch (NumberFormatException ignored) {
+ }
+ }
+
+ return defaultValue;
+ }
+
+ private MatrixCursor buildPagedResults(Uri uri, Bundle queryArgs, int recordsetSize) {
+ int offset = getIntValue(ContentResolver.QUERY_ARG_OFFSET, queryArgs, uri, 0);
+ int limit = getIntValue(ContentResolver.QUERY_ARG_LIMIT, queryArgs, uri, recordsetSize);
+
+ MatrixCursor c = createInMemoryCursor();
+ Bundle extras = c.getExtras();
+
+ // Calculate the number of items to include in the cursor.
+ int numItems = constrain(recordsetSize - offset, 0, limit);
+
+ // Build the paged result set.
+ for (int i = offset; i < offset + numItems; i++) {
+ fillRow(c.newRow(), i);
+ }
+
+ extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, new String[] {
+ ContentResolver.QUERY_ARG_OFFSET,
+ ContentResolver.QUERY_ARG_LIMIT
+ });
+ extras.putInt(ContentResolver.EXTRA_TOTAL_COUNT, recordsetSize);
+ return c;
+ }
+
+ private AbstractWindowedCursor buildPagedWindowedResults(
+ Uri uri, Bundle queryArgs, int recordsetSize) {
+ int offset = getIntValue(ContentResolver.QUERY_ARG_OFFSET, queryArgs, uri, 0);
+ int limit = getIntValue(ContentResolver.QUERY_ARG_LIMIT, queryArgs, uri, recordsetSize);
+
+ int windowSize = limit - 1;
+
+ TestWindowedCursor c = new TestWindowedCursor(PROJECTION, recordsetSize);
+ CursorWindow window = c.getWindow();
+ window.setNumColumns(PROJECTION.length);
+
+ Bundle extras = c.getExtras();
+
+ // Build the unpaged result set.
+ for (int row = 0; row < windowSize; row++) {
+ if (!window.allocRow()) {
+ break;
+ }
+ if (!fillRow(window, row)) {
+ window.freeLastRow();
+ break;
+ }
+ }
+
+ extras.putStringArray(ContentResolver.EXTRA_HONORED_ARGS, new String[] {
+ ContentResolver.QUERY_ARG_OFFSET,
+ ContentResolver.QUERY_ARG_LIMIT
+ });
+ extras.putInt(ContentResolver.EXTRA_TOTAL_COUNT, recordsetSize);
+ return c;
+ }
+
+ private MatrixCursor buildUnpagedResults(int recordsetSize) {
+ MatrixCursor c = createInMemoryCursor();
+
+ // Build the unpaged result set.
+ for (int i = 0; i < recordsetSize; i++) {
+ fillRow(c.newRow(), i);
+ }
+
+ return c;
+ }
+
+ /**
+ * Returns data type of the given object's value.
+ *<p>
+ * Returned values are
+ * <ul>
+ * <li>{@link Cursor#FIELD_TYPE_NULL}</li>
+ * <li>{@link Cursor#FIELD_TYPE_INTEGER}</li>
+ * <li>{@link Cursor#FIELD_TYPE_FLOAT}</li>
+ * <li>{@link Cursor#FIELD_TYPE_STRING}</li>
+ * <li>{@link Cursor#FIELD_TYPE_BLOB}</li>
+ *</ul>
+ *</p>
+ */
+ public static int getTypeOfObject(Object obj) {
+ if (obj == null) {
+ return Cursor.FIELD_TYPE_NULL;
+ } else if (obj instanceof byte[]) {
+ return Cursor.FIELD_TYPE_BLOB;
+ } else if (obj instanceof Float || obj instanceof Double) {
+ return Cursor.FIELD_TYPE_FLOAT;
+ } else if (obj instanceof Long || obj instanceof Integer
+ || obj instanceof Short || obj instanceof Byte) {
+ return Cursor.FIELD_TYPE_INTEGER;
+ } else {
+ return Cursor.FIELD_TYPE_STRING;
+ }
+ }
+
+ private MatrixCursor createInMemoryCursor() {
+ MatrixCursor c = new MatrixCursor(PROJECTION);
+ Bundle extras = new Bundle();
+ c.setExtras(extras);
+ return c;
+ }
+
+ private void fillRow(RowBuilder row, int rowId) {
+ row.add(createCellValue(rowId, 0));
+ row.add(createCellValue(rowId, 1));
+ row.add(createCellValue(rowId, 2));
+ row.add(createCellValue(rowId, 3));
+ row.add(createCellValue(rowId, 4));
+ }
+
+ /**
+ * @return true if the row was successfully populated. If false, caller should freeLastRow.
+ */
+ private static boolean fillRow(CursorWindow window, int row) {
+ if (!window.putLong((int) createCellValue(row, 0), row, 0)) {
+ return false;
+ }
+ for (int i = 1; i < PROJECTION.length; i++) {
+ if (!window.putString((String) createCellValue(row, i), row, i)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static Object createCellValue(int row, int col) {
+ switch(col) {
+ case 0:
+ return row;
+ case 1:
+ return "--aaa--" + row;
+ case 2:
+ return "**bbb**" + row;
+ case 3:
+ return ("^^ccc^^" + row);
+ case 4:
+ return "##ddd##" + row;
+ default:
+ throw new IllegalArgumentException("Unsupported column: " + col);
+ }
+ }
+
+ /**
+ * Asserts that the value at the current cursor position x column
+ * is expected test data for the supplied row.
+ *
+ * <p>Cursor must be pre-positioned.
+ *
+ * @param cursor must be prepositioned to the row to be tested.
+ * @param row row value expected to be reflected in cell. This can be different
+ * than the cursor position due to paging.
+ * @param column
+ */
+ @VisibleForTesting
+ public static void assertExpectedCellValue(Cursor cursor, int row, int column) {
+ int type = cursor.getType(column);
+ switch(type) {
+ case Cursor.FIELD_TYPE_NULL:
+ throw new UnsupportedOperationException("Not implemented.");
+ case Cursor.FIELD_TYPE_INTEGER:
+ assertEquals(createCellValue(row, column), cursor.getInt(column));
+ break;
+ case Cursor.FIELD_TYPE_FLOAT:
+ assertEquals(createCellValue(row, column), cursor.getDouble(column));
+ break;
+ case Cursor.FIELD_TYPE_BLOB:
+ assertEquals(createCellValue(row, column), cursor.getBlob(column));
+ break;
+ case Cursor.FIELD_TYPE_STRING:
+ assertEquals(createCellValue(row, column), cursor.getString(column));
+ break;
+ default:
+ throw new UnsupportedOperationException("Unknown column type: " + type);
+ }
+ }
+
+ @Override
+ public String getType(Uri uri) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+ throw new UnsupportedOperationException();
+ }
+
+ private static int constrain(int amount, int low, int high) {
+ return amount < low ? low : (amount > high ? high : amount);
+ }
+
+ /**
+ * Returns a Uri that includes paging information embedded in the URI.
+ * This allows a test client to force paged results when running on older SDKs...
+ * pre Android O SDKs lacking the ContentResolver#query w/ Bundle override
+ * necessary for paging.
+ */
+ public static Uri forcePagingSpec(Uri uri, int offset, int limit) {
+ assert (uri.getPath().equals(TestContentProvider.PAGED_PATH)
+ || uri.getPath().equals(TestContentProvider.PAGED_WINDOWED_PATH));
+ return uri.buildUpon()
+ .appendQueryParameter(ContentResolver.QUERY_ARG_OFFSET, String.valueOf(offset))
+ .appendQueryParameter(ContentResolver.QUERY_ARG_LIMIT, String.valueOf(limit))
+ .build();
+ }
+
+ public static Uri forceRecordCount(Uri uri, int recordCount) {
+ return uri.buildUpon()
+ .appendQueryParameter(RECORD_COUNT, String.valueOf(recordCount))
+ .build();
+ }
+
+ private static final class TestWindowedCursor extends AbstractWindowedCursor {
+
+ private final String[] mProjection;
+ private final int mCount;
+ private final Bundle mExtras;
+
+ TestWindowedCursor(String[] projection, int count) {
+ mProjection = projection;
+ mCount = count;
+ mExtras = new Bundle();
+
+ setWindow(new CursorWindow("stevie"));
+ }
+
+ @Override
+ public Bundle getExtras() {
+ return mExtras;
+ }
+
+ @Override
+ public int getCount() {
+ return mCount;
+ }
+
+ @Override
+ public String[] getColumnNames() {
+ return mProjection;
+ }
+ }
+}
diff --git a/content/tests/java/android/support/content/TestQueryCallback.java b/content/tests/java/android/support/content/TestQueryCallback.java
new file mode 100644
index 0000000..2ed975d
--- /dev/null
+++ b/content/tests/java/android/support/content/TestQueryCallback.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.Nullable;
+import android.support.v4.util.Pair;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+final class TestQueryCallback implements ContentPager.QueryRunner.Callback {
+
+ private static final String URI_KEY = "testUri";
+ private static final String URI_PAGE_ID = "testPageId";
+
+ private CollectorLatch<Query> mQueryLatch;
+ private CollectorLatch<Pair<Integer, Cursor>> mReplyLatch;
+
+ @Override
+ public @Nullable Cursor runQueryInBackground(Query query) {
+ mQueryLatch.accept(query);
+ Bundle extras = new Bundle();
+ extras.putParcelable(URI_KEY, query.getUri());
+ extras.putInt(URI_PAGE_ID, query.getId());
+ MatrixCursor cursor = new MatrixCursor(new String[]{"id"}, 0);
+ cursor.setExtras(extras);
+ return cursor;
+ }
+
+ @Override
+ public void onQueryFinished(Query query, Cursor cursor) {
+ mReplyLatch.accept(new Pair<>(query.getId(), cursor));
+ }
+
+ public void reset(int expectedCount) throws InterruptedException {
+ mQueryLatch = new CollectorLatch<>(expectedCount);
+ mReplyLatch = new CollectorLatch<>(expectedCount);
+ }
+
+ public void waitFor(int seconds) throws InterruptedException {
+ assertTrue(mQueryLatch.await(seconds, TimeUnit.SECONDS));
+ assertTrue(mReplyLatch.await(seconds, TimeUnit.SECONDS));
+ }
+
+ public void assertQueried(final int expectedPageId) {
+ mQueryLatch.assertHasItem(new Matcher<Query>() {
+ @Override
+ public boolean matches(Query query) {
+ return expectedPageId == query.getId();
+ }
+ });
+ }
+
+ public void assertReceivedContent(Uri expectedUri, final int expectedPageId) {
+ mReplyLatch.assertHasItem(new Matcher<Pair<Integer, Cursor>>() {
+ @Override
+ public boolean matches(Pair<Integer, Cursor> value) {
+ return expectedPageId == value.first;
+ }
+ });
+ List<Pair<Integer, Cursor>> collected = mReplyLatch.getCollected();
+ Cursor cursor = null;
+
+ for (Pair<Integer, Cursor> pair : collected) {
+ if (expectedPageId == pair.first) {
+ cursor = pair.second;
+ }
+ }
+
+ assertEquals(0, cursor.getCount()); // we don't add any records to our test cursor.
+ Bundle extras = cursor.getExtras();
+ assertNotNull(extras);
+ assertTrue(extras.containsKey(URI_KEY));
+ assertEquals(extras.getParcelable(URI_KEY), expectedUri);
+ assertTrue(extras.containsKey(URI_PAGE_ID));
+ assertEquals(extras.getInt(URI_PAGE_ID), expectedPageId);
+ }
+
+ private static final class CollectorLatch<T> extends CountDownLatch {
+
+ private final List<T> mCollected = new ArrayList<>();
+
+ CollectorLatch(int count) {
+ super(count);
+ }
+
+ void accept(@Nullable T value) {
+ onReceived(value);
+ super.countDown();
+ }
+
+ @Override
+ public void countDown() {
+ throw new UnsupportedOperationException("Count is incremented by calls to accept.");
+ }
+
+ void onReceived(@Nullable T value) {
+ mCollected.add(value);
+ }
+
+ List<T> getCollected() {
+ return mCollected;
+ }
+
+ public void assertHasItem(Matcher<T> matcher) {
+ T item = null;
+ for (T val : mCollected) {
+ if (matcher.matches(val)) {
+ item = val;
+ }
+ }
+ assertNotNull(item);
+ }
+
+ public @Nullable T get(int index) {
+ return mCollected.get(index);
+ }
+ }
+
+ interface Matcher<T> {
+ boolean matches(T value);
+ }
+}
diff --git a/content/tests/java/android/support/content/TestQueryRunner.java b/content/tests/java/android/support/content/TestQueryRunner.java
new file mode 100644
index 0000000..957d814
--- /dev/null
+++ b/content/tests/java/android/support/content/TestQueryRunner.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.content;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Test friendly synchronous QueryRunner. Not suitable for use
+ * in production code.
+ */
+public final class TestQueryRunner implements ContentPager.QueryRunner {
+
+ // if false, we'll skip calling through to the mCallback when query is called
+ // this simulates async processing, and allows tests to check that cancel
+ // is handled correctly.
+ public boolean runQuery = true;
+
+ private final Set<Query> mRunning = new HashSet<>();
+
+ @Override
+ public void query(Query query, Callback callback) {
+ if (runQuery) {
+ callback.onQueryFinished(query, callback.runQueryInBackground(query));
+ } else {
+ mRunning.add(query);
+ }
+ }
+
+ @Override
+ public boolean isRunning(Query query) {
+ return mRunning.contains(query);
+ }
+
+ @Override
+ public void cancel(Query query) {
+ mRunning.remove(query);
+ }
+}