Topic 22 of 28 · Android Native Developer

Topic 8 : Android Building Blocks -IV : Content Providers

Lesson TL;DRTopic 8: Android Building Blocks IV : Content Providers 📖 11 min read · 🎯 intermediate · 🧭 Prerequisites: androidbuildingblocksiiibroadcastreceivers, polymorphisminjava Why this matters Up until no...
11 min read·intermediate·android · content-providers · sqlite · content-resolver

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:

MethodDescription
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_MATCH is 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 registered ContentObserver is 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" />
AttributePurpose
android:nameClass name of the provider
android:authoritiesUnique authority string; must match AUTHORITY constant
android:exportedtrue allows other apps to access this provider
android:grantUriPermissionsAllows 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:

  1. getContentResolver().insert(...) routes through the Android system to BookContentProvider.insert(), which writes to SQLite and returns the new URI (e.g. content://com.example.myapp.provider/books/1).
  2. getContentResolver().query(...) calls BookContentProvider.query(), which performs a SELECT * and returns a Cursor.
  3. The loop iterates the Cursor row by row, reading title and author by column name.
  4. cursor.close() is called before textView.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 ContentResolverBookContentProviderSQLiteDatabase, 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.

  1. 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
}
  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).
  • UriMatcher maps URI patterns to integer codes, giving every CRUD method a clean dispatch mechanism.
  • Every Content Provider must be declared in AndroidManifest.xml with a matching android:authorities value.
  • After every successful insert, update, or delete, call notifyChange() so registered ContentObserver instances stay in sync.

📚 Further Reading

Like this topic? It’s one of 28 in Android Native Developer.

Block your seat for ₹2,500 and join the next cohort.