ContentPager.java

/*
 * 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 androidx.contentpager.content;

import static androidx.core.util.Preconditions.checkArgument;
import static androidx.core.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.util.Log;

import androidx.annotation.GuardedBy;
import androidx.annotation.IntDef;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresPermission;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.collection.LruCache;

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 = "androidx.appcompat.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);
        }
    }
}