Pocket Notes 1 — model, Room and DAO
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 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.
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:
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:
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" }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:
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.
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:
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 )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.
Building a fresh project, then four files.
- Open Pocket Studio → Projects → New Project.
- Choose the Empty Compose Activity template.
- Name:
Pocket Notes. Package:com.nativeworks.pocketnotes. Minimum SDK: API 26. Language: Kotlin. Tap Create. - Open
app/build.gradle.kts. Addalias(libs.plugins.ksp)as the fourth line insideplugins { }. - Scroll to
dependencies { }and add the three Room lines. Check the last one starts withksp(, notimplementation(. - Tap Build. It will download Room the first time — give it a minute.
- In the project tree, long-press the folder with your package name and choose New → Package. Call it
data. - Inside
data, tap New → Kotlin File three times:Note,NoteDao,NotesDatabase. Type each one in. - Open
MainActivity.ktand replace its contents with the chapter-1 version. - Tap Run. Read the line under the title.
- 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.
@PrimaryKey(autoGenerate = true) on the id property. The annotation goes on its own line directly above the property, inside the constructor's brackets.val tags: List<String>. A column holds text, a number or a blob. Room does not guess.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.updated_at — SQL is not automatically snake_case.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.: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.@Query("SELECT * FROM notes WHERE id = :id") with suspend fun findById(id: Long): Note?.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.- 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.