Escape the curse of the Cursor with CursorWrapper

The twelfth day’s post of Mercari Advent Calendar 2020 is brought to you by Kinnera Priya Putti (@kinnerapriyap) from the Mercari US@Tokyo Mobile team.

Have you wanted to filter out rows returned by a cursor based on a specific condition?
Have you wanted to really really hide some rows in a cursor that you would rather not show?
Currently, a good way to add, hide or filter rows in a cursor would be with a CursorWrapper.

The official definition of CursorWrapper is as follows:

Wrapper class for Cursor that delegates all calls to the actual cursor object. The primary use for this class is to extend a cursor while overriding only a subset of its methods.

Why use a CursorWrapper?

A cursor represents the result set of a query made against a database in Android and can be used to retrieve media from device storage. To interact with the media store, we can use a ContentResolver object with an SQL-like selection statement to query.

But while ContentResolver#query does allow selection, requesting a cursor each time a filter is applied, or a row is added or removed may not be efficient, needing us to modify the original cursor on the client. This is where a CursorWrapper may be necessary.

How to implement a CursorWrapper?

For this post, let us filter a media cursor to return media in a specific album!

Step 1: Extend the CursorWrapper class

class WoohooCursorWrapper(
    cursor: Cursor?,
    albumName: String?
) : CursorWrapper(cursor)

cursor: Cursor?: This cursor will be used as the underlying cursor to create the cursor wrapper.
albumName: String?: We will be using this albumName to filter through our cursor.

Step 2: Create variables to build the modified wrapper

private val originalCount = super.getCount()
private var filterMap = IntArray(originalCount)
private var filteredCount = 0

originalCount: The original count will allow us to iterate through the cursor as we filter by albumName.
filterMap: This filterMap array will mark the media rows that will be in the modified cursor, so we will start with the original count and add rows as we filter through the cursor.
filteredCount: filteredCount will be the row count in the modified filtered cursor, so we’ll keep incrementing as we find media rows to add to our modified cursor.

Step 3: Initialize the filter

init { } is a good place for us to assign the filtered values to the variables we created before we override our CursorWrapper methods.

If the albumName is blank or null, we should return the cursor as is, which means that filteredCount is the same as originalCount and filterMap contains all of the media rows in the cursor.

if (albumName.isNullOrBlank()) {
   filteredCount = originalCount
   for (i in 0 until count) {
       filterMap[i] = i
   }
}

Next is the main logic of our filter where we iterate through the cursor. We will increment filteredCount and add the position to our filterMap if the albumName is the same as the bucket display name of the media row.

for (i in 0 until originalCount) {
   super.moveToPosition(i) // Move the cursor to the specified position

   // Get the bucketDisplayName of the current media row
   val bucketDisplayName =
       getString(getColumnIndexOrThrow(MediaGalleryHandler.BUCKET_DISPLAY_NAME))

   if (bucketDisplayName == albumName) {
       filterMap[filteredCount++] = i
   }
}

To finish off our init, we’ll reset the cursor since we just iterated through it.

moveToFirst()

Step 4: Override required methods in CursorWrapper

Every method in CursorWrapper doesn’t need to be overridden, but due to the design of the Cursor interface, we do need to override a few methods.

Let’s override each of these methods one by one!

override fun getCount(): Int = filteredCount // Row count of the modified cursor

Since we can’t yet calculate the position of a particular media row in the modified cursor, let us create a variable called filteredPosition and initialize it with -1. Since the getPosition value is zero-based, position -1 will refer to a value before the first row.

private var filteredPosition = -1

override fun getPosition(): Int = filteredPosition

We now have filteredPosition to base the position of a row on, so we can override the moveToPosition method which will be used by the remaining methods to be overridden.

override fun moveToPosition(position: Int): Boolean

We used moveToPosition in the init function to move through the rows, which would also be required by the modified cursor.
In this method, we will do two main things:

  • To utilise filteredPosition as we require, we will set the current position value to filteredPosition and this value will be the position of the media row in the modified cursor.
  • We won’t move linearly through the cursor rows because we will now need to iterate through the filtered media rows as initialized in filterMap. Position in the modified cursor will be defined by the position array in filterMap, which we will use to move to the specified position in the modified cursor.
override fun moveToPosition(position: Int): Boolean {
   // Check that position is not past the end of the cursor or before the beginning of the cursor
   if (position >= this.count || position < -1) return false
   filteredPosition = position

   // Check that position is valid and zero based
   if (position == -1) return false
   return super.moveToPosition(filterMap[position])
}

Since filteredPosition is the new reference to cursor position, the other methods will depend on filteredPosition to move to the required new position.

override fun move(offset: Int): Boolean = moveToPosition(filteredPosition + offset)
override fun moveToFirst(): Boolean = moveToPosition(0)
override fun moveToLast(): Boolean = moveToPosition(count - 1)
override fun moveToNext(): Boolean =  moveToPosition(filteredPosition + 1)
override fun moveToPrevious(): Boolean = moveToPosition(filteredPosition - 1)

We also need to notify the cursor of first, last and invalid positions. For this, we will use filteredPosition and count. Please note that count returns filteredCount from getCount() and not the original count.

override fun isFirst(): Boolean = filteredPosition == 0 && count != 0
override fun isLast(): Boolean = filteredPosition == count - 1 && count != 0
override fun isBeforeFirst(): Boolean = if (count == 0) true else filteredPosition == -1
override fun isAfterLast(): Boolean = if (count == 0) true else filteredPosition == count

How to use a CursorWrapper?

Since CursorWrapper implements the Cursor interface, this means that the CursorWrapper can directly be passed to the view adapter in place of the cursor.

fun getFilteredCursor: Cursor? = WoohooCursorWrapper(originalCursor, albumName)

Source code

You can find the source code for this media album cursor wrapper here.

I used media cursors and cursor wrappers and cursor adapters and more fun cursor things in sher-gil, a media image picker library in kotlin for Android 🥳 so you can check out the library here!

fin.

Thank you for reading about extending a CursorWrapper class and I hope this post will help you overcome the curse of the cursor to build yourself a custom cursor with a CursorWrapper!

Tomorrow’s blog post —the thirteenth in the Mercari Advent Calendar 2020 will be written by nozomoto. Hope you are looking forward to it!

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加