Pocket Studio Academy
HomePart 55.6

Pocket Notes 1 — model, Room and DAO

Full course16 min read·4 questions

Give an app a memory that survives being closed. You describe a note as one Kotlin class and your questions as one interface, and Room writes every line of SQL for you at build time.

The most valuable zero in the course

Dice Duel forgets. Roll a twenty-game winning streak, swipe the app away, open it again — nothing. That was fine. A dice game is a moment, not a record.

Pocket Notes is the opposite. A notes app that loses your notes is not a worse notes app, it is not a notes app at all. Everything in the next six chapters hangs off one requirement: the words have to still be there tomorrow.

So this chapter builds the skeleton and the memory, and then finishes on a screen so plain it looks like a mistake. One line of text:

0 notes stored

That zero is worth more than any animation. It did not come from a variable you set to zero. It came from a real database that opened on the device, ran a real SELECT COUNT(*), and answered honestly. If you can get that zero, everything after it is detail.

Think of it like this

Think about a shoebox of index cards.

The card is one note. Every card has the same printed boxes on it — a line for the title, a space for the writing, a corner for the date. The cards are all different, but the shape of a card never changes.

The box is the database. It holds cards and nothing else.

And then there is the awkward bit nobody thinks about: the person who fetches cards for you. You do not rummage in the box yourself. You say "give me all the cards, newest first" or "find card 12", and they go and do it.

gives you all three. You describe the card, you describe the requests you are allowed to make, and Room writes the person who does the fetching. You never write the fetching code, and that is not laziness — it is the reason your database code has no typos in it.

Start the project

This is a new app, not a change to Dice Duel. The settings are the same shape as Dice Duel's, with a different name.

The one addition is a fourth called — Kotlin Symbol Processing. KSP is the tool that reads your annotated Kotlin at build time and writes new Kotlin next to it. Room is a KSP plugin. Without this line, Room's annotations are just decoration.

app/build.gradle.ktskts
1plugins {
2    alias(libs.plugins.android.application)
3    alias(libs.plugins.kotlin.android)
4    alias(libs.plugins.kotlin.compose)
5    alias(libs.plugins.ksp)
6}

Then three Room dependencies at the bottom of the same file:

app/build.gradle.ktskts
1    // Room: the runtime, the Kotlin/coroutine helpers,
2    // and the code generator that writes the SQL.
3    implementation(libs.androidx.room.runtime)
4    implementation(libs.androidx.room.ktx)
5    ksp(libs.androidx.room.compiler)
6}

Look at that third line. It is ksp(...), not implementation(...). That is not a style choice. implementation means ship this library inside my app. ksp means run this at build time to generate code. Room's compiler is only ever needed on the workbench, never on the phone, and getting this one word wrong produces a crash we will meet at the end of the lesson.

Those names come from the , which already knows about Room:

gradle/libs.versions.tomltext
1[versions]
2room = "2.6.1"
3
4[libraries]
5androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
6androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
7androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
Note

The catalog is the one file in this whole course that breaks the 60-character rule, and it is not your fault. TOML will not let those { group = … } entries wrap onto a second line. You will scroll sideways across four lines once, and then never open the file again.

The theme is a warm one — honey and paper rather than the usual blue. It follows the same Theme.kt / Type.kt / Shape.kt pattern you built for Dice Duel, so the wrapper will look familiar:

ui/theme/Theme.ktkotlin
1@Composable
2fun PocketNotesTheme(
3    darkTheme: Boolean = isSystemInDarkTheme(),
4    content: @Composable () -> Unit
5) {
6    MaterialTheme(
7        colorScheme =
8            if (darkTheme) DarkColors else LightColors,
9        typography = PocketTypography,
10        shapes = PocketShapes,
11        content = content
12    )
13}

LightColors and DarkColors above it are long lists of Color(0xFF…) values — about forty lines each, and nothing new. They are in the checkpoint ZIP at the bottom of this lesson. The rest of the chapter is the part that matters.

The card: one class, one table

Here is the whole data model of Pocket Notes.

Every property has a default. That is what lets chapter 4 write Note(title = "Shopping") without spelling out the other three.

The requests: an interface you never implement

Now the person who fetches cards. In Room this is a — a Data Access Object — and the surprising thing about it is that it is an with no body anywhere.

Why an interface, and not a class?

Because you are not describing how. You are describing what.

A class would mean writing the SQL, opening a cursor, reading each column by index, building a Note, closing the cursor, and doing it again for the next query. Hundreds of lines of code where every one of the mistakes is silent — a column read in the wrong order gives you a note whose title is a date.

An interface means Room can do that. And because Room does it at build time, your SQL is checked at build time too. Misspell a column and the compiler stops you, on your phone, in seconds. Compare that with the traditional alternative, where a typo in a query string is a crash your users find for you.

That is the whole trade: you give up writing the code, and you get an error message instead of a bug report.

Why every disk function is suspend

Reading from storage takes time. Not much — a millisecond or two — but it is time your app spends not drawing.

Android gives you one thread for drawing, called the . Everything the user can see happens on it. Sixty frames a second means that thread has 16 milliseconds to do everything, and if it is waiting on a disk read it is not drawing, and the screen freezes.

So Room simply refuses. Call a blocking database function on the main thread and you get an with an unusually clear message:

Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

Marking the DAO functions makes that impossible to get wrong. A suspend function can only be called from a , a coroutine knows how to wait without blocking, and Room quietly moves the work to a background thread for you.

The box itself

One class, three annotations, and a lump of ceremony that guarantees the database is opened exactly once.

This pattern — one shared instance, created lazily, guarded by a lock — is called a . Databases are the textbook case. Opening a second connection to the same file is slow at best, and at worst two connections disagree about what is in it.

Prove it opens

Now the boring screen, which is the whole point of the chapter.

MainActivity.ktkotlin
1@Composable
2fun DatabaseCheckScreen() {
3    val context = LocalContext.current
4    val type = MaterialTheme.typography
5    val colors = MaterialTheme.colorScheme
6
7    var count by remember {
8        mutableStateOf<Int?>(null)
9    }
10
11    // Runs once, in a coroutine, when the screen appears.
12    LaunchedEffect(Unit) {
13        val dao = NotesDatabase.get(context).noteDao()
14        count = dao.count()
15    }

count is Int? — nullable — and starts at null. That is not an accident. null here means we have not asked yet, which is a genuinely different state from 0, meaning we asked and there are none. Squash the two together and your screen tells a small lie for the first frame.

is the bridge. It runs its block in a when the composable first appears, which is exactly the kind of place a suspend function is allowed to be called from. Unit as the key means "start once, never restart".

The rest is a centred with two Texts, and one when that turns the nullable number into words:

MainActivity.ktkotlin
1            Text(
2                text = when (val c = count) {
3                    null -> "Opening the database…"
4                    else -> "$c notes stored"
5                },
6                style = type.bodyLarge,
7                color = colors.onSurfaceVariant,
8                textAlign = TextAlign.Center
9            )
9:41▲ ▮
Pocket Notes
0 notes stored

End of chapter 1. Two lines of text, and a database that really opened.

No app bar. No buttons. Nothing to tap. If your phone is in dark mode the page is #17110E and the text is #EDE0D4, because the theme carries both schemes and Android picks one.

Try it in Pocket Studio

Building a fresh project, then four files.

  1. Open Pocket StudioProjectsNew Project.
  2. Choose the Empty Compose Activity template.
  3. Name: Pocket Notes. Package: com.nativeworks.pocketnotes. Minimum SDK: API 26. Language: Kotlin. Tap Create.
  4. Open app/build.gradle.kts. Add alias(libs.plugins.ksp) as the fourth line inside plugins { }.
  5. Scroll to dependencies { } and add the three Room lines. Check the last one starts with ksp(, not implementation(.
  6. Tap Build. It will download Room the first time — give it a minute.
  7. In the project tree, long-press the folder with your package name and choose NewPackage. Call it data.
  8. Inside data, tap NewKotlin File three times: Note, NoteDao, NotesDatabase. Type each one in.
  9. Open MainActivity.kt and replace its contents with the chapter-1 version.
  10. Tap Run. Read the line under the title.
  11. Now the real test: press Home, swipe Pocket Notes away from recents, and open it from its icon. Still "0 notes stored" — and it still had to open a real database file to tell you that.

Pocket Notes — end of chapter 1

A complete project. Unzip it, open it in Pocket Studio, and press Run.

Download ZIP
Error Doctor5 common errors
e: [ksp] .../data/Note.kt:16: An entity must have at least 1 field annotated with @PrimaryKey
MeansRoom will not build a table it cannot find rows in. Every table needs one column whose value is never repeated.
FixPut @PrimaryKey(autoGenerate = true) on the id property. The annotation goes on its own line directly above the property, inside the constructor's brackets.
e: [ksp] .../data/Note.kt:21: Cannot figure out how to save this field into database. You can consider adding a type converter for it. e: [ksp] .../data/Note.kt:21: Cannot figure out how to read this field from a cursor.
MeansYou gave a property a type SQLite has never heard of — very often val tags: List<String>. A column holds text, a number or a blob. Room does not guess.
FixStick to String, Int, Long, Double and Boolean for now. If you really need a list, join it into one string with tags.joinToString(","), or write a @TypeConverter class later.
e: [ksp] .../data/NoteDao.kt:24: There is a problem with the query: [SQLITE_ERROR] SQL error or missing database (no such column: updated_at)
MeansRoom really runs your SQL against a throwaway database while building. There is no column called updated_at — SQL is not automatically snake_case.
FixSpell the column exactly as the Kotlin property is spelled: ORDER BY updatedAt DESC. A second error about converting a Cursor usually appears alongside it and disappears on its own once the query is valid.
e: [ksp] .../data/NoteDao.kt:30: Each bind variable in the query must have a matching method parameter. Cannot find method parameters for :noteId. e: [ksp] .../data/NoteDao.kt:30: Unused parameter: id
Means:noteId in the SQL is a hole Room fills from a parameter with that exact name. Your parameter is called id, so one side is looking for something the other never offered.
FixMake the two words identical: @Query("SELECT * FROM notes WHERE id = :id") with suspend fun findById(id: Long): Note?.
java.lang.RuntimeException: cannot find implementation for com.nativeworks.pocketnotes.data.NotesDatabase. NotesDatabase_Impl does not exist
MeansThis one compiles and then crashes on launch. Room's code generator never ran, so the class it should have written is simply not there.
FixCheck both halves of the KSP wiring in app/build.gradle.kts: alias(libs.plugins.ksp) inside plugins { }, and ksp(libs.androidx.room.compiler) inside dependencies { }. Writing implementation(...) instead of ksp(...) for the compiler produces exactly this crash.
Recap
  • An is a : the class is the table, each property is a column, and marks the one that must be unique.
  • A is an because you describe what you want and writes how — checking every query at build time, on your phone, in seconds.
  • Queries that return a are live. Room re-sends the whole list whenever the table changes, so nothing in your app ever calls "refresh".
  • Everything that touches the disk is , because the has 16 milliseconds a frame and cannot spend them waiting.
  • The database is a — opened once, shared everywhere, via a guarded by .
  • Next: the screen talks straight to the DAO, which does not scale past one number. Chapter 2 puts a and a between them, and turns that live Flow into a single the screen can draw.