diff --git a/core/java/android/content/ContentProviderNative.java b/core/java/android/content/ContentProviderNative.java index d428a3a857b7c..2f87633a39d3b 100644 --- a/core/java/android/content/ContentProviderNative.java +++ b/core/java/android/content/ContentProviderNative.java @@ -24,6 +24,7 @@ import android.database.Cursor; import android.database.CursorToBulkCursorAdaptor; import android.database.DatabaseUtils; import android.database.IContentObserver; +import android.database.PageViewCursor; import android.net.Uri; import android.os.Binder; import android.os.Bundle; @@ -103,6 +104,7 @@ abstract public class ContentProviderNative extends Binder implements IContentPr if (cursor != null) { CursorToBulkCursorAdaptor adaptor = null; + cursor = PageViewCursor.wrap(cursor, queryArgs); try { adaptor = new CursorToBulkCursorAdaptor(cursor, observer, getProviderName()); diff --git a/core/java/android/database/PageViewCursor.java b/core/java/android/database/PageViewCursor.java new file mode 100644 index 0000000000000..fbd039d917422 --- /dev/null +++ b/core/java/android/database/PageViewCursor.java @@ -0,0 +1,245 @@ +/* + * 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.database; + +import static com.android.internal.util.Preconditions.checkArgument; + +import android.annotation.Nullable; +import android.content.ContentResolver; +import android.os.Bundle; +import android.util.Log; +import android.util.MathUtils; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.ArrayUtils; + +/** + * Cursor wrapper that provides visibility into a subset of a wrapped cursor. + * + * The window is specified by offset and limit. + * + * @hide + */ +public final class PageViewCursor extends CrossProcessCursorWrapper { + + /** + * An extra added to results that are auto-paged using the wrapper. + */ + public static final String EXTRA_AUTO_PAGED = "android.content.extra.AUTO_PAGED"; + + private static final String TAG = "PageViewCursor"; + private static final boolean DEBUG = false; + private static final boolean VERBOSE = false; + + private final int mOffset; // aka first index + private final int mCount; + private final Bundle mExtras; + + private int mPos = -1; + + /** + * @see PageViewCursor#wrap(Cursor, Bundle) + */ + @VisibleForTesting + public PageViewCursor(Cursor cursor, int offset, int limit) { + super(cursor); + + checkArgument(offset > -1); + checkArgument(limit > -1); + + mOffset = offset; + + mExtras = new Bundle(); + Bundle extras = cursor.getExtras(); + if (extras != null) { + mExtras.putAll(extras); + } + mExtras.putBoolean(EXTRA_AUTO_PAGED, true); + + // We need a mutable bundle so we can add QUERY_RESULT_SIZE. + // Direct equality check is correct here. Bundle.EMPTY is a specific instance + // of Bundle that is immutable by way of implementation. + // mExtras = (extras == Bundle.EMPTY) ? new Bundle() : extras; + + // When we're wrapping another cursor, it should not already be "paged". + checkArgument(!mExtras.containsKey(ContentResolver.EXTRA_TOTAL_SIZE)); + + int count = mCursor.getCount(); + mExtras.putInt(ContentResolver.EXTRA_TOTAL_SIZE, count); + + mCount = MathUtils.constrain(count - offset, 0, limit); + + if (DEBUG) Log.d(TAG, "Wrapped cursor" + + " offset: " + mOffset + + ", limit: " + limit + + ", delegate_size: " + count + + ", paged_count: " + mCount); + } + + @Override + public Bundle getExtras() { + return mExtras; + } + + @Override + public int getPosition() { + return mPos; + } + + @Override + public boolean isBeforeFirst() { + if (mCount == 0) { + return true; + } + return mPos == -1; + } + + @Override + public boolean isAfterLast() { + if (mCount == 0) { + return true; + } + return mPos == mCount; + } + + @Override + public boolean isFirst() { + return mPos == 0; + } + + @Override + public boolean isLast() { + return mPos == mCount - 1; + } + + @Override + public boolean moveToFirst() { + return moveToPosition(0); + } + + @Override + public boolean moveToLast() { + return moveToPosition(mCount - 1); + } + + @Override + public boolean moveToNext() { + return move(1); + } + + @Override + public boolean moveToPrevious() { + return move(-1); + } + + @Override + public boolean move(int offset) { + return moveToPosition(mPos + offset); + } + + @Override + public boolean moveToPosition(int position) { + if (position >= mCount) { + if (VERBOSE) Log.v(TAG, "Invalid Positon: " + position + " >= count: " + mCount + + ". Moving to last record."); + mPos = mCount; + super.moveToPosition(mOffset + mPos); // move into "after last" state. + return false; + } + + // Make sure position isn't before the beginning of the cursor + if (position < 0) { + if (VERBOSE) Log.v(TAG, "Ignoring invalid move to position: " + position); + mPos = -1; + super.moveToPosition(mPos); + return false; + } + + if (position == mPos) { + if (VERBOSE) Log.v(TAG, "Ignoring no-op move to position: " + position); + return true; + } + + int delegatePosition = position + mOffset; + if (VERBOSE) Log.v(TAG, "Moving delegate cursor to position: " + delegatePosition); + if (super.moveToPosition(delegatePosition)) { + mPos = position; + return true; + } else { + mPos = -1; + super.moveToPosition(-1); + return false; + } + } + + @Override + public boolean onMove(int oldPosition, int newPosition) { + throw new UnsupportedOperationException("Not supported."); + } + + @Override + public int getCount() { + return mCount; + } + + /** + * Wraps the cursor such that it will honor paging args (if present), AND if the cursor + * does not report paging size. + * + *
No-op if cursor already contains paging or is less than specified page size. + */ + public static Cursor wrap(Cursor cursor, @Nullable Bundle queryArgs) { + + boolean hasPagingArgs = + queryArgs != null + && (queryArgs.containsKey(ContentResolver.QUERY_ARG_OFFSET) + || queryArgs.containsKey(ContentResolver.QUERY_ARG_LIMIT)); + + if (!hasPagingArgs) { + if (VERBOSE) Log.d(TAG, "No-wrap: No paging args in request."); + return cursor; + } + + if (hasPagedResponseDetails(cursor.getExtras())) { + if (VERBOSE) Log.d(TAG, "No-wrap. Cursor has paging details."); + return cursor; + } + + return new PageViewCursor( + cursor, + queryArgs.getInt(ContentResolver.QUERY_ARG_OFFSET, 0), + queryArgs.getInt(ContentResolver.QUERY_ARG_LIMIT, Integer.MAX_VALUE)); + } + + /** + * @return true if the extras contains information indicating the associated + * cursor is paged. + */ + private static boolean hasPagedResponseDetails(@Nullable Bundle extras) { + if (extras != null && extras.containsKey(ContentResolver.EXTRA_TOTAL_SIZE)) { + return true; + } + + String[] honoredArgs = extras.getStringArray(ContentResolver.EXTRA_HONORED_ARGS); + if (honoredArgs != null && ( + ArrayUtils.contains(honoredArgs, ContentResolver.QUERY_ARG_OFFSET) + || ArrayUtils.contains(honoredArgs, ContentResolver.QUERY_ARG_LIMIT))) { + return true; + } + + return false; + } +} diff --git a/core/tests/coretests/src/android/database/PageViewCursorTest.java b/core/tests/coretests/src/android/database/PageViewCursorTest.java new file mode 100644 index 0000000000000..0be89d5361d8d --- /dev/null +++ b/core/tests/coretests/src/android/database/PageViewCursorTest.java @@ -0,0 +1,318 @@ +/* + * 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.database; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import android.annotation.Nullable; +import android.content.ContentResolver; +import android.os.Bundle; +import android.support.test.runner.AndroidJUnit4; +import android.util.Log; +import android.util.MathUtils; + +import com.android.internal.annotations.VisibleForTesting; +import com.android.internal.util.ArrayUtils; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Random; + +@RunWith(AndroidJUnit4.class) +public class PageViewCursorTest { + + private static final int ITEM_COUNT = 20; + + private static final String NAME_COLUMN = "name"; + private static final String NUM_COLUMN = "num"; + + private static final String[] COLUMNS = new String[]{ + NAME_COLUMN, + NUM_COLUMN + }; + + private static final String[] NAMES = new String[] { + "000", + "111", + "222", + "333", + "444", + "555", + "666", + "777", + "888", + "999", + "aaa", + "bbb", + "ccc", + "ddd", + "eee", + "fff", + "ggg", + "hhh", + "iii", + "jjj" + }; + + private MatrixCursor mDelegate; + private PageViewCursor mCursor; + + @Before + public void setUp() { + Random rand = new Random(); + + mDelegate = new MatrixCursor(COLUMNS); + for (int i = 0; i < ITEM_COUNT; i++) { + MatrixCursor.RowBuilder row = mDelegate.newRow(); + row.add(NAME_COLUMN, NAMES[i]); + row.add(NUM_COLUMN, rand.nextInt()); + } + + mCursor = new PageViewCursor(mDelegate, 10, 5); + } + + @Test + public void testPage_Size() { + assertEquals(5, mCursor.getCount()); + } + + @Test + public void testPage_TotalSize() { + assertEquals(ITEM_COUNT, mCursor.getExtras().getInt(ContentResolver.EXTRA_TOTAL_SIZE)); + } + + @Test + public void testPage_OffsetExceedsCursorCount_EffectivelyEmptyCursor() { + mCursor = new PageViewCursor(mDelegate, ITEM_COUNT * 2, 5); + assertEquals(0, mCursor.getCount()); + } + + @Test + public void testMoveToPosition() { + assertTrue(mCursor.moveToPosition(0)); + assertEquals(NAMES[10], mCursor.getString(0)); + assertTrue(mCursor.moveToPosition(1)); + assertEquals(NAMES[11], mCursor.getString(0)); + assertTrue(mCursor.moveToPosition(4)); + assertEquals(NAMES[14], mCursor.getString(0)); + + // and then back down again for good measure. + assertTrue(mCursor.moveToPosition(1)); + assertEquals(NAMES[11], mCursor.getString(0)); + assertTrue(mCursor.moveToPosition(0)); + assertEquals(NAMES[10], mCursor.getString(0)); + } + + @Test + public void testMoveToPosition_MoveToSamePosition_NoOp() { + assertTrue(mCursor.moveToPosition(1)); + assertEquals(NAMES[11], mCursor.getString(0)); + assertTrue(mCursor.moveToPosition(1)); + assertEquals(NAMES[11], mCursor.getString(0)); + } + + @Test + public void testMoveToPosition_PositionOutOfBounds_MovesToBeforeFirst() { + assertTrue(mCursor.moveToPosition(0)); + assertEquals(NAMES[10], mCursor.getString(0)); + + // move before + assertFalse(mCursor.moveToPosition(-12)); + assertTrue(mCursor.isBeforeFirst()); + } + + @Test + public void testMoveToPosition_PositionOutOfBounds_MovesToAfterLast() { + assertTrue(mCursor.moveToPosition(0)); + assertEquals(NAMES[10], mCursor.getString(0)); + + assertFalse(mCursor.moveToPosition(222)); + assertTrue(mCursor.isAfterLast()); + } + + @Test + public void testPosition() { + assertEquals(-1, mCursor.getPosition()); + } + + @Test + public void testIsBeforeFirst() { + assertTrue(mCursor.isBeforeFirst()); + mCursor.moveToFirst(); + assertFalse(mCursor.isBeforeFirst()); + } + + @Test + public void testCount_ZeroForEmptyCursor() { + mCursor = new PageViewCursor(mDelegate, 0, 0); + assertEquals(0, mCursor.getCount()); + } + + @Test + public void testIsBeforeFirst_TrueForEmptyCursor() { + mCursor = new PageViewCursor(mDelegate, 0, 0); + assertTrue(mCursor.isBeforeFirst()); + } + + @Test + public void testIsAfterLast() { + assertFalse(mCursor.isAfterLast()); + mCursor.moveToLast(); + mCursor.moveToNext(); + assertTrue(mCursor.isAfterLast()); + } + + @Test + public void testIsAfterLast_TrueForEmptyCursor() { + mCursor = new PageViewCursor(mDelegate, 0, 0); + assertTrue(mCursor.isAfterLast()); + } + + @Test + public void testIsFirst() { + assertFalse(mCursor.isFirst()); + mCursor.moveToFirst(); + assertTrue(mCursor.isFirst()); + } + + @Test + public void testIsLast() { + assertFalse(mCursor.isLast()); + mCursor.moveToLast(); + assertTrue(mCursor.isLast()); + } + + @Test + public void testMove() { + // note that initial position is -1, so moving + // 2 will only put as at 1. + mCursor.move(2); + assertEquals(NAMES[11], mCursor.getString(0)); + mCursor.move(-1); + assertEquals(NAMES[10], mCursor.getString(0)); + } + + @Test + public void testMoveToFist() { + mCursor.moveToPosition(3); + mCursor.moveToFirst(); + assertEquals(NAMES[10], mCursor.getString(0)); + } + + @Test + public void testMoveToLast() { + mCursor.moveToLast(); + assertEquals(NAMES[14], mCursor.getString(0)); + } + + @Test + public void testMoveToNext() { + // default position is -1, so next is 0. + mCursor.moveToNext(); + assertEquals(NAMES[10], mCursor.getString(0)); + } + + @Test + public void testMoveToNext_AfterLastReturnsFalse() { + mCursor.moveToLast(); + assertFalse(mCursor.moveToNext()); + } + + @Test + public void testMoveToPrevious() { + mCursor.moveToPosition(3); + mCursor.moveToPrevious(); + assertEquals(NAMES[12], mCursor.getString(0)); + } + + @Test + public void testMoveToPrevious_BeforeFirstReturnsFalse() { + assertFalse(mCursor.moveToPrevious()); + } + + @Test + public void testWindow_ReadPastEnd() { + assertFalse(mCursor.moveToPosition(10)); + } + + @Test + public void testOffset_LimitOutOfBounds() { + mCursor = new PageViewCursor(mDelegate, 5, 100); + assertEquals(15, mCursor.getCount()); + } + + @Test + public void testPagingMarker() { + mCursor = new PageViewCursor(mDelegate, 5, 100); + assertTrue(mCursor.getExtras().getBoolean(PageViewCursor.EXTRA_AUTO_PAGED)); + } + + @Test + public void testWrap() { + Bundle queryArgs = new Bundle(); + queryArgs.putInt(ContentResolver.QUERY_ARG_OFFSET, 5); + queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, 5); + Cursor wrapped = PageViewCursor.wrap(mDelegate, queryArgs); + assertTrue(wrapped instanceof PageViewCursor); + assertEquals(5, wrapped.getCount()); + } + + @Test + public void testWrap_NoOpWithoutPagingArgs() { + Cursor wrapped = PageViewCursor.wrap(mDelegate, Bundle.EMPTY); + assertTrue(mDelegate == wrapped); + } + + @Test + public void testWrap_NoOpCursorsWithExistingPaging_ByTotalSize() { + Bundle extras = new Bundle(); + extras.putInt(ContentResolver.EXTRA_TOTAL_SIZE, 5); + mDelegate.setExtras(extras); + + Bundle queryArgs = new Bundle(); + queryArgs.putInt(ContentResolver.QUERY_ARG_OFFSET, 5); + queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, 5); + Cursor wrapped = PageViewCursor.wrap(mDelegate, queryArgs); + assertTrue(mDelegate == wrapped); + } + + @Test + public void testWrap_NoOpCursorsWithExistingPaging_ByHonoredArgs() { + Bundle extras = new Bundle(); + extras.putStringArray( + ContentResolver.EXTRA_HONORED_ARGS, + new String[] { + ContentResolver.QUERY_ARG_OFFSET, + ContentResolver.QUERY_ARG_LIMIT + }); + mDelegate.setExtras(extras); + + Bundle queryArgs = new Bundle(); + queryArgs.putInt(ContentResolver.QUERY_ARG_OFFSET, 5); + queryArgs.putInt(ContentResolver.QUERY_ARG_LIMIT, 5); + Cursor wrapped = PageViewCursor.wrap(mDelegate, queryArgs); + assertTrue(mDelegate == wrapped); + } + + private void assertStringAt(int row, int column, String expected) { + mCursor.moveToPosition(row); + assertEquals(expected, mCursor.getString(column)); + } +}