Android Content Provider Tutorial

Photo by Jeremy Hynes on Unsplash
Photo by Jeremy Hynes on Unsplash
Content Provider is one of the four application components of Android. It can help app manage data stored in itself or in other apps, and provide a way to share data with other apps.

Content Provider is one of the four application components of Android. It can help app manage data stored in itself or in other apps, and provide a way to share data with other apps. This article will introduce how to access a content provider, and how to create a custom Content Provider.

The complete code for this chapter can be found in and .

Overview

Content Provider can help app manage data stored in itself or in other apps, and provide a way to share data with other apps. Additionally, it provides a mechanism to define data security. As shown in the figure below, the app allows other apps to safely access or modify its data through the content provider.

Overview of Content Providers from Google Developers
Overview of Content Providers from Google Developers

When app wants to access ContentProvider, it must access ContentProvider through ContentResolver. But when using ContentResolver, we want this action to be performed asynchronously in the background. Therefore, the official website recommends accessing the ContentResolver in the background through the CursorLoader in the UI thread, as shown in the figure below.

Interaction between ContentProvider, other classes and storages
Interaction between ContentProvider, other classes and storages from Google Developers

Content URI and Authority

Content URI is an URI that identifies the data in the provider, and authority is a name that identifies the provider. Content URI consists of an authority and a path. Path can be a table name of a database.

If your app implements a content provider, when you install the app to the Android system, the authority of the app’s content provider will be registered to the Android system. Let’s say the authority is the name of the app’s content provider. So it must be an unique name in the Android system. The official website recommends using the package name of the app to design the authority.

In this article, we will implement a BooksProvider. Its package name is com.waynestalk.booksprovider.

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.waynestalk.booksprovider">
</manifest>

So, the authority of BooksProvider will be com.waynestalk.booksprovider.provider.

Implementing ContentProvider – BooksProvider

When implementing ContentProvider, first define its authority and content URI. Then, implement onCreate(), getType(), query(), insert(), update(), and delete().

Among them, onCreate() will only be called once when the provider is created and initialized, so onCreate() is thread-safe. But all other methods can be called by multiple threads at the same time. Therefore, developers must handle thread-safe when implementing these methods.

Database

BooksProvider manages a table books. This table has three columns, namely:

  • id: The ID of a book, which is the primary key.
  • name: The name of a book.
  • authors: The authors of a book, separated by commas.
package com.waynestalk.booksprovider.db

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "books")
data class BookEntity(
    @PrimaryKey val id: Long?,
    val name: String,
    val authors: String?,
)
class BooksProvider : ContentProvider() {
    companion object {
        private const val _ID = "id"
        private const val NAME = "name"
        private const val AUTHORS = "authors"
    }

    private lateinit var database: BookDatabase
}

Content URI

Next, we define the authority as com.waynestalk.booksprovider.provider, and define the content URI as the authority followed by the name of the table books. So, the content URI will be com.waynestalk.booksprovider.provider/books. Therefore, this content URI can identify the data in the table books . If the id of a book is added after the content URI, the new URI can identify a certain data in the table books. For example, com.waynestalk.booksprovider.provider/books/1 refers to the data whose id is 1 in the table books managed by BooksProvider.

UriMatcher is a tool that can map URI to integers. addURI() adds mapping rules to UriMatcher. The second parameter of addURI() is the pattern of the path. Pattern has two wildcard characters:

  • *: Matches a string of any characters.
  • #: Matches a string of numeric characters.
class BooksProvider : ContentProvider() {
    companion object {
        private const val AUTHORITY = "com.waynestalk.booksprovider.provider"
        private val CONTENT_URI = Uri.parse("content://${AUTHORITY}/books")

        private const val FIND_ALL = 1
        private const val FIND_ONE = 2
    }

    private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI(AUTHORITY, "books", FIND_ALL)
        addURI(AUTHORITY, "books/#", FIND_ONE)
    }
』

onCreate()

Every time the Android system creates a provider, it will first call onCreate(). However, the Android system may kill the provider at any time when it is not being used. Therefore, the Android system may create and kill the provider many times. Therefore, we should minimize the initialization time in onCreate(). If onCreate() takes too much time, it will slow down the provider startup. We can defer some initialization tasks until they are really needed.

class BooksProvider : ContentProvider() {
    override fun onCreate(): Boolean {
        database = BookDatabase.getInstance(context ?: return false)
        return true
    }
}

getType()

getType() will return the MIME type corresponding to the URI. If the URI is corresponding to multiple records, the prefix of the type will be ContentResolver.CURSOR_DIR_BASE_TYPE/vnd.android.cursor.dir/). If the URI corresponds to a single record, the prefix of the type will be ContentResolver.CURSOR_ITEM_BASE_TYPE/vnd.android.cursor.item/).

class BooksProvider : ContentProvider() {
    companion object {
        private const val BOOKS_MIME_TYPE =
            "${ContentResolver.CURSOR_DIR_BASE_TYPE}/vnd.com.waynestalk.booksprovider.provider.books"
        private const val BOOK_MIME_TYPE =
            "${ContentResolver.CURSOR_ITEM_BASE_TYPE}/vnd.com.waynestalk.booksprovider.provider.books"
    }

    override fun getType(uri: Uri): String {
        return when (uriMatcher.match(uri)) {
            FIND_ALL -> BOOKS_MIME_TYPE
            FIND_ONE -> BOOK_MIME_TYPE
            else -> throw IllegalArgumentException("Unsupported URI: $uri")
        }
    }
}

query()

query() handles query requests. Its parameters map to SQL SELECT, as shown in the following table.

query()SELECT
uriFROM table_name
projectionSELECT col, col, col, …
selectionWHERE col = ? AND …
selectionArgsReplace ? in selection
sortOrderORDER BY col, col, …
query() compared to SELECT

First, use uriMatcher to determine whether the uri is a request to query multiple records or a single record. Afterwards, use other parameters to build the SQL string to query the database.

class BooksProvider : ContentProvider() {
    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?
    ): Cursor {
        val query = when (uriMatcher.match(uri)) {
            FIND_ALL -> {
                val builder = SQLiteQueryBuilder()
                builder.tables = "books"
                val sql = builder.buildQuery(
                    projection,
                    selection,
                    null,
                    null,
                    if (sortOrder?.isNotEmpty() == true) sortOrder else "id ASC",
                    null
                )
                SimpleSQLiteQuery(sql, selectionArgs)
            }
            FIND_ONE -> {
                val builder = SQLiteQueryBuilder()
                builder.tables = "books"
                val sql = builder.buildQuery(
                    projection,
                    "id = ${uri.lastPathSegment}",
                    null,
                    null,
                    null,
                    null
                )
                SimpleSQLiteQuery(sql)
            }
            else -> {
                throw IllegalArgumentException("Unsupported URI: $uri")
            }
        }

        val cursor = database.dao().query(query)
        cursor.setNotificationUri(context?.contentResolver, uri)

        return cursor
    
}

insert()

insert() handles requests for inserting a record.

class BooksProvider : ContentProvider() {
    override fun insert(uri: Uri, values: ContentValues?): Uri? {
        if (uriMatcher.match(uri) != FIND_ALL)
            throw IllegalArgumentException("Unsupported URI for insertion: $uri")
        if (values == null) return null

        val name =
            values.getAsString(NAME) ?: throw IllegalArgumentException("Value NAME is required")
        val authors = values.getAsString(AUTHORS)
        val id = database.dao().insert(BookEntity(null, name, authors))
        val entityUri = ContentUris.withAppendedId(CONTENT_URI, id)
        context?.contentResolver?.notifyChange(entityUri, null)
        return entityUri
    }
}

update()

update() handles requests to update data.

class BooksProvider : ContentProvider() {
    override fun update(
        uri: Uri,
        values: ContentValues?,
        selection: String?,
        selectionArgs: Array<out String>?
    ): Int {
        if (values == null) return 0

        return when (uriMatcher.match(uri)) {
            FIND_ALL -> {
                val rows = database.openHelper.writableDatabase.update(
                    "books",
                    OnConflictStrategy.REPLACE,
                    values,
                    selection,
                    selectionArgs
                )
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            FIND_ONE -> {
                val name = values.getAsString(NAME)
                val authors = values.getAsString(AUTHORS)
                val id = uri.lastPathSegment?.toLong() ?: return 0
                val rows = database.dao().update(BookEntity(id, name, authors))
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            else -> throw IllegalArgumentException("Unsupported URI: $uri")
        }
    }
}

delete()

delete() handles requests to delete data.

class BooksProvider : ContentProvider() {
    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        return when (uriMatcher.match(uri)) {
            FIND_ALL -> {
                val rows = database.openHelper.writableDatabase.delete(
                    "books",
                    selection,
                    selectionArgs
                )
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            FIND_ONE -> {
                val rows = database.openHelper.writableDatabase.delete(
                    "books",
                    "id = ?",
                    arrayOf(uri.lastPathSegment)
                )
                context?.contentResolver?.notifyChange(uri, null)
                rows
            }
            else -> throw IllegalArgumentException("Unsupported URI: $uri")
        }
    }
}

AndroidManifest.xml

In AndroidManifest.xml, first set the package name. Then, declare two <permission/>, one is read permission, the other is write permission. The attribute android:protectionLevel can be set to normal.

Declare <provider/>, whose attributes are set as follows:

  • android:authorities: Provider’s authority.
  • exported: Set to true. Then other apps can access it.
  • android:grantUriPermissions: Set temporary permission to true.
  • android:readPermission: Set the read permission, which is the READ_BOOKS we just declared above.
  • android:writePermission: Set the write permission, which is the WRITE_BOOKS we just declared above.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    package="com.waynestalk.booksprovider">

    <permission
        android:name="com.waynestalk.booksprovider.provider.READ_BOOKS"
        android:protectionLevel="normal" />
    <permission
        android:name="com.waynestalk.booksprovider.provider.WRITE_BOOKS"
        android:protectionLevel="normal" />

    <application>
        <provider
            android:name=".provider.BooksProvider"
            android:authorities="com.waynestalk.booksprovider.provider"
            android:enabled="true"
            android:exported="true"
            android:grantUriPermissions="true"
            android:label="BooksProvider"
           
android:readPermission="com.waynestalk.booksprovider.provider.READ_BOOKS"
            android:writePermission="com.waynestalk.booksprovider.provider.WRITE_BOOKS" />
    </application>

</manifest>

Implementing a Contract Class – BooksContract

The contract class is a simple class, only defining the Content URI, columns names, MIME types, and some other constants. We can provide BooksContract for other apps.

import android.net.Uri

object BooksContract {
    val CONTENT_URI: Uri = Uri.parse("content://com.waynestalk.booksprovider.provider/books")

    const val _ID = "id"
    const val NAME = "name"
    const val AUTHORS = "authors"
}

Accessing BooksProvider

Next, we’ll describe how to access the BooksProvider.

Query

We use ContentResolver.query() to query data from the provider. The parameters passed to ContentResolver.query() will be passed to BooksProvider.query().

class BookListViewModel : ViewModel() {
    val books: MutableLiveData<List<Book>> = MutableLiveData()

    fun loadBooks(contentResolver: ContentResolver, namePattern: String? = null) {
        viewModelScope.launch(Dispatchers.IO) {
            val cursor = contentResolver.query(
                BooksContract.CONTENT_URI,
                arrayOf(BooksContract._ID, BooksContract.NAME, BooksContract.AUTHORS),
                namePattern?.takeIf { it.isNotEmpty() }?.let { "${BooksContract.NAME} LIKE ?" },
                namePattern?.takeIf { it.isNotEmpty() }?.let { arrayOf("%$it%") },
                "${BooksContract._ID} ASC",
            ) ?: return@launch

            if (cursor.count == 0) {
                cursor.close()
                books.postValue(emptyList())
                return@launch
            }

            val idIndex = cursor.getColumnIndex(BooksContract._ID)
            val nameIndex = cursor.getColumnIndex(BooksContract.NAME)
            val authorsIndex = cursor.getColumnIndex(BooksContract.AUTHORS)

            val list = mutableListOf<Book>()
            while (cursor.moveToNext()) {
                val id = cursor.getLong(idIndex)
                val name = cursor.getString(nameIndex)
                val authors = cursor.getString(authorsIndex)
                list.add(Book(id, name, authors))
            }
            Log.d(TAG, "Loaded books: $list")

            cursor.close()
            books.postValue(list)
        }
    }
}

Insertion

We use ContentResolver.insert() to add a record to the provider. The parameters passed to ContentResolver.insert() will be passed to BooksProvider.insert(). It will return the URI of the added data.

class AddBookViewModel : ViewModel() {
    val result: MutableLiveData<Result<Uri>> = MutableLiveData()

    private fun addBook(contentResolver: ContentResolver, name: String, authors: String?) {
        try {
            val values = ContentValues()
            values.put(BooksContract.NAME, name)
            authors?.let { values.put(BooksContract.AUTHORS, it) }

            val uri = contentResolver.insert(BooksContract.CONTENT_URI, values)
            if (uri == null) {
                result.postValue(Result.failure(Exception("Returned URI is null")))
            } else {
                result.postValue(Result.success(uri))
            }
        } catch (e: Exception) {
            result.postValue(Result.failure(e))
        }
    }
}

Update

We use ContentResolver.update() to update one or more records to the provider. The parameters passed to ContentResolver.update() will be passed to BooksProvider.update(). It will return the number of records updated.

If it is to update multiple records, we use the URI identifying the table books. If updating a single record, we use the URI identifying that record.

class AddBookViewModel : ViewModel() {
    val result: MutableLiveData<Result<Uri>> = MutableLiveData()

    var editedBookId: Long? = null

    private fun editBook(contentResolver: ContentResolver, name: String, authors: String?) {
        try {
            val values = ContentValues()
            values.put(BooksContract.NAME, name)
            authors?.let { values.put(BooksContract.AUTHORS, it) }

            val uri = ContentUris.withAppendedId(BooksContract.CONTENT_URI, editedBookId!!)
            val rows = contentResolver.update(uri, values, null, null)
            if (rows > 0) {
                result.postValue(Result.success(uri))
            } else {
                result.postValue(Result.failure(Exception("Couldn't update the book")))
            }
        } catch (e: Exception) {
            result.postValue(Result.failure(e))
        }
    }
}

Deletion

We use ContentResolver.delete() to delete one or more records from the provider. The parameters passed to ContentResolver.delete() will be passed to BooksProvider.delete(). It will return the number of records deleted.

If multiple records are to be deleted, we use the URI identifying the table books. If a single record is to be deleted, we use the URI identifying that record.

class BookListViewModel : ViewModel() {
    val result: MutableLiveData<Result<Uri>> = MutableLiveData()

    fun deleteBook(contentResolver: ContentResolver, book: Book) {
        viewModelScope.launch {
            try {
                val uri = ContentUris.withAppendedId(BooksContract.CONTENT_URI, book.id)
                val rows = contentResolver.delete(uri, null, null)
                if (rows > 0) {
                    result.postValue(Result.success(uri))
                } else {
                    result.postValue(Result.failure(Exception("Couldn't delete the book")))
                }
            } catch (e: Exception) {
                result.postValue(Result.failure(e))
            }
        }
    }
}

Permissions

If the activity or fragment of BooksProvider access itself, there is no need to add <uses-permission/>. But if other apps access the provider, you need to add <uses-permission/>.

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <uses-permission android:name="com.waynestalk.booksprovider.provider.READ_BOOKS" />
    <uses-permission android:name="com.waynestalk.booksprovider.provider.WRITE_BOOKS" />
</manifest>

You also need to request permission in activities.

ActivityCompat.requestPermissions(
    this,
    arrayOf(BooksContract.READ_PERMISSION, BooksContract.WRITE_PERMISSION),
    PERMISSION_REQUEST_CODE
)

Conclusion

Although this article is long, it is not difficult to implement a content provider. Most of the code is just converting the parameters into the appropriate SQL string and then executing the SQL string against the database. Although we may rarely need to implement a content provider, the system provides many built-in content providers. Understanding the details of the implementation will also help us better understand how to access the content provider.

References

Leave a Reply

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

You May Also Like