Jetpack Libraries: Room Relations, Paging, Navigation and Offline-First

Android Development with Kotlin: 21 Real Projects · Architecture, Data and Networking · 4 min read · Updated

Offline-first

  1. The screen only reads from the local database, so it works with no connection.
  2. The repository refreshes in the background.
  3. New data comes from the network.
  4. The repository writes it to Room, and the screen updates by itself.

Jetpack is the set of Google libraries that solve common app problems. You already used ViewModel, Room, DataStore, WorkManager and Navigation. This lesson adds the rest of the ones you will meet at work.

Room relationships

One-to-many: a user has many orders. Put a userId foreign key in the orders table, and read both with an @Relation class.

@Entity data class User(@PrimaryKey val id: Int, val name: String)
@Entity(foreignKeys = [ForeignKey(User::class, ["id"], ["userId"], onDelete = ForeignKey.CASCADE)])
data class Order(@PrimaryKey(autoGenerate = true) val id: Int = 0, val userId: Int, val total: Long)

data class UserWithOrders(
    @Embedded val user: User,
    @Relation(parentColumn = "id", entityColumn = "userId") val orders: List<Order>
)

@Dao interface UserDao {
    @Transaction
    @Query("SELECT * FROM User WHERE id = :id")
    fun userWithOrders(id: Int): Flow<UserWithOrders>
}

One-to-one is the same with a single object instead of a list. Many-to-many needs a *join table* (for example StudentCourse) and @Relation(associateBy = Junction(StudentCourse::class)). @Transaction makes sure several reads or writes happen together, or not at all. Mark foreignKeys with CASCADE so deleting a parent removes its children.

Migrations

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("ALTER TABLE Note ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0")
    }
}
Room.databaseBuilder(context, AppDatabase::class.java, "app.db").addMigrations(MIGRATION_1_2).build()

Turn on exportSchema and keep the exported JSON in git so you can write migration tests. Never use fallbackToDestructiveMigration in a released app unless losing user data is acceptable.

Proto DataStore and database encryption

Preferences DataStore stores key-value pairs. Proto DataStore stores a typed object defined by a schema, which prevents key typos. Room itself is not encrypted. For sensitive data, either encrypt the fields you store or use an encrypted SQLite build such as SQLCipher, and think hard about whether you need to store the data at all.

Paging 3

Paging loads a long list in pages as the user scrolls. A PagingSource loads one page (from the network or Room), a Pager turns it into a Flow<PagingData<T>>, and Compose shows it with collectAsLazyPagingItems().

val posts: Flow<PagingData<Post>> = Pager(PagingConfig(pageSize = 20)) { PostSource(api) }
    .flow.cachedIn(viewModelScope)

@Composable fun PostList(vm: PostViewModel) {
    val items = vm.posts.collectAsLazyPagingItems()
    LazyColumn {
        items(items.itemCount) { index -> items[index]?.let { PostRow(it) } }
    }
}
  • Pass an id, not an object: route detail/{id} with navArgument("id") { type = NavType.IntType }.
  • Add deepLinks = listOf(navDeepLink { uriPattern = "myapp://item/{id}" }) to a destination, and an intent filter in the manifest.
  • Group screens with navigation(startDestination = "login", route = "auth") { ... } (nested graph) so you can leave the whole group at once.
  • For bottom navigation, save and restore state with launchSingleTop = true; restoreState = true and popUpTo(startDestination) { saveState = true }.

SplashScreen, App Startup, Activity Result and CameraX

  • SplashScreen API: installSplashScreen() before super.onCreate shows a consistent launch screen on all Android versions.
  • App Startup: initialises libraries in a defined order from one place, so many libraries do not each slow startup.
  • Activity Result APIs: rememberLauncherForActivityResult for permissions, picking photos (PickVisualMedia) and documents.
  • SavedStateHandle: ViewModel access to navigation arguments and values that survive process death.
  • CameraX: a simple camera API with use cases for preview, image capture and analysis (used in the QR project).

Offline-first caching

The recipe: the UI observes Room; a refresh() function fetches from the network and writes into Room; errors only show a message because old data is still visible. Add a lastUpdated time to decide when to refresh. Use WorkManager to sync in the background with a network constraint.

Your task

  1. Add a one-to-many relation (category and expenses) to project 7.
  2. Add a migration that adds a column and write a note about how you would test it.
  3. Use Paging 3 to show 1,000 fake items in a LazyColumn.
  4. Add a deep link to a detail screen and open it from adb.

How to check it worked

  • Relations load with one query.
  • A migration keeps old data.
  • You can describe the offline-first flow.

Frequently asked questions

Why use a join table?

A many-to-many relation cannot be stored in one column, so a third table holds pairs of ids, one row for each link.

Why Paging 3?

It loads only what the user is about to see, caches pages and handles loading and error states, so big lists do not use much memory or data.

What does offline-first mean?

The local database is the source of truth. The network only refreshes it, so the app works without a connection.