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);
+    }
+}