Room Database and Paging

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page covers Room, Android’s SQLite persistence library, and Paging 3, the library that loads large or network-backed lists a page at a time. Both are used throughout TrailMate, the hiking-trail companion used across this section: Room is the single source of truth for the trail catalog and its photos, and Paging loads the trail list a page at a time from Room, backed by a network RemoteMediator. This page assumes the offline-first repository pattern introduced in Recommended App Architecture; it goes into the implementation detail that page only sketches. Coroutines and Flow, which every Room DAO and Paging API in this page returns or consumes, are covered in Coroutines Basics and Flows and are not re-explained here. Networking with TrailApi is covered in Networking with Retrofit, OkHttp and Ktor, DataStore and file storage in Local Storage: DataStore, Files, Scoped Storage and MediaStore, Hilt in Dependency Injection with Hilt, Dagger and Koin, and DAO/repository unit testing in more depth in Unit Testing with JUnit, Mockito, MockK and Robolectric. The baseline for this page is Room 2.8.5 (with a look at Room 3.0.3, androidx.room3) and Paging 3.5.1.

Why Room Instead of Raw SQLite

Room is a persistence layer on top of SQLite that removes the two biggest sources of database bugs: hand-written SQL that only fails at run time, and boilerplate that maps Cursor rows to objects by hand. Room verifies every @Query against the schema at compile time — a typo in a column name or a query that does not match its return type fails the build, not a user’s device. It generates the mapping code itself, and it integrates directly with coroutines and Flow so a query can be observed reactively instead of polled.

In TrailMate, Room is the layer OfflineFirstTrailRepository treats as the single source of truth: the UI never reads the network response directly, it reads Room, and a background refresh (a periodic sync worker, a pull-to-refresh, or a RemoteMediator, covered later on this page) is the only thing allowed to write into it.

Gradle Setup: KSP and the Room Gradle Plugin

Room’s annotation processor runs through KSP (Kotlin Symbol Processing), not kapt; KSP analyzes Kotlin symbols directly instead of going through a Java stub, which builds noticeably faster. The Room Gradle plugin adds one more piece: it copies each version of the generated JSON schema into a folder in the module (schemaDirectory), which both documents every schema change over the app’s lifetime and is required for testing migrations.

// gradle/libs.versions.toml
[versions]
room = "2.8.5"
ksp = "2.3.12"

[libraries]
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-paging = { module = "androidx.room:room-paging", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" }
// core/database/build.gradle.kts
plugins {
    alias(libs.plugins.android.library) // AGP 9: Kotlin is built in, no org.jetbrains.kotlin.android
    alias(libs.plugins.ksp)
    alias(libs.plugins.room)
}

room {
    // Every schema version is exported here as a JSON file, one per version,
    // e.g. schemas/com.example.trailmate.core.database.TrailMateDatabase/3.json.
    // MigrationTestHelper (see below) reads these files to build past versions of the database.
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    implementation(libs.room.runtime)
    implementation(libs.room.ktx)
    implementation(libs.room.paging)
    ksp(libs.room.compiler)
    testImplementation(libs.room.testing)
}

KSP 2.3.12 is a KSP2 release, versioned independently of Kotlin (the old <kotlin>-<ksp> scheme such as 2.2.20-2.0.4 is gone), and because AGP 9 compiles Kotlin itself the module applies no org.jetbrains.kotlin.android plugin — the same baseline as Project Structure, Gradle and Build Variants.

The old alternative — adding room-compiler to kapt { } and passing room.schemaLocation as an annotation-processor argument — still works but is legacy; new modules should use the Room Gradle plugin and KSP as shown above.

Entities

An @Entity class maps one Kotlin class to one SQLite table. TrailMate stores two entities: TrailEntity for the trail catalog and PhotoEntity for the user’s photos of each trail.

// core/database/src/main/kotlin/com/example/trailmate/core/database/entity/TrailEntity.kt
package com.example.trailmate.core.database.entity

import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "trails",
    indices = [Index(value = ["region"]), Index(value = ["difficulty"])],
)
data class TrailEntity(
    @PrimaryKey val id: String,
    val name: String,
    val region: String,
    @ColumnInfo(name = "length_km") val lengthKm: Double,
    val difficulty: Difficulty,
    @ColumnInfo(name = "photo_url") val photoUrl: String,
    @ColumnInfo(name = "is_favorite", defaultValue = "0") val isFavorite: Boolean,
)
// core/database/src/main/kotlin/com/example/trailmate/core/database/entity/PhotoEntity.kt
package com.example.trailmate.core.database.entity

import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey

@Entity(
    tableName = "photos",
    foreignKeys = [
        ForeignKey(
            entity = TrailEntity::class,
            parentColumns = ["id"],
            childColumns = ["trail_id"],
            onDelete = ForeignKey.CASCADE,
        ),
    ],
    indices = [Index(value = ["trail_id"])],
)
data class PhotoEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    @androidx.room.ColumnInfo(name = "trail_id") val trailId: String,
    val uri: String,
    @androidx.room.ColumnInfo(name = "taken_at") val takenAt: Long,
)

@ColumnInfo(name = …​) decouples the Kotlin property name from the SQL column name (snake_case in the table, camelCase in Kotlin, a common convention). defaultValue on a column matters for auto-migrations: it lets Room add the column to existing rows without a manual migration, covered below.

Indices

An index speeds up lookups and joins on a column at the cost of extra storage and slightly slower writes. Add one with indices on @Entity (as above) whenever a column is filtered or joined on often: region and difficulty because TrailListScreen filters by both, and trail_id on photos because every relation query below joins on it and because it backs the foreign key’s cascading delete. A column that is only ever read as part of a full table scan, such as name in a screen with no search field, does not need one — indices are not free, so add them where a query plan shows they help rather than on every column defensively.

Type Converters

SQLite has no native enum or date column, so TrailEntity.difficulty (Difficulty, a Kotlin enum) needs a TypeConverter that tells Room how to store and read it back.

// core/database/src/main/kotlin/com/example/trailmate/core/database/Converters.kt
package com.example.trailmate.core.database

import androidx.room.TypeConverter
import com.example.trailmate.core.model.Difficulty
import java.time.Instant

class Converters {
    @TypeConverter
    fun fromDifficulty(value: Difficulty): String = value.name

    @TypeConverter
    fun toDifficulty(value: String): Difficulty = Difficulty.valueOf(value)

    @TypeConverter
    fun fromInstant(value: Instant?): Long? = value?.toEpochMilli()

    @TypeConverter
    fun toInstant(value: Long?): Instant? = value?.let(Instant::ofEpochMilli)
}

The converter class is registered once, on the database (@TypeConverters(Converters::class), shown below), and applies to every entity and DAO query in it. Keep converters pure and cheap — they run on every row of every query that touches that column, on whatever thread the query runs on.

Relations: @Relation and @Embedded

A trail and its photos are stored in two tables but are almost always read together. @Relation tells Room to run a second query and assemble the result into a single Kotlin object, so the repository never has to join manually.

// core/database/src/main/kotlin/com/example/trailmate/core/database/entity/TrailWithPhotos.kt
package com.example.trailmate.core.database.entity

import androidx.room.Embedded
import androidx.room.Relation

data class TrailWithPhotos(
    @Embedded val trail: TrailEntity,
    @Relation(parentColumn = "id", entityColumn = "trail_id")
    val photos: List<PhotoEntity>,
)

@Embedded flattens TrailEntity’s own columns directly into the result (no `trail_ prefix needed here because the outer holder class is not itself a table); @Relation tells Room which parent and child column join the two tables. Room issues one query for the parent rows and one follow-up query per relation, not a SQL JOIN, which avoids duplicating trail columns once per photo. The same @Embedded annotation is also used the other way, to group a handful of columns of one entity into a nested value class (a Coordinates(lat: Double, lon: Double) field embedded inside TrailEntity, for example) without a separate table.

Data Access Objects (DAOs)

A @Dao interface declares the queries Room generates an implementation for. Returning Flow<T> from a query makes it reactive: Room re-runs the query and re-emits automatically whenever a write touches a table the query reads from, so TrailListViewModel never needs to refresh anything manually after an insert.

// core/database/src/main/kotlin/com/example/trailmate/core/database/dao/TrailDao.kt
package com.example.trailmate.core.database.dao

import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Upsert
import com.example.trailmate.core.database.entity.PhotoEntity
import com.example.trailmate.core.database.entity.TrailEntity
import com.example.trailmate.core.database.entity.TrailWithPhotos
import kotlinx.coroutines.flow.Flow

@Dao
interface TrailDao {

    @Query("SELECT * FROM trails WHERE region = :region ORDER BY name")
    fun observeTrails(region: String): Flow<List<TrailEntity>>

    @Transaction
    @Query("SELECT * FROM trails WHERE id = :trailId")
    fun observeTrailWithPhotos(trailId: String): Flow<TrailWithPhotos?>

    @Transaction
    @Query("SELECT * FROM trails ORDER BY name")
    fun pagingSource(): PagingSource<Int, TrailEntity>

    @Upsert
    suspend fun upsertAll(trails: List<TrailEntity>)

    @Upsert
    suspend fun upsertPhoto(photo: PhotoEntity)

    @Transaction
    suspend fun replaceRegion(region: String, trails: List<TrailEntity>) {
        deleteRegion(region)
        upsertAll(trails)
    }

    @Query("DELETE FROM trails WHERE region = :region")
    suspend fun deleteRegion(region: String)
}

Three things worth calling out:

  • @Upsert (added in Room 2.5) inserts a row if its primary key is new and updates it otherwise, in one call — the common case for syncing server data, which used to need a manual @Insert(onConflict = REPLACE) plus a separate @Update fallback.

  • @Transaction on a multi-step suspend function (replaceRegion) makes the delete-then-upsert atomic, so a reader never observes a moment with zero trails for that region. @Transaction on a @Query that returns a @Relation (observeTrailWithPhotos, pagingSource) makes the parent query and each relation’s follow-up query run inside one transaction, so the result is a consistent snapshot even if a write lands between them.

  • fun pagingSource(): PagingSource<Int, TrailEntity> needs no method body and no @Transaction-generated SQL beyond the @Query itself — Room generates a PagingSource implementation that loads pages of rows directly from SQLite, keyed by row offset. This is the DAO method Pager uses in the Paging section below.

The Database Class

@Database ties the entities, the DAOs and the type converters together and is the class the rest of the app depends on.

// core/database/src/main/kotlin/com/example/trailmate/core/database/TrailMateDatabase.kt
package com.example.trailmate.core.database

import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RenameColumn
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.example.trailmate.core.database.dao.PhotoDao
import com.example.trailmate.core.database.dao.RemoteKeyDao
import com.example.trailmate.core.database.dao.TrailDao
import com.example.trailmate.core.database.entity.PhotoEntity
import com.example.trailmate.core.database.entity.RemoteKeyEntity
import com.example.trailmate.core.database.entity.TrailEntity

@Database(
    entities = [TrailEntity::class, PhotoEntity::class, RemoteKeyEntity::class],
    version = 3,
    autoMigrations = [
        AutoMigration(from = 1, to = 2),
        AutoMigration(from = 2, to = 3, spec = TrailMateDatabase.AddFavoriteColumnSpec::class),
    ],
    exportSchema = true,
)
@TypeConverters(Converters::class)
abstract class TrailMateDatabase : RoomDatabase() {
    abstract fun trailDao(): TrailDao
    abstract fun photoDao(): PhotoDao
    abstract fun remoteKeyDao(): RemoteKeyDao

    @RenameColumn(tableName = "trails", fromColumnName = "favorite", toColumnName = "is_favorite")
    class AddFavoriteColumnSpec : AutoMigrationSpec
}

abstract fun trailDao(): TrailDao is all a DAO accessor needs — Room implements it. exportSchema = true (the default) is what makes the Room Gradle plugin write a JSON snapshot of the schema into schemaDirectory on every build; that file is what both auto-migrations and MigrationTestHelper validate against, so it must be checked into version control alongside the entity change that produced it.

Migrations

Every schema change bumps version on @Database and needs a migration path, or Room throws IllegalStateException the first time the app opens an updated database it does not know how to read.

Auto-migrations

An auto-migration covers changes Room can infer purely from the two exported schemas: adding a table, adding a column with a defaultValue, or (with an AutoMigrationSpec, as above) renaming a table or column, which Room cannot tell apart from a drop-and-add without a hint. Declaring AutoMigration(from = 1, to = 2) with no spec is enough when the change is simple, as it is from version 1 to 2 above.

Manual migrations

A migration Room cannot infer — splitting a column into two, backfilling data with a computed value, changing a column’s type — needs a hand-written Migration that runs raw SQL against the old schema.

// core/database/src/main/kotlin/com/example/trailmate/core/database/migrations.kt
package com.example.trailmate.core.database

import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase

val MIGRATION_3_4 = object : Migration(3, 4) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE trails ADD COLUMN length_miles REAL NOT NULL DEFAULT 0")
        db.execSQL("UPDATE trails SET length_miles = length_km * 0.621371")
    }
}

A manual Migration is passed to the database builder alongside any auto-migrations:

Room.databaseBuilder(context, TrailMateDatabase::class.java, "trailmate.db")
    .addMigrations(MIGRATION_3_4)
    .build()

Testing migrations with MigrationTestHelper

MigrationTestHelper builds a real SQLite database at a past exported schema version, runs a migration against it, and asserts the result matches the next exported schema — catching a migration bug long before it reaches a device with real user data.

// core/database/src/androidTest/kotlin/com/example/trailmate/core/database/MigrationTest.kt
package com.example.trailmate.core.database

import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Rule
import org.junit.Test

class MigrationTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        TrailMateDatabase::class.java,
        emptyList(),
        FrameworkSQLiteOpenHelperFactory(),
    )

    @Test
    fun migrate3To4_addsLengthMilesColumn() {
        helper.createDatabase("trailmate-test.db", 3).apply {
            execSQL(
                "INSERT INTO trails (id, name, region, length_km, difficulty, photo_url, is_favorite) " +
                    "VALUES ('t1', 'Ridge Loop', 'alps', 12.0, 'MODERATE', 'https://example.com/p.jpg', 0)",
            )
            close()
        }

        val migrated = helper.runMigrationsAndValidate("trailmate-test.db", 4, true, MIGRATION_3_4)

        migrated.query("SELECT length_miles FROM trails WHERE id = 't1'").use { cursor ->
            check(cursor.moveToFirst())
            check(cursor.getDouble(0) > 7.4 && cursor.getDouble(0) < 7.5)
        }
    }
}

`runMigrationsAndValidate’s last argument runs Room’s own schema validation, which fails the test if the migrated schema does not exactly match the exported JSON for that version — so a manual migration that forgets a column or an index is caught here, not in production. See Android Developers — Migrate your Room database and Android Developers — MigrationTestHelper.

Prepopulating the Database

An app that ships with a starter dataset — TrailMate could ship a handful of well-known trails so the list is not empty on first launch, offline, before the first sync — can prepopulate the database from a bundled asset instead of inserting rows in code on first run.

Room.databaseBuilder(context, TrailMateDatabase::class.java, "trailmate.db")
    .createFromAsset("database/trailmate_seed.db")
    .build()

The asset is a real SQLite file matching version 1’s schema exactly (built once, offline, from the same entities); Room copies it in on first open and then runs the usual migrations up to the current version. createFromFile and createFromInputStream cover the same idea for a file outside assets or a stream produced at run time (for example downloaded once and cached).

A Hilt-Provided Singleton

Exactly one RoomDatabase instance per file should exist for the life of the process — creating a second one against the same file wastes memory and risks lock contention. Hilt enforces this by scoping the provider to SingletonComponent.

// app/src/main/kotlin/com/example/trailmate/di/DatabaseModule.kt
package com.example.trailmate.di

import android.content.Context
import androidx.room.Room
import com.example.trailmate.core.database.MIGRATION_3_4
import com.example.trailmate.core.database.TrailMateDatabase
import com.example.trailmate.core.database.dao.TrailDao
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

    @Provides
    @Singleton
    fun provideTrailMateDatabase(@ApplicationContext context: Context): TrailMateDatabase =
        Room.databaseBuilder(context, TrailMateDatabase::class.java, "trailmate.db")
            .addMigrations(MIGRATION_3_4)
            .build()

    @Provides
    fun provideTrailDao(database: TrailMateDatabase): TrailDao = database.trailDao()
}

provideTrailDao is intentionally un-scoped: it is cheap to call and Hilt already guarantees the TrailMateDatabase it reads from is a singleton, so every caller ends up sharing the same DAO backed by the same connection pool regardless.

Main-Safety

Every Room query that touches disk must run off the main thread. Suspend DAO functions and Flow-returning queries are main-safe automatically — Room dispatches them to its own internal executor — which is why every DAO method above is either suspend or returns Flow. Room additionally throws at run time if a non-suspend, non-Flow query is called from the main thread, unless allowMainThreadQueries() is explicitly opted into on the builder. The check is not limited to debug builds — a release build throws the same IllegalStateException — and allowMainThreadQueries() is a test-only escape hatch that should never ship. This mirrors the dispatcher-injection discipline covered in ViewModel, Lifecycle-Aware Components, Coroutines and Flow — a repository built on Room-only DAOs needs no explicit withContext(Dispatchers.IO) around a query, because Room already guarantees it never blocks the caller’s thread.

Full-Text Search (FTS)

For a column that needs substring or token search rather than an exact match — searching trail names and descriptions by keyword, for example — Room can back a table with SQLite’s FTS4 virtual table module instead of an ordinary table.

@Fts4(contentEntity = TrailEntity::class)
@Entity(tableName = "trails_fts")
data class TrailFts(val name: String, val region: String)

@Dao
interface TrailSearchDao {
    @Query("SELECT trails.* FROM trails JOIN trails_fts ON trails.rowid = trails_fts.rowid WHERE trails_fts MATCH :query")
    fun search(query: String): Flow<List<TrailEntity>>
}

contentEntity keeps the FTS index in sync with TrailEntity automatically instead of duplicating the data by hand. FTS is worth reaching for only once a plain LIKE '%query%' query becomes a measured bottleneck or the search needs token-aware matching (prefix search, ranking); for a short list like TrailMate’s own catalog a plain indexed LIKE query is usually enough.

Room 3.0 (androidx.room3) Migration Notes

Room 3.0 (stable since mid-2026) ships as a new Maven group and package, androidx.room3, side by side with the 2.x androidx.room artifacts rather than as an in-place upgrade — so migrating is a deliberate, versioned step, not a patch update. The changes that matter when planning that migration:

Change What it means for TrailMate

KSP-only

kapt support for the Room compiler is dropped; a module still using kapt for Room must move to KSP first (see the Gradle setup section above), independently of the Room 3 migration itself.

Kotlin codegen only

The generated DAO/database implementations are Kotlin, not Java, changing nothing about how TrailDao or TrailMateDatabase are called from Kotlin, but ending Java-source interop with generated Room code.

Full Kotlin Multiplatform support

TrailMateDatabase could move into a shared commonMain source set and back a future non-Android target (desktop, iOS) with the same entities and DAOs, using the platform-specific RoomDatabase.Builder factory each target provides.

Package rename

Every import moves from androidx.room. to androidx.room3.; the annotation names, DAO patterns, @Upsert, @Relation, migrations and MigrationTestHelper usage shown throughout this page carry over unchanged, only the import and Gradle coordinates change.

Because the two live in different Maven groups, a large codebase can migrate module by module rather than in one sweep; TrailMate’s :core:database module is a natural first candidate precisely because Paging’s RemoteMediator and DAOs already isolate every other module from Room-specific types behind the repository interface. See Android Developers — Room 3.0 release notes.

See also Local Storage: SQLDelight, Room, SQLite Drivers, DataStore and Settings in the Kotlin Multiplatform section for Room 3 in shared code.

Testing DAOs with an In-Memory Database

A DAO test does not need a real file on disk or an emulator’s SQLite build — an in-memory database is faster to create and tear down, and disappears automatically at the end of the test.

// core/database/src/androidTest/kotlin/com/example/trailmate/core/database/TrailDaoTest.kt
package com.example.trailmate.core.database

import androidx.room.Room
import androidx.test.platform.app.InstrumentationRegistry
import com.example.trailmate.core.database.entity.Difficulty
import com.example.trailmate.core.database.entity.TrailEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals

class TrailDaoTest {

    private lateinit var database: TrailMateDatabase

    @Before
    fun createDatabase() {
        database = Room.inMemoryDatabaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            TrailMateDatabase::class.java,
        ).allowMainThreadQueries().build()
    }

    @After
    fun closeDatabase() {
        database.close()
    }

    @Test
    fun upsertAll_thenObserveTrails_emitsInsertedRows() = runTest {
        val trail = TrailEntity(
            id = "t1", name = "Ridge Loop", region = "alps", lengthKm = 12.0,
            difficulty = Difficulty.MODERATE, photoUrl = "https://example.com/p.jpg", isFavorite = false,
        )

        database.trailDao().upsertAll(listOf(trail))
        val result = database.trailDao().observeTrails("alps").first()

        assertEquals(listOf(trail), result)
    }
}

inMemoryDatabaseBuilder (rather than databaseBuilder) is the only difference from production setup; allowMainThreadQueries() is acceptable here because the test itself controls the thread the query runs on and values immediate, synchronous failures over the small realism gap. This test needs an Android runtime (a device, emulator, or Robolectric) because Room delegates to the platform’s SQLite implementation; a pure JVM unit test without one of those cannot open a Room database at all. See Android Developers — Test your Room database.

Paging 3

Loading TrailMate’s entire trail catalog into memory at once does not scale once the catalog grows past a few hundred trails, and re-querying "give me the next 20" by hand means hand-rolling offset tracking, duplicate-row edge cases and loading-state bookkeeping. Paging 3 does that work once, correctly, and integrates with both Compose and RecyclerView.

PagingSource, Pager, PagingData and cachedIn

A PagingSource<Key, Value> loads one page at a time, given a key (the previous page’s next key, or null for the first page) and a load size. Room already generates one from @Query fun pagingSource(): PagingSource<Int, TrailEntity> (shown earlier), so OfflineFirstTrailRepository only has to wrap it in a Pager:

// core/data/src/main/kotlin/com/example/trailmate/core/data/OfflineFirstTrailRepository.kt
package com.example.trailmate.core.data

import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.example.trailmate.core.database.dao.TrailDao
import com.example.trailmate.core.model.Trail
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject

class OfflineFirstTrailRepository @Inject constructor(
    private val trailDao: TrailDao,
) : TrailRepository {

    override fun observeTrailsPaged(): Flow<PagingData<Trail>> =
        Pager(
            config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false),
        ) {
            trailDao.pagingSource()
        }.flow.map { pagingData -> pagingData.map(TrailEntity::toDomain) }
}

PagingData<T> is a Flow-friendly, immutable snapshot of loaded pages plus their LoadState; it is not a List and cannot be collected into one, by design, because the whole point of paging is that the full list is never materialized at once. .map on PagingData (Paging’s own operator, not Flow.map) transforms each item, here from TrailEntity to the domain Trail model, without touching how paging itself works.

The ViewModel exposes that Flow<PagingData<Trail>> with .cachedIn(viewModelScope):

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListViewModel.kt
package com.example.trailmate.feature.trails

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.trailmate.core.data.TrailRepository
import com.example.trailmate.core.model.Trail
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import dagger.hilt.android.lifecycle.HiltViewModel

@HiltViewModel
class TrailListViewModel @Inject constructor(
    trailRepository: TrailRepository,
) : ViewModel() {

    val pagedTrails: Flow<PagingData<Trail>> =
        trailRepository.observeTrailsPaged().cachedIn(viewModelScope)
}

cachedIn multicasts the paged stream and keeps already-loaded pages alive across configuration changes and multiple collectors, so rotating the device does not re-run every page load from scratch. Without it, each new collector (each recomposition that resubscribes, or a fresh Fragment view after rotation) would restart paging from page one.

Consuming PagingData in Compose: collectAsLazyPagingItems

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
package com.example.trailmate.feature.trails

import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey

@Composable
fun TrailListScreen(viewModel: TrailListViewModel = hiltViewModel()) {
    val trails = viewModel.pagedTrails.collectAsLazyPagingItems()

    LazyColumn {
        items(count = trails.itemCount, key = trails.itemKey { it.id }) { index ->
            trails[index]?.let { trail -> TrailRow(trail) }
        }

        when (val append = trails.loadState.append) {
            is LoadState.Loading -> item { CircularProgressIndicator() }
            is LoadState.Error -> item { Text("Couldn't load more trails: ${append.error.message}") }
            is LoadState.NotLoading -> Unit
        }
    }
}

trails[index] returns null for a not-yet-loaded item when placeholders are enabled, or triggers a load when placeholders are disabled (as configured above); either way the lambda must tolerate a null item. itemKey gives LazyColumn a stable key per trail so Compose can diff insertions correctly instead of by position.

Consuming PagingData with Views: PagingDataAdapter

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailPagingAdapter.kt
package com.example.trailmate.feature.trails

import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import com.example.trailmate.core.model.Trail

class TrailPagingAdapter : PagingDataAdapter<Trail, TrailViewHolder>(TRAIL_DIFF) {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
        TrailViewHolder.create(parent)

    override fun onBindViewHolder(holder: TrailViewHolder, position: Int) {
        getItem(position)?.let(holder::bind)
    }

    companion object {
        private val TRAIL_DIFF = object : DiffUtil.ItemCallback<Trail>() {
            override fun areItemsTheSame(old: Trail, new: Trail) = old.id == new.id
            override fun areContentsTheSame(old: Trail, new: Trail) = old == new
        }
    }
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListFragment.kt
viewLifecycleOwner.lifecycleScope.launch {
    viewModel.pagedTrails.collectLatest { pagingData -> adapter.submitData(pagingData) }
}

PagingDataAdapter is a ListAdapter variant that already knows how to diff two PagingData snapshots using the supplied DiffUtil.ItemCallback; submitData (a suspend function) replaces submitList and must be called from a lifecycle-aware coroutine (collectLatest inside repeatOnLifecycle/viewLifecycleOwner.lifecycleScope, as covered in ViewModel, Lifecycle-Aware Components, Coroutines and Flow) so paging stops collecting while the view is stopped.

RemoteMediator: Offline-First Paging with Remote Keys

A Pager built directly over trailDao.pagingSource() (as shown above) only ever pages through rows already in Room — it never itself calls the network. A RemoteMediator plugs into the same Pager and is invoked whenever Paging runs out of locally cached rows in a given direction, so the whole pipeline stays offline-first: the UI always reads Room, and the mediator’s only job is keeping Room’s cache one network page ahead of what has been displayed.

// core/data/src/main/kotlin/com/example/trailmate/core/data/TrailRemoteMediator.kt
package com.example.trailmate.core.data

import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.example.trailmate.core.database.TrailMateDatabase
import com.example.trailmate.core.database.entity.RemoteKeyEntity
import com.example.trailmate.core.database.entity.TrailEntity
import com.example.trailmate.core.network.TrailApi

@OptIn(ExperimentalPagingApi::class)
class TrailRemoteMediator(
    private val region: String,
    private val trailApi: TrailApi,
    private val database: TrailMateDatabase,
) : RemoteMediator<Int, TrailEntity>() {

    override suspend fun load(loadType: LoadType, state: PagingState<Int, TrailEntity>): MediatorResult {
        val page = when (loadType) {
            LoadType.REFRESH -> 1
            LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
            LoadType.APPEND -> {
                val remoteKey = database.remoteKeyDao().remoteKeyForRegion(region)
                    ?: return MediatorResult.Success(endOfPaginationReached = true)
                remoteKey.nextPage ?: return MediatorResult.Success(endOfPaginationReached = true)
            }
        }

        return try {
            val response = trailApi.getTrails(page = page, region = region)
            database.withTransaction {
                if (loadType == LoadType.REFRESH) {
                    database.trailDao().deleteRegion(region)
                }
                database.remoteKeyDao().upsert(
                    RemoteKeyEntity(region = region, nextPage = if (response.isLast) null else page + 1),
                )
                database.trailDao().upsertAll(response.trails.map { it.toEntity() })
            }
            MediatorResult.Success(endOfPaginationReached = response.isLast)
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

The RemoteKeyEntity table (see the figure below) is what makes this resumable and consistent: it is the only place the "next page to fetch" number lives, so an APPEND load after process death picks up exactly where the last one left off instead of guessing from row counts. LoadType.PREPEND returns immediately because TrailMate’s API is not reverse-paginated — there is nothing "before" page one. The whole database write runs inside withTransaction, so a reader observing Room mid-load never sees the old page deleted without the new page in place yet.

RemoteMediator is wired into the same Pager as before:

Pager(
    config = PagingConfig(pageSize = 20, prefetchDistance = 5),
    remoteMediator = TrailRemoteMediator(region, trailApi, database),
) {
    database.trailDao().pagingSource()
}.flow

LoadState

Every PagingData snapshot carries a LoadState for three load directions — refresh (the initial load or a retry), prepend and append — each one of NotLoading, Loading or Error. Both consumers shown above read loadState.append to show a footer spinner or error row; a full-screen loading or error state (for the very first page, before anything is on screen) reads loadState.refresh instead:

when (val refresh = trails.loadState.refresh) {
    is LoadState.Loading -> FullScreenLoading()
    is LoadState.Error -> FullScreenError(message = refresh.error.message, onRetry = trails::retry)
    is LoadState.NotLoading -> Unit
}

trails.retry() re-issues the load that last failed (the RemoteMediator’s `load call included), which is usually all a "Retry" button needs to call.

Testing Paging with asSnapshot

asSnapshot (from androidx.paging:paging-testing) drives a Flow<PagingData<T>> to completion in a test and returns a plain List<T>, without needing a RecyclerView, a Compose UI, or a running RemoteMediator loop to observe.

// core/data/src/test/kotlin/com/example/trailmate/core/data/OfflineFirstTrailRepositoryTest.kt
package com.example.trailmate.core.data

import androidx.paging.testing.asSnapshot
import kotlinx.coroutines.test.runTest
import kotlin.test.assertEquals

class OfflineFirstTrailRepositoryTest {

    @Test
    fun observeTrailsPaged_returnsAllSeededTrails() = runTest {
        val repository = OfflineFirstTrailRepository(fakeTrailDao)

        val snapshot = repository.observeTrailsPaged().asSnapshot()

        assertEquals(listOf("Ridge Loop", "Coastal Path"), snapshot.map { it.name })
    }
}

asSnapshot accepts an optional lambda to drive scroll-triggered loads (onListen/appendScrollWhile) when the test needs to exercise pagination past the first page; for a small fake dataset like the one above the default behavior — load everything the source produces — is enough. See Android Developers — Test Paging.

Room and Paging Together

The figure below shows how the pieces on this page fit into OfflineFirstTrailRepository: Room’s entities and DAOs (including the RemoteKeyEntity/RemoteKeyDao pair a RemoteMediator needs), the Pager built on top of them, and the two ways PagingData is consumed depending on whether the screen is built with Compose or Views.

Diagram of Room and Paging in TrailMate. A box labelled TrailMateDatabase (RoomDatabase) contains three entities — TrailEntity with indices and type converters, PhotoEntity with a foreign key on trailId, and RemoteKeyEntity holding a nextKey per region for the RemoteMediator — each feeding its own DAO: TrailDao (Flow queries, @Upsert, @Transaction, pagingSource()) and RemoteKeyDao. A note states the database is provided as an application-scoped singleton by Hilt’s DatabaseModule. Below, an OfflineFirstTrailRepository box builds a Pager with a remoteMediator = TrailRemoteMediator, backed by trailDao.pagingSource(); Room is the single source of truth and the RemoteMediator fetches pages from TrailApi and upserts them into Room. An arrow leads down to a Flow<PagingData<Trail>> box marked .cachedIn(viewModelScope). Two arrows fan out from there to two consumer boxes: TrailListScreen, using collectAsLazyPagingItems() with a LazyColumn and a LoadState footer, and TrailListFragment, using a TrailPagingAdapter that extends PagingDataAdapter<Trail, TrailViewHolder>.

Common Mistakes

  • Running a blocking Room query on the main thread. Only suspend functions and Flow-returning queries are main-safe; a plain, non-suspend List<TrailEntity>-returning query called directly from a click handler throws IllegalStateException at run time, in release builds as well as debug ones (unless the builder called allowMainThreadQueries(), which should be reserved for tests).

  • Forgetting to bump version or export a schema. A schema change with no version bump crashes every existing install on the next open; exportSchema = true with no schemaDirectory configured only produces a build warning, not test coverage — both need to be present together for migrations to be testable at all.

  • Collecting PagingData without cachedIn. Every new collector re-triggers Pager’s initial load from page one; on a screen that recomposes often, or a `Fragment view recreated on every rotation, this reloads the whole first page repeatedly with no benefit.

  • A RemoteMediator that does not persist a remote key. Without a RemoteKeyEntity-style table, there is no way to resume APPEND after process death except re-deriving the next page from row counts, which breaks the moment a row is deleted locally (a favorite removed, a stale trail purged) and shifts the count.

  • Treating PagingData like a List. It cannot be collected into a List, filtered, or measured for size outside of PagingData.filter/.map and the adapter’s own itemCount; code that needs a plain, complete list (an export feature, a "select all" action) should query the DAO directly instead of going through Paging.