Paging Library 3 and Content Provider

I needed to display the contents of a Android content provider in a recyclerview with pagination.

I also wanted to try out version 3 of the Android Paging Library (which is currently in 3.0.0.alpha2 release), but most of the sources of documentation and tutorials are targeted at accessing either a network (e.g. Retrofit) or a database.

So I wrote a simple demo app on GitHub to get Paging 3.0 to work with a content provider instead.

Just be aware that working with an alpha release means that there may be changes to the paging library in it’s final release.

Pagination for a Content Provider

How to page the contents of a content provider depends on the specific provider. However for some Android content providers, such as Contacts or Telephony, they access a built-in database so we can just pass database parameters to them.

The demo app uses the paging library with the Telephony provider to access SMS messages, and page the contents to a recyclerview.
Although I could have used the ContentResolver directly to do the query on the content provider, in the app I decided to wrap the query in a repository class to encapsulate the query parameters and the field mapping for the results.

class MessageRepository (
private val context: Context) {

fun getMessages(limit: Int, offset: Int): List<Message>
    {
        val cursor = context.contentResolver.query(
            Sms.CONTENT_URI,
            arrayOf<String>(
                Sms.Inbox.ADDRESS,
                Sms.Inbox.BODY,
                Sms.Inbox.DATE
            ),
            null,
            null,
            Sms.Inbox.DEFAULT_SORT_ORDER + " LIMIT " + limit + " OFFSET " + offset
        )

The LIMIT parameter is the number of records you want to retrieve for each page (page size), and OFFSET is how many rows to skip before retrieving records (to ignore previous pages already retrieved).

Pagination .. and Filtering

Another common scenario is to apply filtering to the query to only retrieve and display a subset of the content provider data.

As an example, to filter the retrieval of SMS messages to only return messages where the phone number contains a substring of numbers. This means we need to pass the filter string to the ContentResolver query, as well as the paging parameters.

fun getMessages(filter: String, limit: Int, offset: Int): List<Message>
    {
        val filterArg = "%" + filter+ "%"

        val cursor = context.contentResolver.query(
            Sms.CONTENT_URI,
            arrayOf<String>(
                Sms.Inbox.ADDRESS,
                Sms.Inbox.BODY,
                Sms.Inbox.DATE
            ),
            Sms.ADDRESS + " LIKE ?",
            arrayOf(filterArg),
            Sms.Inbox.DEFAULT_SORT_ORDER + " LIMIT " + limit + " OFFSET " + offset
        )

For testing ContentResolver queries, I also wrote a testing app in GitHub to quickly run arbitrary queries on content providers. I used this to check the paging and filtering parameters worked as expected before using the queries in the demo.

PagingSource – Connect Paging to the Data

One of the major changes to Paging Library version 3 is replacing DataSource with PagingSource, and it is in the load function of the PagingSource subclass that the ContentResolver query will be done (in the demo app code, indirectly via the repository class).

class MessagePagingSource(val repo: MessageRepository) : PagingSource<Int, Message>() {

    // the initial load size for the first page may be different from the requested size
    var initialLoadSize: Int = 0

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Message> {
        try {
            // Start refresh at page 1 if undefined.
            val nextPageNumber = params.key ?: 1

            if (params.key == null)
            {
                initialLoadSize = params.loadSize
            }

            // work out the offset into the database to retrieve records from the page number,
            // allow for a different load size for the first page
            val offsetCalc = {
                if (nextPageNumber == 2)
                    initialLoadSize
                else
                    ((nextPageNumber - 1) * params.loadSize) + (initialLoadSize - params.loadSize)
            }
            val offset = offsetCalc.invoke()

            val messages = repo.getMessages(params.loadSize, offset)
            val count = messages.size

            return LoadResult.Page(
                data = messages,
                prevKey = null, // Only paging forward.
                // assume that if a full page is not loaded, that means the end of the data
                nextKey = if (count < params.loadSize) null else nextPageNumber + 1
            )
        } catch (e: Exception) {
            return LoadResult.Error<Int, Message>(e)
        }
    }
}

To make the PagingSource work with the content provider, we needed to do 2 things:

1. Translate the parameters you receive in the load() function into the paging parameters to pass on to the ContentResolver query.

Page number – except for the first page, the page number for the query is the key field from the LoadParams parameter. This is also required to work out the offset parameter for the ContentResolver query.

val nextPageNumber = params.key ?: 1

Page size – set the pageSize parameter for the PagingConfig class.
For the demo app I just use a page size of 10 to make it easier for testing.

Pager(
        PagingConfig(pageSize = 10)
    )

One complication is that for the initial load (for the first page), the params.loadSize may be different from the page size requested. This can be set with the initialLoadSize parameter. It is recommended by the documentation to load enough data initially to fill several screens to handle small scrolls without having to load more data.

Pager(
        PagingConfig(pageSize = 10, initialPageSize = 20)
    )

Note that if you do not specify initialLoadSize, then the paging library will set this for you and load more than the page size you requested anyway.

(From PagingConfig source code:)

val initialLoadSize: Int = pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER

    companion object {
           internal const val DEFAULT_INITIAL_PAGE_MULTIPLIER = 3
    }

So you need to take this into account when working out the offset for the ContentResolver query.

if (params.key == null)
            {
                initialLoadSize = params.loadSize
            }

            // work out the offset into the database to retrieve records from the page number,
            // allow for a different load size for the first page
            val offsetCalc = {
                if (nextPageNumber == 2)
                    initialLoadSize
                else
                    ((nextPageNumber - 1) * params.loadSize) + (initialLoadSize - params.loadSize)
            }
            val offset = offsetCalc.invoke()
2. Tell the adapter to stop paging when the end of the data has been reached.

To stop loading data, we just need to pass null as the nextKey parameter of the LoadResult returned from the function.

We assume that no more data is available from the content provider when the data returned from the query contains less than the page size requested.

           val count = messages.size

            return LoadResult.Page(
                data = messages,
                prevKey = null, // Only paging forward.
                // assume that if a full page is not loaded, that means the end of the data
                nextKey = if (count < params.loadSize) null else nextPageNumber + 1
            )

RxJava 2, too

The demo app uses Kotlin flow as that is what is used in the Paging library 3 documentation and sample code, which makes the code easier to follow along with the documentation samples. However the demo code also has alternative classes that uses RxJava 2 instead. To run the RxJava code, modify the manifest to use an alternative activity and rebuild.

Leave a Reply

Your email address will not be published. Required fields are marked *