So far every app forgot its data when closed. Real apps need to store information on the device. Android has SQLite built in, a small database in a file, and the Room library makes it pleasant to use from Kotlin. You describe your table as a class, write queries as functions, and Room generates all the boring code and checks your SQL when the app is built.
Concepts in this project
- Entity: a data class marked
@Entity. One class is one table and one object is one row. - DAO (data access object): an interface with functions marked
@Query,@Upsertand@Delete. You write the SQL, Room writes the code. - Database: an abstract class marked
@Databasethat lists the entities and gives you the DAOs. - Flow: a DAO function that returns
Flow<List<Note>>emits a new list every time the table changes. The screen updates by itself, with no refresh code. - KSP: a build tool that Room uses to generate its code. You add it as a plugin.
Create the project
- New Empty Activity project
Notes, packagecom.example.notes. - Add the KSP plugin and the Room, ViewModel and lifecycle libraries from the snippet below, then sync.
- Create the six files.
`Gradle additions for projects 5 to 7`
// Top of app/build.gradle.kts -> plugins { ... } (add the KSP plugin; match its version to your Kotlin version)
id("com.google.devtools.ksp") version "2.0.21-1.0.28"
// (In the project-level build file, or in settings, declare the same plugin with "apply false" if Android Studio asks.)
// app/build.gradle.kts -> dependencies { ... }
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1") // generates the database code at build time
// Project 6 only (weather):
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:2.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// and in the plugins block: id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
Notes/app/src/main/java/com/example/notes/
Note.kt the entity (the table)
NoteDao.kt queries
AppDatabase.kt the database
NotesViewModel.kt search + data for the screen
NotesScreen.kt list, search box, add/edit dialog
MainActivity.kt creates the database and shows the screen
The code
`app/src/main/java/com/example/notes/Note.kt`
package com.example.notes
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val body: String,
val updatedAt: Long = System.currentTimeMillis()
)
`app/src/main/java/com/example/notes/NoteDao.kt`
package com.example.notes
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface NoteDao {
// A Flow re-emits every time the table changes, so the screen updates by itself.
@Query("SELECT * FROM notes ORDER BY updatedAt DESC")
fun observeAll(): Flow<List<Note>>
@Upsert
suspend fun save(note: Note)
@Delete
suspend fun delete(note: Note)
}
@Upsert inserts a new note or updates an existing one with the same ID, so one function covers both Add and Edit. The search query uses LIKE with percent signs on either side to match text anywhere in the title or body.
`app/src/main/java/com/example/notes/AppDatabase.kt`
package com.example.notes
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [Note::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
@Volatile private var instance: AppDatabase? = null
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(context.applicationContext, AppDatabase::class.java, "notes.db")
.build().also { instance = it }
}
}
}
`app/src/main/java/com/example/notes/NotesViewModel.kt`
package com.example.notes
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class NotesViewModel(app: Application) : AndroidViewModel(app) {
private val dao = AppDatabase.get(app).noteDao()
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
// Combine the database flow with the search text. The result is a new list every time either changes.
val notes: StateFlow<List<Note>> = combine(dao.observeAll(), _query) { all, q ->
if (q.isBlank()) all
else all.filter { it.title.contains(q, ignoreCase = true) || it.body.contains(q, ignoreCase = true) }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setQuery(text: String) { _query.value = text }
fun save(id: Long, title: String, body: String) {
if (title.isBlank() && body.isBlank()) return
viewModelScope.launch {
dao.save(Note(id = id, title = title.trim().ifEmpty { "Untitled" }, body = body.trim()))
}
}
fun delete(note: Note) {
viewModelScope.launch { dao.delete(note) }
}
}
The ViewModel holds the search text as a StateFlow. flatMapLatest switches to a fresh database query each time the text changes and drops the old one. The database work runs off the main thread automatically, because Room Flow queries do.
`app/src/main/java/com/example/notes/NotesScreen.kt`
package com.example.notes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NotesScreen(vm: NotesViewModel = viewModel()) {
val notes by vm.notes.collectAsStateWithLifecycle()
val query by vm.query.collectAsStateWithLifecycle()
// null = no dialog, a Note with id 0 = new note, otherwise editing that note
var editing by remember { mutableStateOf<Note?>(null) }
Scaffold(
topBar = { TopAppBar(title = { Text("Notes") }) },
floatingActionButton = {
FloatingActionButton(onClick = { editing = Note(title = "", body = "") }) {
Icon(Icons.Default.Add, contentDescription = "New note")
}
}
) { padding ->
Column(Modifier.padding(padding).padding(horizontal = 16.dp)) {
OutlinedTextField(
value = query,
onValueChange = vm::setQuery,
label = { Text("Search notes") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
if (notes.isEmpty()) {
Text(
if (query.isBlank()) "No notes yet. Tap + to write one." else "No notes match \"$query\".",
modifier = Modifier.padding(top = 32.dp)
)
}
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(notes, key = { it.id }) { note ->
Card(Modifier.fillMaxWidth().clickable { editing = note }) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(note.title, style = MaterialTheme.typography.titleMedium)
Text(note.body, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
IconButton(onClick = { vm.delete(note) }) {
Icon(Icons.Default.Delete, contentDescription = "Delete note")
}
}
}
}
}
}
}
editing?.let { note ->
var title by remember(note.id) { mutableStateOf(note.title) }
var body by remember(note.id) { mutableStateOf(note.body) }
AlertDialog(
onDismissRequest = { editing = null },
title = { Text(if (note.id == 0L) "New note" else "Edit note") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(title, { title = it }, label = { Text("Title") }, singleLine = true)
OutlinedTextField(body, { body = it }, label = { Text("Note") }, minLines = 3)
}
},
confirmButton = { TextButton(onClick = { vm.save(note.id, title, body); editing = null }) { Text("Save") } },
dismissButton = { TextButton(onClick = { editing = null }) { Text("Cancel") } }
)
}
}
`app/src/main/java/com/example/notes/MainActivity.kt`
package com.example.notes
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaterialTheme { NotesScreen() } }
}
}
Expected output
You see a search box, an empty-list message and a floating + button. Tap + and a dialog asks for a title and text. Save, and the note appears in the list at once. Tap a note to edit it. Long-press or use the delete icon to remove it. Type in the search box and the list narrows as you type. Close the app completely and reopen it: the notes are still there, which is the whole point of Room.
Your task
- Build the app, add three notes and check that they survive closing the app.
- Add a
createdAtcolumn and show the date under each title. Raise the version number and remember to uninstall the old build. - Sort notes with the newest first using
ORDER BY. - Add a pin flag and show pinned notes above the others.
How to check it worked
- Notes persist after the app is killed.
- Search narrows the list as you type.
- You can explain what an entity, a DAO and a Flow are.