Topic 8: Android Building Blocks -IV : Content Providers
📖 11 min read · 🎯 intermediate · 🧭 Prerequisites: android-building-blocks-iii-broadcast-receivers, polymorphism-in-java
Why this matters
Up until now, every app you've built keeps its data locked inside itself — its own database, its own files, nobody else gets in. But think about how your phone's Contacts app works: your WhatsApp reads your contacts, your email app reads your contacts, even a third-party dialer reads your contacts. How does that happen without every app having a copy of the database? That's exactly what Content Providers solve. They let one app expose structured data to others in a controlled, permission-based way. This lesson shows you how that system works — and how to build one yourself.
What You'll Learn
- Understand what Content Providers are and why Android needs them
- Identify the three core building blocks: Content URI, ContentResolver, and Cursor
- Build a fully working Content Provider backed by an SQLite database
- Register a Content Provider in the Android Manifest
- Access data from a Content Provider using ContentResolver in an Activity
The Analogy
Think of a Content Provider as a post office with strict counter service. The city's records (your SQLite data) are locked in a vault behind the counter — no visitor walks in and rummages through the filing cabinets themselves. Instead, they fill out a standardized request form (the Content URI), hand it to the clerk (the ContentResolver), and receive back exactly the documents they asked for, sorted and formatted (the Cursor). The post office enforces who can read, who can write, and who gets turned away — all without exposing where the vault is or how the filing system works. That disciplined abstraction is precisely what Content Providers deliver.
Chapter 1: Overview of Content Providers
Content Providers manage access to a structured set of data. They encapsulate the data and provide mechanisms for defining data security. Android ships with a number of built-in Content Providers that expose standard data types — contact information, media files, calendar events, and more.
Any application that needs to share data with other applications, or that needs a clean abstraction layer over its own data store, benefits from implementing a Content Provider.
Key Components
1. Content URI
A Content URI is a unique identifier for accessing data in a Content Provider. It follows the form:
content://<authority>/<path>/<id>
content://— the scheme, always present<authority>— uniquely identifies the provider (usually a reverse-domain string, e.g.com.example.myapp.provider)<path>— the table or data collection (e.g.books)<id>— optional; targets a single row (e.g.books/5)
2. ContentResolver
ContentResolver is the client-side class that your Activity or Fragment uses to talk to any Content Provider — your own or the system's. It provides the standard CRUD methods:
| Method | Description |
|---|---|
query() | Retrieve rows |
insert() | Add a new row |
update() | Modify existing rows |
delete() | Remove rows |
getType() | Return the MIME type for a URI |
3. Cursor
Cursor is an interface that provides random read-write access to the result set returned by a database query. You iterate over it row by row, calling cursor.getString(), cursor.getInt(), etc., to pull out column values. Always call cursor.close() when finished to free resources.
sequenceDiagram
participant App as Client App
participant CR as ContentResolver
participant CP as BookContentProvider
participant DB as SQLite DB
App->>CR: query(CONTENT_URI, ...)
CR->>CP: query(uri, projection, selection, ...)
CP->>DB: SELECT ...
DB-->>CP: ResultSet
CP-->>CR: Cursor
CR-->>App: Cursor
App->>App: iterate Cursor
App->>CR: insert(CONTENT_URI, values)
CR->>CP: insert(uri, values)
CP->>DB: INSERT ...
DB-->>CP: rowId
CP-->>CR: new Uri
CR-->>App: Uri
Chapter 2: Implementing a Content Provider
We will build a Content Provider that manages a list of books stored in an SQLite database. There are four steps: define the schema, create the database helper, define the provider, and register it.
Step 1: Define the Database Schema
BookContract.java is a contract class — a public final class that holds constants for table and column names. Implementing BaseColumns gives every table a free _ID primary key column, which the Android framework expects.
package com.example.myapp;
import android.provider.BaseColumns;
public final class BookContract {
private BookContract() {}
public static class BookEntry implements BaseColumns {
public static final String TABLE_NAME = "books";
public static final String COLUMN_NAME_TITLE = "title";
public static final String COLUMN_NAME_AUTHOR = "author";
}
}
Step 2: Create the Database Helper
BookDbHelper.java extends SQLiteOpenHelper, which manages database creation and version management. onCreate() runs the CREATE TABLE statement the first time the database is opened. onUpgrade() handles migrations — here it drops and recreates the table.
package com.example.myapp;
import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
public class BookDbHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "books.db";
private static final int DATABASE_VERSION = 1;
public BookDbHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
final String SQL_CREATE_BOOKS_TABLE =
"CREATE TABLE " + BookContract.BookEntry.TABLE_NAME + " (" +
BookContract.BookEntry._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
BookContract.BookEntry.COLUMN_NAME_TITLE + " TEXT NOT NULL," +
BookContract.BookEntry.COLUMN_NAME_AUTHOR + " TEXT NOT NULL" +
");";
db.execSQL(SQL_CREATE_BOOKS_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
db.execSQL("DROP TABLE IF EXISTS " + BookContract.BookEntry.TABLE_NAME);
onCreate(db);
}
}
Step 3: Define the Content Provider
BookContentProvider.java extends ContentProvider and implements the six abstract methods. A UriMatcher maps incoming URIs to integer codes (BOOKS = 100 for the whole collection, BOOK_ID = 101 for a single row) so every method can switch on them cleanly.
package com.example.myapp;
import android.content.ContentProvider;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.SQLException;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class BookContentProvider extends ContentProvider {
public static final String AUTHORITY = "com.example.myapp.provider";
public static final Uri CONTENT_URI =
Uri.parse("content://" + AUTHORITY + "/" + BookContract.BookEntry.TABLE_NAME);
private static final int BOOKS = 100;
private static final int BOOK_ID = 101;
private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
uriMatcher.addURI(AUTHORITY, BookContract.BookEntry.TABLE_NAME, BOOKS);
uriMatcher.addURI(AUTHORITY, BookContract.BookEntry.TABLE_NAME + "/#", BOOK_ID);
}
private BookDbHelper dbHelper;
@Override
public boolean onCreate() {
Context context = getContext();
dbHelper = new BookDbHelper(context);
return true;
}
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection,
@Nullable String selection, @Nullable String[] selectionArgs,
@Nullable String sortOrder) {
SQLiteDatabase database = dbHelper.getReadableDatabase();
Cursor cursor;
switch (uriMatcher.match(uri)) {
case BOOKS:
cursor = database.query(BookContract.BookEntry.TABLE_NAME,
projection, selection, selectionArgs, null, null, sortOrder);
break;
case BOOK_ID:
selection = BookContract.BookEntry._ID + "=?";
selectionArgs = new String[]{ String.valueOf(ContentUris.parseId(uri)) };
cursor = database.query(BookContract.BookEntry.TABLE_NAME,
projection, selection, selectionArgs, null, null, sortOrder);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
switch (uriMatcher.match(uri)) {
case BOOKS:
return "vnd.android.cursor.dir/" + AUTHORITY + "." + BookContract.BookEntry.TABLE_NAME;
case BOOK_ID:
return "vnd.android.cursor.item/" + AUTHORITY + "." + BookContract.BookEntry.TABLE_NAME;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
SQLiteDatabase database = dbHelper.getWritableDatabase();
long id;
Uri returnUri;
switch (uriMatcher.match(uri)) {
case BOOKS:
id = database.insert(BookContract.BookEntry.TABLE_NAME, null, values);
if (id > 0) {
returnUri = ContentUris.withAppendedId(CONTENT_URI, id);
} else {
throw new SQLException("Failed to insert row into " + uri);
}
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
getContext().getContentResolver().notifyChange(uri, null);
return returnUri;
}
@Override
public int delete(@NonNull Uri uri, @Nullable String selection,
@Nullable String[] selectionArgs) {
SQLiteDatabase database = dbHelper.getWritableDatabase();
int rowsDeleted;
switch (uriMatcher.match(uri)) {
case BOOKS:
rowsDeleted = database.delete(
BookContract.BookEntry.TABLE_NAME, selection, selectionArgs);
break;
case BOOK_ID:
selection = BookContract.BookEntry._ID + "=?";
selectionArgs = new String[]{ String.valueOf(ContentUris.parseId(uri)) };
rowsDeleted = database.delete(
BookContract.BookEntry.TABLE_NAME, selection, selectionArgs);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
if (rowsDeleted != 0) {
getContext().getContentResolver().notifyChange(uri, null);
}
return rowsDeleted;
}
@Override
public int update(@NonNull Uri uri, @Nullable ContentValues values,
@Nullable String selection, @Nullable String[] selectionArgs) {
SQLiteDatabase database = dbHelper.getWritableDatabase();
int rowsUpdated;
switch (uriMatcher.match(uri)) {
case BOOKS:
rowsUpdated = database.update(
BookContract.BookEntry.TABLE_NAME, values, selection, selectionArgs);
break;
case BOOK_ID:
selection = BookContract.BookEntry._ID + "=?";
selectionArgs = new String[]{ String.valueOf(ContentUris.parseId(uri)) };
rowsUpdated = database.update(
BookContract.BookEntry.TABLE_NAME, values, selection, selectionArgs);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
if (rowsUpdated != 0) {
getContext().getContentResolver().notifyChange(uri, null);
}
return rowsUpdated;
}
}
Key design points in this provider:
UriMatcher.NO_MATCHis the default return value when no URI pattern matches; the static block registers both patterns before any method runs.cursor.setNotificationUri()wires the Cursor to the URI so that any registeredContentObserveris notified when data changes.getContext().getContentResolver().notifyChange(uri, null)fires after every successful insert, update, or delete — keeping observers in sync.getReadableDatabase()is used for queries (read-only, no lock contention);getWritableDatabase()is used for mutations.
Step 4: Register the Content Provider in the Manifest
Every Content Provider must be declared in AndroidManifest.xml. The authorities attribute must exactly match the AUTHORITY constant in your provider class.
<provider
android:name=".BookContentProvider"
android:authorities="com.example.myapp.provider"
android:exported="true"
android:grantUriPermissions="true" />
| Attribute | Purpose |
|---|---|
android:name | Class name of the provider |
android:authorities | Unique authority string; must match AUTHORITY constant |
android:exported | true allows other apps to access this provider |
android:grantUriPermissions | Allows temporary URI-based permissions to be granted to other apps |
Chapter 3: Accessing Data from the Content Provider
With the provider registered, any component in any app can reach it through ContentResolver. Below is a complete MainActivity that inserts a book, queries all books, and displays the results in a TextView.
MainActivity.java
package com.example.myapp;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;
public class MainActivity extends AppCompatActivity {
private TextView textView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
textView = findViewById(R.id.textView);
// Insert a new book
ContentValues values = new ContentValues();
values.put(BookContract.BookEntry.COLUMN_NAME_TITLE, "Android Programming");
values.put(BookContract.BookEntry.COLUMN_NAME_AUTHOR, "John Doe");
Uri newUri = getContentResolver().insert(BookContentProvider.CONTENT_URI, values);
// Query all books
Cursor cursor = getContentResolver().query(
BookContentProvider.CONTENT_URI, null, null, null, null);
if (cursor != null) {
StringBuilder builder = new StringBuilder();
while (cursor.moveToNext()) {
String title = cursor.getString(
cursor.getColumnIndex(BookContract.BookEntry.COLUMN_NAME_TITLE));
String author = cursor.getString(
cursor.getColumnIndex(BookContract.BookEntry.COLUMN_NAME_AUTHOR));
builder.append("Title: ").append(title)
.append(", Author: ").append(author).append("\n");
}
cursor.close();
textView.setText(builder.toString());
}
}
}
activity_main.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="16sp" />
</LinearLayout>
What happens end-to-end:
getContentResolver().insert(...)routes through the Android system toBookContentProvider.insert(), which writes to SQLite and returns the new URI (e.g.content://com.example.myapp.provider/books/1).getContentResolver().query(...)callsBookContentProvider.query(), which performs aSELECT *and returns aCursor.- The loop iterates the
Cursorrow by row, readingtitleandauthorby column name. cursor.close()is called beforetextView.setText()to release the database resources.
Chapter 4: Putting It Together — The Full Data Flow
flowchart TD
A[AndroidManifest.xml\nregisters authority] --> B[BookContentProvider\nonCreate → BookDbHelper]
B --> C[SQLite books.db]
D[MainActivity\ngetContentResolver] --> E{ContentResolver}
E -- insert --> B
E -- query --> B
B -- Cursor --> E
E -- Cursor --> D
D --> F[TextView displays results]
The Manifest registration is the entry point — without it, no URI will resolve to your provider. BookDbHelper initializes the SQLite file on first access. Every CRUD call flows through ContentResolver → BookContentProvider → SQLiteDatabase, with the UriMatcher acting as the dispatcher at every step.
🧪 Try It Yourself
Task: Extend the books Content Provider to support deleting a book by its ID from MainActivity.
- After the existing insert and query code, add a delete call targeting the first book's URI:
// Delete the book we just inserted
if (newUri != null) {
int rowsDeleted = getContentResolver().delete(newUri, null, null);
// rowsDeleted should equal 1
}
- Re-run the query after the delete and rebuild the
StringBuilder.
Success criterion: The TextView is empty (or shows no books) after the delete, confirming that BookContentProvider.delete() matched the BOOK_ID case, targeted the correct row, and called notifyChange().
Bonus: Add a second ContentValues insert for a different author, then query with a selection of "author=?" and selectionArgs of new String[]{"John Doe"} — you should see only John Doe's book returned.
🔍 Checkpoint Quiz
Q1. What is the purpose of UriMatcher in a Content Provider implementation?
A) It validates that the SQLite schema is correctly defined
B) It maps incoming Content URIs to integer codes so provider methods can dispatch on them
C) It resolves authority strings to package names at runtime
D) It encrypts URI parameters before they reach the database
Q2. Given this query() implementation, what happens when the URI content://com.example.myapp.provider/books/42 is passed in?
switch (uriMatcher.match(uri)) {
case BOOKS:
cursor = database.query(TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder);
break;
case BOOK_ID:
selection = BookContract.BookEntry._ID + "=?";
selectionArgs = new String[]{ String.valueOf(ContentUris.parseId(uri)) };
cursor = database.query(TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder);
break;
default:
throw new IllegalArgumentException("Unknown URI: " + uri);
}
A) All books are returned because the path contains "books"
B) A query is executed for _ID = 42 only
C) An IllegalArgumentException is thrown because the URI has a numeric segment
D) ContentUris.parseId(uri) returns -1 and the query returns zero rows
Q3. Why must android:authorities in the Manifest exactly match the AUTHORITY constant defined in the provider class?
A) The system uses the authority string as the lookup key to route ContentResolver calls to the correct provider process
B) It is only needed for exported providers; internal providers ignore the authority
C) It controls the SQLite database file name on disk
D) It sets the permission scope for READ_CONTACTS
Q4. You add a ContentObserver to watch BookContentProvider.CONTENT_URI. A colleague's code inserts a new row but your observer never fires. What is the most likely cause?
A) ContentObserver cannot observe custom providers, only system providers
B) The insert method is missing the getContext().getContentResolver().notifyChange(uri, null) call
C) The observer must be registered in onStart() not onCreate()
D) cursor.setNotificationUri() was called with the wrong URI
A1. B — UriMatcher maps URI patterns to integer codes (BOOKS = 100, BOOK_ID = 101), giving provider methods a clean switch dispatch point without brittle string comparisons.
A2. B — The URI matches the BOOK_ID pattern (books/#), so ContentUris.parseId(uri) extracts 42 and the query filters to _ID = 42.
A3. A — The Android system resolves Content URIs by authority. When ContentResolver receives a URI, it looks up the registered provider whose android:authorities matches, then routes the call to that process. A mismatch means no provider is found and the call fails with a NullPointerException or silent failure.
A4. B — notifyChange() must be called after every successful mutation. Without it, registered observers receive no signal. cursor.setNotificationUri() wires queries to observe changes, but the trigger itself comes from notifyChange() on the write side.
🪞 Recap
- A Content Provider encapsulates a structured data store and exposes it through a standardized URI-based API, enabling secure cross-app data sharing.
- The three core building blocks are the Content URI (address), the ContentResolver (client), and the Cursor (result set).
UriMatchermaps URI patterns to integer codes, giving every CRUD method a clean dispatch mechanism.- Every Content Provider must be declared in
AndroidManifest.xmlwith a matchingandroid:authoritiesvalue. - After every successful insert, update, or delete, call
notifyChange()so registeredContentObserverinstances stay in sync.
📚 Further Reading
- Official Android Content Providers docs — the source of truth on provider lifecycle, permissions, and file provider patterns
- ContentResolver API reference — full method signatures for query, insert, update, delete, and bulk operations
- UriMatcher API reference — details on wildcard patterns (
#for numbers,*for strings) - SQLiteOpenHelper guide — deeper coverage of database migrations and threading considerations
- ⬅️ Previous: Android Building Blocks III: Broadcast Receivers
- ➡️ Next: Inheritance in Java