Techbase Academy
Android · Lesson 34

Persist Data with Room

14 phasesPocket Expense TrackerAndroid Studio
Phase 1 of 14
Lesson Introduction

In lesson 32 you learned SQL. Now you will use it from Kotlin. Room is the Jetpack library that gives your app a SQLite database without you writing cursor loops or SQL boilerplate. You describe your tables as Kotlin classes, your queries as annotated functions, and Room generates the code at build time and checks your SQL while you compile.

You will build Inventory, a stock-keeping app for a small shop (think of a provision store in Bodija market): a list of items with name, price and quantity, a screen to add an item, and a screen to view, sell, edit and delete one. All data stays on the phone and survives closing the app, rebooting the phone and process death. Then you will plan the Bus Schedule practice app.

This lesson covers the four items of Pathway 2, Unit 6 of the official Android Basics with Compose course that deal with Room: Using Room Kotlin APIs, Persist data with Room, Read and update data with Room and Practice: Build Bus Schedule app.

A note on versions. Room has a new major version, Room 3.0, published under new names (androidx.room3). Room 2.x (androidx.room) is in maintenance mode. Older tutorials, including some Google codelabs, still show Room 2.x. This lesson teaches the current Room 3 API, shows the Room 2.x equivalent wherever it differs, and tells you which to check. Always read the official setup page for the exact current versions and imports (https://developer.android.com/training/data-storage/room), because this is a fast-moving library.

By the end you will be able to:

  • Define an entity, a DAO and a database class.
  • Add Room with KSP using the version catalog.
  • Write @Insert, @Update, @Delete and @Query functions, using suspend and Flow.
  • Create one database instance and expose it through a repository and the app container (lesson 30).
  • Build ViewModels that expose database data as StateFlow with stateIn(WhileSubscribed(5000)).
  • Validate input before saving, and format money safely.
  • Inspect the live database with Android Studio's Database Inspector.
  • Test a DAO with an in-memory database.
  • Explain schema migrations.

Big idea: Room turns "rows in a table" into "objects in a list", and turns "the table changed" into "the Flow emitted again". Your UI never asks "did anything change?". It just collects.

✏️ Your Task
Practise what you just learned about Lesson Introduction. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 2 of 14
Prerequisite Concepts
  • SQL (lesson 32). You will write SELECT, WHERE, ORDER BY inside annotations.
  • Flow and stateIn (lesson 33).
  • Repositories and the app container (lesson 30).
  • Coroutines and suspend (lesson 28).
  • ViewModel, UI state, and navigation (lessons 24 to 26).
✏️ Your Task
Practise what you just learned about Prerequisite Concepts. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 3 of 14
Conceptual Understanding: The Three Room Pieces
text
  Kotlin data class          interface of functions        abstract class
 +------------------+       +------------------------+    +--------------------+
 |  @Entity  Item   | <---- |  @Dao  ItemDao         | <--|  @Database         |
 |  one TABLE       |       |  @Insert @Query ...    |    |  InventoryDatabase |
 +------------------+       +------------------------+    +--------------------+
   describes a row           describes operations           ties it together;
                                                            gives you the DAO
Piece What it is Compares to SQL
Entity A data class annotated @Entity; one instance is one row CREATE TABLE
DAO (data access object) An interface annotated @Dao whose functions are the queries SELECT, INSERT, UPDATE, DELETE
Database An abstract class annotated @Database that lists entities and exposes DAOs The database file

Room generates the implementation of the DAO and the database for you at build time. Because it reads your annotations and SQL during compilation, mistakes become build errors.

Why suspend and Flow?

A database is slow compared with memory (it reads a file), so Room refuses to run queries on the main thread. Two ways to follow that rule:

DAO function style Use for Example
suspend fun One-shot work: insert, update, delete, "get once" suspend fun insert(item: Item)
Function returning Flow<T> Observing: emits the first result, then again whenever the table changes fun getAllItems(): Flow<List<Item>>

A Flow-returning function is not suspend (the flow itself is cold, lesson 33). Room runs the query on a background thread and re-runs it automatically when the underlying table is modified. That is why a list on screen updates the instant you insert a row, with no refresh code.

✏️ Your Task
Practise what you just learned about Conceptual Understanding: The Three Room Pieces. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 4 of 14
Step-by-Step Build 1: Add Room to the Project

Create a new Empty Activity project called Inventory (package com.example.inventory). Room needs KSP (Kotlin Symbol Processing) to generate code, and Room 3 supports no other processor (no KAPT).

Look up the current versions of Room (https://developer.android.com/jetpack/androidx/releases/room3) and the KSP plugin matching your Kotlin version (https://github.com/google/ksp/releases) and fill in:

libs.versions.toml
[versions]
room = "<latest stable Room 3>"
ksp = "<KSP version that matches your Kotlin version>"
[libraries]
androidx-room-runtime = { group = "androidx.room3", name = "room3-runtime", version.ref = "room" }
androidx-room-compiler = { group = "androidx.room3", name = "room3-compiler", version.ref = "room" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
build.gradle.kts
plugins {
 alias(libs.plugins.ksp)
}
dependencies {
 implementation(libs.androidx.room.runtime)
 ksp(libs.androidx.room.compiler)
}

Also add alias(libs.plugins.ksp) apply false to the root build.gradle.kts plugin block, then Sync Now. The KSP version string starts with your Kotlin version (for example a 2.x.y-1.0.z pattern), so choose the one for the Kotlin version in your catalog.

You also need a SQLite driver. Room 3 is built on the androidx.sqlite driver APIs. For Android the simplest option is the framework driver that wraps the SQLite already on the phone; if your setup page shows AndroidSQLiteDriver, use that (artifact androidx.sqlite:sqlite-framework; check the current page for the exact coordinate). A bundled driver (androidx.sqlite:sqlite-bundled) ships its own SQLite so behaviour is identical on every phone, at the price of a larger app.

Room 2.x equivalents. If your project (or a tutorial) uses Room 2.x: artifacts are androidx.room:room-runtime and androidx.room:room-compiler, plus androidx.room:room-ktx for coroutine and Flow support in older versions; imports start androidx.room. (not androidx.room3.); @TypeConverter stays @TypeConverter (it is @ColumnTypeConverter in Room 3); you do not have to set a driver; and blocking DAO functions are allowed (but should never be called on the main thread). The concepts in this lesson are identical.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 1: Add Room to the Project. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 5 of 14
Step-by-Step Build 2: The Entity
Item.kt
package com.example.inventory.data
import androidx.room3.Entity
import androidx.room3.PrimaryKey
@Entity(tableName = "items")
data class Item(
 @PrimaryKey(autoGenerate = true) val id: Int = 0,
 val name: String,
 val priceKobo: Long,
 val quantity: Int
)
  • @Entity(tableName = "items") makes the class a table named items (otherwise the table takes the class name).
  • @PrimaryKey(autoGenerate = true) lets SQLite choose the id. The = 0 default means "not saved yet": when you insert an Item(name = ..., ...) with id = 0, Room asks the database to generate one.
  • Each property becomes a column of the matching type (Int and Long become INTEGER, String becomes TEXT).
  • Money is stored as a whole number of kobo (100 kobo = 1 naira), the integer approach from lesson 32. ₦1,250.50 is 125050.

Other annotations you will meet: @ColumnInfo(name = "item_name") to rename a column, @Ignore to skip a property, and indices for indexes.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 2: The Entity. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 6 of 14
Step-by-Step Build 3: The DAO
ItemDao.kt
package com.example.inventory.data
import androidx.room3.Dao
import androidx.room3.Delete
import androidx.room3.Insert
import androidx.room3.OnConflictStrategy
import androidx.room3.Query
import androidx.room3.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface ItemDao {
 @Insert(onConflict = OnConflictStrategy.IGNORE)
 suspend fun insert(item: Item)
 @Update
 suspend fun update(item: Item)
 @Delete
 suspend fun delete(item: Item)
 @Query("SELECT * FROM items WHERE id = :id")
 fun getItem(id: Int): Flow<Item?>
 @Query("SELECT * FROM items ORDER BY name ASC")
 fun getAllItems(): Flow<List<Item>>
}

Read each one:

Function What Room generates
@Insert suspend fun insert(item) An INSERT statement. OnConflictStrategy.IGNORE ignores a row whose primary key already exists; REPLACE overwrites it.
@Update suspend fun update(item) UPDATE ... WHERE id = item.id, using the primary key to find the row.
@Delete suspend fun delete(item) DELETE ... WHERE id = item.id.
@Query(...) fun getItem(id) Your own SQL. :id is a parameter bound to the function argument, never string-glued (lesson 32).
Flow<Item?> Emits the row, or null if it does not exist (for example after deletion), and emits again after changes.

A few helpful details:

  • The SQL in @Query is validated at compile time against your entities. Misspell itemz and the build fails with a clear message.
  • In Room 3, non-observable DAO functions must be suspend. The insert, update and delete functions return Unit here, but they can also return the new row id (Long) or a count of affected rows (Int).
  • Flow<Item?> is nullable because getItem may match nothing.
✏️ Your Task
Practise what you just learned about Step-by-Step Build 3: The DAO. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 7 of 14
Step-by-Step Build 4: The Database and One Instance
InventoryDatabase.kt
package com.example.inventory.data
import android.content.Context
import androidx.room3.Database
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.AndroidSQLiteDriver
@Database(entities = [Item::class], version = 1, exportSchema = true)
abstract class InventoryDatabase : RoomDatabase() {
 abstract fun itemDao(): ItemDao
 companion object {
 fun build(context: Context): InventoryDatabase =
 Room.databaseBuilder<InventoryDatabase>(
 context = context.applicationContext,
 name = "inventory_database"
 )
 .setDriver(AndroidSQLiteDriver())
 .build()
 }
}
  • @Database(entities = [...], version = 1) lists every table. Increase version whenever you change the schema (lesson section on migrations).
  • exportSchema = true writes a JSON copy of each version of your schema into a folder you commit; Room uses it for tests and automatic migrations. Configure the output folder as the official migration guide shows (a room { schemaDirectory(...) } block or a KSP argument, depending on the Room version).
  • abstract fun itemDao() is how you get the DAO. Room generates the body.
  • context.applicationContext avoids leaking an Activity (lesson 24).
  • Create one Database instance for the whole app. Building one is expensive. In lesson 30 you learned the right home for a long-lived object: the app container.

Check your imports against the official setup page: package and driver names changed between Room 2 and 3, and are the most likely thing to differ in your project.

Wire it into the container

kotlin
// AppContainer.kt
interface AppContainer {
 val itemsRepository: ItemsRepository
}
class DefaultAppContainer(private val context: Context) : AppContainer {
 override val itemsRepository: ItemsRepository by lazy {
 OfflineItemsRepository(InventoryDatabase.build(context).itemDao())
 }
}
kotlin
// InventoryApplication.kt (register with android:name in the manifest)
class InventoryApplication : Application() {
 lateinit var container: AppContainer
 override fun onCreate() {
 super.onCreate()
 container = DefaultAppContainer(this)
 }
}

The repository

kotlin
// ItemsRepository.kt
interface ItemsRepository {
 fun getAllItemsStream(): Flow<List<Item>>
 fun getItemStream(id: Int): Flow<Item?>
 suspend fun insertItem(item: Item)
 suspend fun deleteItem(item: Item)
 suspend fun updateItem(item: Item)
}
class OfflineItemsRepository(private val itemDao: ItemDao) : ItemsRepository {
 override fun getAllItemsStream(): Flow<List<Item>> = itemDao.getAllItems()
 override fun getItemStream(id: Int): Flow<Item?> = itemDao.getItem(id)
 override suspend fun insertItem(item: Item) = itemDao.insert(item)
 override suspend fun deleteItem(item: Item) = itemDao.delete(item)
 override suspend fun updateItem(item: Item) = itemDao.update(item)
}

The name OfflineItemsRepository hints at the offline-first design in lesson 62: the database is the source of truth. Later, a NetworkItemsRepository could sync with a server without changing any ViewModel.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 4: The Database and One Instance. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 8 of 14
Step-by-Step Build 5: ViewModels and Screens

The home list

kotlin
// HomeViewModel.kt
data class HomeUiState(val itemList: List<Item> = emptyList())
class HomeViewModel(itemsRepository: ItemsRepository) : ViewModel() {
 val homeUiState: StateFlow<HomeUiState> =
 itemsRepository.getAllItemsStream()
 .map { HomeUiState(it) }
 .stateIn(
 scope = viewModelScope,
 started = SharingStarted.WhileSubscribed(5_000),
 initialValue = HomeUiState()
 )
}

This is lesson 33's stateIn recipe. Insert a row anywhere in the app and this StateFlow emits, and the list redraws. Imports needed: androidx.lifecycle.ViewModel, androidx.lifecycle.viewModelScope, kotlinx.coroutines.flow.* (StateFlow, SharingStarted, map, stateIn).

Entry with validation and a money helper

kotlin
// ItemEntryViewModel.kt
data class ItemDetails(
 val id: Int = 0,
 val name: String = "",
 val price: String = "", // typed by the user, in naira, e.g. "1250.50"
 val quantity: String = ""
)
data class ItemUiState(
 val itemDetails: ItemDetails = ItemDetails(),
 val isEntryValid: Boolean = false
)
fun ItemDetails.toItem(): Item = Item(
 id = id,
 name = name.trim(),
 priceKobo = nairaTextToKobo(price) ?: 0L,
 quantity = quantity.toIntOrNull() ?: 0
)
fun Item.toItemDetails(): ItemDetails = ItemDetails(
 id = id,
 name = name,
 price = "%d.%02d".format(priceKobo / 100, priceKobo % 100),
 quantity = quantity.toString()
)
fun nairaTextToKobo(text: String): Long? {
 val clean = text.trim()
 if (!Regex("""\d+(\.\d{1,2})?""").matches(clean)) return null
 val parts = clean.split(".")
 val naira = parts[0].toLongOrNull() ?: return null
 val kobo = parts.getOrNull(1)?.padEnd(2, '0')?.toLongOrNull() ?: 0L
 return naira * 100 + kobo
}

nairaTextToKobo("1250.5") gives 125050 (the .5 is padded to 50), "300" gives 30000, and "abc" or "1.234" give null. Working with text and then converting once at the boundary keeps floating-point numbers out of your money entirely. ("%d.%02d".format(...) uses the device locale for digits; in locales that use different numerals you would format differently, a trap lesson 20 explained. For a shop-owner app, test it in your target locale.)

kotlin
class ItemEntryViewModel(private val itemsRepository: ItemsRepository) : ViewModel() {
 var itemUiState by mutableStateOf(ItemUiState())
 private set
 fun updateUiState(details: ItemDetails) {
 itemUiState = ItemUiState(details, isEntryValid = validate(details))
 }
 private fun validate(d: ItemDetails): Boolean =
 d.name.isNotBlank() &&
 nairaTextToKobo(d.price) != null &&
 (d.quantity.toIntOrNull() ?: -1) >= 0
 suspend fun saveItem() {
 if (validate(itemUiState.itemDetails)) {
 itemsRepository.insertItem(itemUiState.itemDetails.toItem())
 }
 }
}

Here the form state is held with Compose mutableStateOf inside the ViewModel (private set protects it from outside writes), a pattern the official codelab uses for text fields. StateFlow works equally well. saveItem is a suspend function the screen launches from rememberCoroutineScope (lesson 28), so the save is tied to the UI event.

Details: read, sell, edit, delete

kotlin
class ItemDetailsViewModel(
 savedStateHandle: SavedStateHandle,
 private val itemsRepository: ItemsRepository
) : ViewModel() {
 private val itemId: Int = checkNotNull(savedStateHandle["itemId"])
 val uiState: StateFlow<ItemDetailsUiState> =
 itemsRepository.getItemStream(itemId)
 .filterNotNull()
 .map { ItemDetailsUiState(it.toItemDetails(), outOfStock = it.quantity <= 0) }
 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ItemDetailsUiState())
 suspend fun sellOne() {
 val item = uiState.value.itemDetails.toItem()
 if (item.quantity > 0) itemsRepository.updateItem(item.copy(quantity = item.quantity - 1))
 }
 suspend fun deleteItem() {
 itemsRepository.deleteItem(uiState.value.itemDetails.toItem())
 }
}
data class ItemDetailsUiState(
 val itemDetails: ItemDetails = ItemDetails(),
 val outOfStock: Boolean = true
)

savedStateHandle["itemId"] receives the route argument from navigation (lesson 26: type-safe routes put their arguments into the SavedStateHandle). filterNotNull() drops the null emitted after a delete, so the screen does not crash while navigating away. Tap Sell, and updateItem changes the row; the getItem Flow re-emits; the screen shows the new quantity. You wrote no refresh code.

Factory

Use the factory pattern from lesson 30 to build each ViewModel from InventoryApplication.container, with createSavedStateHandle() for the one needing a handle:

kotlin
object AppViewModelProvider {
 val Factory = viewModelFactory {
 initializer { HomeViewModel(inventoryApplication().container.itemsRepository) }
 initializer { ItemEntryViewModel(inventoryApplication().container.itemsRepository) }
 initializer {
 ItemDetailsViewModel(
 this.createSavedStateHandle(),
 inventoryApplication().container.itemsRepository
 )
 }
 }
}
fun CreationExtras.inventoryApplication(): InventoryApplication =
 this[AndroidViewModel.APPLICATION_KEY] as InventoryApplication

(Imports: androidx.lifecycle.createSavedStateHandle, androidx.lifecycle.viewmodel.CreationExtras, androidx.lifecycle.AndroidViewModel.)

Seeing the screen

Build three screens following lessons 21 and 26: a LazyColumn for the home list with a + floating action button; a form with two OutlinedTextFields (set keyboardOptions for decimal and number keyboards) and a Save button enabled when isEntryValid; and a details screen with Sell, Edit and Delete buttons. Delete should ask for confirmation with an AlertDialog.

✏️ Your Task
Practise what you just learned about Step-by-Step Build 5: ViewModels and Screens. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 9 of 14
Inspecting the Database

Run the app, add three items, then open View > Tool Windows > App Inspection and choose Database Inspector (lesson 44 covers the inspectors in depth). You can:

  • See the items table and every row live.
  • Run your own SQL (SELECT * FROM items WHERE quantity = 0).
  • Edit a cell and watch the app's list update immediately, which proves the Flow wiring.

The Inspector works with debuggable builds running on an emulator or device that supports it. It is the fastest way to answer "is the problem in my query or in my UI?".

✏️ Your Task
Practise what you just learned about Inspecting the Database. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 10 of 14
Migrations: Changing the Schema Later

Suppose version 2 of your app adds a supplier column. Users already have a version 1 database on their phones. If you simply change the entity and bump version, Room refuses to open the old file ("a migration from 1 to 2 is required") because it cannot guess what to do with the existing data.

You have three options:

Approach Use it when
Auto-migration Simple changes: add a column or table, rename with a spec. Declare @Database(version = 2, autoMigrations = [AutoMigration(from = 1, to = 2)]). Needs exportSchema so Room can compare schema JSON files.
Manual Migration Anything auto-migration cannot do. You write the ALTER TABLE SQL yourself.
Destructive migration (fallbackToDestructiveMigration) Throws away all user data when the schema changes. Only for throw-away or cache data, never for something users created, such as an inventory.

Always test migrations (Room provides a migration test helper that loads the exported schemas). A broken migration crashes the app at launch for every existing user, which is among the most painful bugs to ship. The exact API for migrations changed in Room 3 (migrations are suspend, working with SQLiteConnection instead of SupportSQLiteDatabase); follow the official migration guide for your Room version rather than copying from older articles.

✏️ Your Task
Practise what you just learned about Migrations: Changing the Schema Later. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 11 of 14
Testing a DAO

A DAO test runs on a device or emulator (it needs a real SQLite) and uses an in-memory database that vanishes after the test:

kotlin
// app/src/androidTest/.../ItemDaoTest.kt
package com.example.inventory
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.AndroidSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.example.inventory.data.InventoryDatabase
import com.example.inventory.data.Item
import com.example.inventory.data.ItemDao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ItemDaoTest {
 private lateinit var db: InventoryDatabase
 private lateinit var dao: ItemDao
 @Before
 fun createDb() {
 val context = ApplicationProvider.getApplicationContext<Context>()
 db = Room.inMemoryDatabaseBuilder<InventoryDatabase>(context)
 .setDriver(AndroidSQLiteDriver())
 .build()
 dao = db.itemDao()
 }
 @After
 fun closeDb() = db.close()
 @Test
 fun insert_thenReadAll_returnsItemsSortedByName() = runTest {
 dao.insert(Item(name = "Rice", priceKobo = 5_000_000, quantity = 3))
 dao.insert(Item(name = "Beans", priceKobo = 2_500_000, quantity = 10))
 val names = dao.getAllItems().first().map { it.name }
 assertEquals(listOf("Beans", "Rice"), names)
 }
 @Test
 fun update_changesQuantity() = runTest {
 dao.insert(Item(name = "Garri", priceKobo = 100_000, quantity = 5))
 val saved = dao.getAllItems().first().single()
 dao.update(saved.copy(quantity = 4))
 assertEquals(4, dao.getItem(saved.id).first()?.quantity)
 }
}

The .first() operator takes one emission from the Flow and finishes, which gives a clean "read once" in a test. Test dependencies: androidTestImplementation entries for androidx.test.ext:junit, androidx.test:core and kotlinx-coroutines-test (look up current versions; the project template already includes the first). Room 2.x uses Room.inMemoryDatabaseBuilder(context, InventoryDatabase::class.java) and often .allowMainThreadQueries() for blocking tests.

✏️ Your Task
Practise what you just learned about Testing a DAO. Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 12 of 14
Guided Practice Exercises
🎯 Your Challenge

Exercise 1: Low-stock query

Add a DAO function getLowStock(threshold: Int): Flow<List<Item>> that returns items with a quantity at or below the threshold, lowest first. Expose it through the repository and write a DAO test with three items (quantities 0, 3 and 10, threshold 3) that expects the first two, in order.

kotlin
// ItemDao.kt
@Query("SELECT * FROM items WHERE quantity <= :threshold ORDER BY quantity ASC, name ASC")
fun getLowStock(threshold: Int): Flow<List<Item>>
// ItemsRepository.kt
fun getLowStockStream(threshold: Int): Flow<List<Item>>
// OfflineItemsRepository.kt
override fun getLowStockStream(threshold: Int) = itemDao.getLowStock(threshold)
kotlin
@Test
fun lowStock_returnsItemsAtOrBelowThreshold() = runTest {
 dao.insert(Item(name = "Salt", priceKobo = 10_000, quantity = 0))
 dao.insert(Item(name = "Sugar", priceKobo = 20_000, quantity = 3))
 dao.insert(Item(name = "Rice", priceKobo = 30_000, quantity = 10))
 val result = dao.getLowStock(3).first().map { it.name }
 assertEquals(listOf("Salt", "Sugar"), result)
}

The SQL is checked at build time. Adding the second sort key name makes ties deterministic, as lesson 32 stressed.

🎯 Your Challenge

Exercise 2: Search

Add a search box to the home screen that filters by name using SQL, not Kotlin. Use LIKE with a parameter and make the query reactive to the text with flatMapLatest.

kotlin
// ItemDao.kt
@Query("SELECT * FROM items WHERE name LIKE '%' || :text || '%' ORDER BY name")
fun search(text: String): Flow<List<Item>>
kotlin
// HomeViewModel.kt
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
class HomeViewModel(private val repo: ItemsRepository) : ViewModel() {
 private val query = MutableStateFlow("")
 fun onQueryChange(text: String) { query.value = text }
 val homeUiState: StateFlow<HomeUiState> = query
 .debounce(250)
 .flatMapLatest { q -> if (q.isBlank()) repo.getAllItemsStream() else repo.searchStream(q.trim()) }
 .map { HomeUiState(it) }
 .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), HomeUiState())
}

'%' || :text || '%' builds the pattern inside SQL with a bound parameter, so user text can never alter the query (the safe alternative to string concatenation from lesson 32). A user who types % or _ will match as wildcards; escape them if exact matching matters.

🎯 Your Challenge

Exercise 3: The practice, Bus Schedule

Plan and build the Bus Schedule app. The data is a table of stops: id, stopName, arrivalTimeInMillis. Screen 1 lists all stops ordered by arrival time. Tapping a stop shows only the buses that arrive at that stop, ordered by time. Prepopulate the database with sample rows (use your own city's stops, for example Ibadan to Lagos route stops). Use two DAO queries, getAll() and getByStopName(name), both returning Flow.

kotlin
@Entity(tableName = "schedule")
data class Schedule(
 @PrimaryKey val id: Int,
 @ColumnInfo(name = "stop_name") val stopName: String,
 @ColumnInfo(name = "arrival_time") val arrivalTimeInMillis: Long
)
@Dao
interface ScheduleDao {
 @Query("SELECT * FROM schedule ORDER BY arrival_time ASC")
 fun getAll(): Flow<List<Schedule>>
 @Query("SELECT * FROM schedule WHERE stop_name = :stopName ORDER BY arrival_time ASC")
 fun getByStopName(stopName: String): Flow<List<Schedule>>
}

To prepopulate, copy a ready-made database file from assets/ using the builder's createFromAsset("database/bus_schedule.db") (Room 2.x and Room 3 offer ways to do this; check the current builder API) or insert sample rows in a RoomDatabase.Callback the first time the database is created. Format times with SimpleDateFormat("h:mm a", Locale.getDefault()).format(Date(millis)). The detail view uses the same list screen with a stop name argument in the navigation route (lesson 26), and a ViewModel that exposes scheduleFor(stopName) as stateIn StateFlow. Nothing in this app needs insert, update or delete, so the DAO is read-only, a good example of exposing only what the UI needs.

✏️ Your Task

Open the Database Inspector, insert a row by running INSERT INTO items (name, priceKobo, quantity) VALUES ('Test', 100, 1), and watch the list update. Then change the name column name in the entity without bumping the version and read the exact error you get. Restore it.

Phase 13 of 14
Common Beginner Mistakes (and How to Fix Them)

Mistake #1: Creating the database more than once. What happens: wasted memory, and two instances that do not see each other's changes. The fix: one instance in the app container.

Mistake #2: Forgetting @PrimaryKey. Symptom: a build error saying the entity needs a primary key. The fix: add one, usually autoGenerate = true with a default of 0.

Mistake #3: Changing the schema without bumping the version. Symptom: a crash on launch with an integrity or identity-hash error. The fix: increase version and provide a migration. During early development, uninstall the app to clear the old database.

Mistake #4: fallbackToDestructiveMigration() on user data. What happens: all user data deleted on upgrade. The fix: write migrations for anything users created.

Mistake #5: Blocking DAO calls on the main thread (Room 2.x). Symptom: Cannot access database on the main thread. The fix: suspend or Flow. Do not use allowMainThreadQueries() outside tests.

Mistake #6: @Update on an item with the wrong id. What happens: nothing is updated. The fix: load the row first and use copy on it, so the id is preserved.

Mistake #7: Storing money in Double. What happens: rounding oddities. The fix: kobo as Long.

Mistake #8: Wrong KSP version. Symptom: a Gradle error mentioning an incompatible Kotlin version. The fix: use the KSP release whose version prefix matches your Kotlin version.

Mistake #9: Room 2.x and Room 3 imports mixed together. Symptom: unresolved references or ambiguous types. The fix: pick one version, use only its package (androidx.room. or androidx.room3.).

Mistake #10: Treating the Flow like a one-shot call. What happens: calling getAllItems() repeatedly, creating new collectors. The fix: build the Flow once in the ViewModel and expose it with stateIn.

✏️ Your Task
Practise what you just learned about Common Beginner Mistakes (and How to Fix Them). Open Android Studio, type the examples by hand, change a value, and observe what happens.
Phase 14 of 14
Knowledge Check

Question 1. What are the three main Room components?

The entity (a data class that defines a table), the DAO (an interface of query and write functions) and the database class (ties entities and DAOs together and gives access to the DAO).

Question 2. Why does a DAO function returning Flow not need suspend?

A Flow is cold and does its work only when collected, in a coroutine, on a background dispatcher chosen by Room. The function itself returns immediately.

Question 3. How does the UI update when a row is inserted from another screen?

Room re-runs observed queries when their tables change, so the Flow emits a new list, the ViewModel's StateFlow updates, and Compose recomposes. No manual refresh is needed.

Question 4. Why is fallbackToDestructiveMigration dangerous?

It deletes all existing data when the schema version changes. For user-created data that means permanent loss on app update.

Question 5. Why store prices as whole kobo rather than Double?

Binary floating point cannot represent many decimal values exactly, so arithmetic produces rounding errors. Integers in the smallest currency unit are exact.

Go deeper: official resources

✏️ Your Task
Practise what you just learned about Knowledge Check. Open Android Studio, type the examples by hand, change a value, and observe what happens.
🏗️ Build It — Mini Project
Pocket Expense Tracker

Project goal: Build a personal expense tracker with Room, from scratch, using everything in this lesson.

Requirements:

  1. Entity Expense(id, title, amountKobo: Long, category: String, createdAtMillis: Long).
  2. DAO with insert, delete, getAll() ordered by newest first, getByCategory(category), and getTotal(): Flow<Long?> using SELECT SUM(amountKobo) FROM expenses.
  3. Database singleton in the app container, repository, ViewModels with stateIn(WhileSubscribed(5000)).
  4. A list screen with the running total at the top (formatted as naira), an add form with validation, and swipe or button delete with confirmation.
  5. A filter chip row for categories.
  6. Two DAO tests with an in-memory database: total of three expenses, and getByCategory.
  7. Inspect the live data in Database Inspector and take notes on one thing you learned.

Stretch goal: add @Database(version = 2) with a new note column and an auto-migration, and prove that existing rows survive.

Expense.kt
package com.example.expenses.data
import androidx.room3.Dao
import androidx.room3.Delete
import androidx.room3.Entity
import androidx.room3.Insert
import androidx.room3.PrimaryKey
import androidx.room3.Query
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "expenses")
data class Expense(
 @PrimaryKey(autoGenerate = true) val id: Int = 0,
 val title: String,
 val amountKobo: Long,
 val category: String,
 val createdAtMillis: Long
)
@Dao
interface ExpenseDao {
 @Insert
 suspend fun insert(expense: Expense)
 @Delete
 suspend fun delete(expense: Expense)
 @Query("SELECT * FROM expenses ORDER BY createdAtMillis DESC")
 fun getAll(): Flow<List<Expense>>
 @Query("SELECT * FROM expenses WHERE category = :category ORDER BY createdAtMillis DESC")
 fun getByCategory(category: String): Flow<List<Expense>>
 @Query("SELECT SUM(amountKobo) FROM expenses")
 fun getTotal(): Flow<Long?>
}
ExpenseDaoTest.kt
@RunWith(AndroidJUnit4::class)
class ExpenseDaoTest {
 private lateinit var db: ExpenseDatabase
 private lateinit var dao: ExpenseDao
 @Before fun create() {
 val context = ApplicationProvider.getApplicationContext<Context>()
 db = Room.inMemoryDatabaseBuilder<ExpenseDatabase>(context)
 .setDriver(AndroidSQLiteDriver())
 .build()
 dao = db.expenseDao()
 }
 @After fun close() = db.close()
 @Test fun total_sumsAllExpenses() = runTest {
 dao.insert(Expense(title = "Bus", amountKobo = 20_000, category = "Transport", createdAtMillis = 1))
 dao.insert(Expense(title = "Lunch", amountKobo = 150_000, category = "Food", createdAtMillis = 2))
 dao.insert(Expense(title = "Data", amountKobo = 100_000, category = "Bills", createdAtMillis = 3))
 assertEquals(270_000L, dao.getTotal().first())
 }
 @Test fun byCategory_filters() = runTest {
 dao.insert(Expense(title = "Bus", amountKobo = 20_000, category = "Transport", createdAtMillis = 1))
 dao.insert(Expense(title = "Lunch", amountKobo = 150_000, category = "Food", createdAtMillis = 2))
 val food = dao.getByCategory("Food").first()
 assertEquals(listOf("Lunch"), food.map { it.title })
 }
}

Notes: SUM returns NULL on an empty table, which is why getTotal() is Flow<Long?>; display total ?: 0L. The test checks the arithmetic by hand: 20,000 + 150,000 + 100,000 = 270,000 kobo, or ₦2,700. The ExpenseDatabase class mirrors InventoryDatabase from the lesson, with entities = [Expense::class]. The auto-migration stretch goal needs exportSchema set up and autoMigrations = [AutoMigration(from = 1, to = 2)]; follow the current migration guide for the exact syntax in your Room version. Because the Room API has recently changed major versions, treat any import in this solution as a starting point and confirm it against the official setup page.

1

Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.

2

Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 34 project”.

3

On github.com click New repository, name it android-lesson-34-persist-data-with-room, set it to Public, leave Add a README unticked, and click Create repository.

4

Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.

5

Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉

Lesson 34 complete! 🎉

You covered:

  • Lesson Introduction
  • Prerequisite Concepts
  • Conceptual Understanding: The Three Room Pieces
  • Step-by-Step Build 1: Add Room to the Project
  • Step-by-Step Build 2: The Entity
  • Step-by-Step Build 3: The DAO
  • Step-by-Step Build 4: The Database and One Instance
  • Step-by-Step Build 5: ViewModels and Screens
  • Inspecting the Database
  • Migrations: Changing the Schema Later
  • Testing a DAO
  • Guided Practice Exercises
  • Common Beginner Mistakes (and How to Fix Them)
  • Knowledge Check
All 72 lessons in Google Android Studio ·Techbase Academy ·© 2025–2026 Techbase Consultant Services, Ibadan
Esc
↑ ↓ to moveEnter to openEsc to close