I shipped my first QR scanner app without scan history. Users complained. Fair enough - scanning the same WiFi QR code every time is annoying. So I added history using raw SQLite.
That was a mistake.
Cursor management, forgetting to close database connections, SQL typos that only crashed in production. I spent more time debugging database code than building actual features. When I rewrote it with Room, the scan history feature took an afternoon instead of a week.
Raw SQLite vs Room
Here's what fetching scan history looked like before Room:
fun getAllScans(): List<ScanRecord> {
val scans = mutableListOf<ScanRecord>()
val db = dbHelper.readableDatabase
var cursor: Cursor? = null
try {
cursor = db.rawQuery(
"SELECT * FROM scan_history ORDER BY timestamp DESC",
null
)
while (cursor.moveToNext()) {
scans.add(
ScanRecord(
id = cursor.getLong(cursor.getColumnIndexOrThrow("id")),
content = cursor.getString(cursor.getColumnIndexOrThrow("content")),
format = cursor.getString(cursor.getColumnIndexOrThrow("format")),
timestamp = cursor.getLong(cursor.getColumnIndexOrThrow("timestamp")),
isFavorite = cursor.getInt(cursor.getColumnIndexOrThrow("is_favorite")) == 1
)
)
}
} finally {
cursor?.close()
}
return scans
}
And here's the same thing with Room:
@Query("SELECT * FROM scan_history ORDER BY timestamp DESC")
fun getAllScans(): Flow<List<ScanRecord>>
One line. The SQL is checked at compile time. If I typo a column name, the build fails instead of crashing on a user's phone. And it returns a Flow, so my UI updates automatically when data changes.
Setup
Add these to your module's build.gradle.kts:
plugins {
id("com.google.devtools.ksp")
}
dependencies {
val roomVersion = "2.6.1"
implementation("androidx.room:room-runtime:$roomVersion")
implementation("androidx.room:room-ktx:$roomVersion")
ksp("androidx.room:room-compiler:$roomVersion")
}
Use KSP, not KAPT. It's faster and Google recommends it now.
The Entity
An Entity is just a data class that maps to a database table. For scan history:
@Entity(tableName = "scan_history")
data class ScanRecord(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
val content: String,
val format: String,
val timestamp: Long,
@ColumnInfo(name = "is_favorite")
val isFavorite: Boolean = false,
val label: String? = null
)
Few things I learned:
autoGenerate = truehandles ID creation. Don't generate IDs yourself.@ColumnInfolets you use snake_case in the database while keeping camelCase in Kotlin. I prefer this because SQL conventions use underscores.- Nullable fields like
labelbecome nullable columns. Room handles this correctly. - Default values work. New scans get
isFavorite = falseautomatically.
Type Converters: Teaching Room About Your Custom Types
Room only understands primitives. Strings, Longs, Ints — that's its world. The moment you try to store a Date or an enum or a custom data class directly, Room will throw a compile error that feels more confusing than it should be.
The fix is a TypeConverter. You write two functions — one to convert your type into something Room understands, one to convert it back — and Room handles the rest automatically.
Here's the converter I use for timestamps:
class Converters {
@TypeConverter
fun fromTimestamp(value: Long?): Date? = value?.let { Date(it) }
@TypeConverter
fun dateToTimestamp(date: Date?): Long? = date?.time
}
Then register it on the Database class:
@Database(entities = [ScanRecord::class], version = 1)
@TypeConverters(Converters::class)
abstract class ScanDatabase : RoomDatabase() {
abstract fun scanDao(): ScanDao
}
That's it. Room now knows how to serialize and deserialize Date automatically anywhere in your schema.
The enum gotcha nobody warns you about.
Enums are where I've seen developers quietly ship a time bomb. You have two options: store by name (the string) or by ordinal (the position in the enum definition).
Ordinal looks fine — it's just an Int, efficient, clean. But the moment you reorder your enum values or insert a new one in the middle, every existing row in your database silently maps to the wrong value. No crash, no error. Just corrupted data.
Store by name. Always.
@TypeConverter
fun fromScanFormat(value: String?): ScanFormat? =
value?.let { enumValueOf<ScanFormat>(it) }
@TypeConverter
fun scanFormatToString(format: ScanFormat?): String? = format?.name
Yes, it uses slightly more storage. It doesn't matter. The safety is worth it.
The DAO
This is where you define database operations. I use suspend functions for one-shot operations and Flow for data I want to observe.
@Dao
interface ScanDao {
@Query("SELECT * FROM scan_history ORDER BY timestamp DESC")
fun getAllScans(): Flow<List<ScanRecord>>
@Query("SELECT * FROM scan_history WHERE is_favorite = 1 ORDER BY timestamp DESC")
fun getFavorites(): Flow<List<ScanRecord>>
@Query("SELECT * FROM scan_history WHERE content LIKE '%' || :query || '%'")
fun searchScans(query: String): Flow<List<ScanRecord>>
@Insert
suspend fun insert(scan: ScanRecord)
@Query("UPDATE scan_history SET is_favorite = NOT is_favorite WHERE id = :scanId")
suspend fun toggleFavorite(scanId: Long)
@Query("DELETE FROM scan_history WHERE id = :scanId")
suspend fun delete(scanId: Long)
@Query("DELETE FROM scan_history WHERE timestamp < :cutoffTime AND is_favorite = 0")
suspend fun deleteOldScans(cutoffTime: Long)
}
The search query uses SQLite's LIKE with wildcards. Works fine for hundreds of records. If you're dealing with thousands, consider FTS (Full-Text Search), but I've never needed it for scan history.
That deleteOldScans query is useful - I run it on app launch to clear scans older than 30 days, but only if they're not favorited. Users don't lose their starred items.
Database Class
@Database(
entities = [ScanRecord::class],
version = 1
)
abstract class ScanDatabase : RoomDatabase() {
abstract fun scanDao(): ScanDao
}
For dependency injection with Hilt:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): ScanDatabase {
return Room.databaseBuilder(
context,
ScanDatabase::class.java,
"scan_database"
).build()
}
@Provides
fun provideScanDao(database: ScanDatabase): ScanDao {
return database.scanDao()
}
}
The @Singleton annotation matters. You want one database instance for the entire app. Multiple instances cause locking issues and weird bugs.
Relationships: One-to-Many in a Real App
Most Room tutorials show you isolated entities. In a real app, your data has relationships. Entities reference other entities. That's where things get interesting — and where Room's API requires a bit more ceremony than you'd expect.
I'll extend the QR scanner example. Instead of just storing individual scans, imagine grouping them into sessions — one scan session might capture 12 QR codes in a warehouse inventory check. That's a classic one-to-many: one ScanSession has many ScanRecords.
First, the parent entity:
@Entity(tableName = "scan_sessions")
data class ScanSession(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val label: String,
val createdAt: Long = System.currentTimeMillis()
)
Then update ScanRecord to reference it with a foreign key:
@Entity(
tableName = "scan_history",
foreignKeys = [ForeignKey(
entity = ScanSession::class,
parentColumns = ["id"],
childColumns = ["session_id"],
onDelete = ForeignKey.CASCADE
)],
indices = [Index("session_id")]
)
data class ScanRecord(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "session_id") val sessionId: Long,
val content: String,
val format: String,
val timestamp: Long,
@ColumnInfo(name = "is_favorite") val isFavorite: Boolean = false
)
The ForeignKey.CASCADE means if you delete a session, all its scans get deleted automatically. The @Index on session_id is important — without it, Room will warn you that foreign key columns should be indexed, and queries filtering by session will be slow.
Now the result class that joins them together:
data class ScanSessionWithScans(
@Embedded val session: ScanSession,
@Relation(
parentColumn = "id",
entityColumn = "session_id"
)
val scans: List<ScanRecord>
)
And the DAO query:
@Transaction
@Query("SELECT * FROM scan_sessions ORDER BY created_at DESC")
fun getSessionsWithScans(): Flow<List<ScanSessionWithScans>>
Why @Transaction is not optional here.
When you use @Relation, Room doesn't run a single JOIN query. It runs two queries — first for the parent rows, then for the children. If another thread writes between those two queries, you can get inconsistent results: sessions with missing or mismatched scans.
@Transaction wraps both queries in a single database transaction, making them atomic. I learned this the hard way when I was getting intermittent mismatches during high-frequency scans. Adding @Transaction fixed it immediately.
If Room is giving you @Relation results that look slightly off under load, this is almost certainly why.
For many-to-many relationships, you need a junction table — a separate entity that holds pairs of foreign keys. That's a post for another day, but the pattern builds directly on what's here.
ViewModel Integration
Here's how I connect the DAO to the UI:
@HiltViewModel
class ScanHistoryViewModel @Inject constructor(
private val scanDao: ScanDao
) : ViewModel() {
val scans: StateFlow<List<ScanRecord>> = scanDao.getAllScans()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
fun onScanCompleted(content: String, format: String) {
viewModelScope.launch {
scanDao.insert(
ScanRecord(
content = content,
format = format,
timestamp = System.currentTimeMillis()
)
)
}
}
fun onToggleFavorite(scanId: Long) {
viewModelScope.launch {
scanDao.toggleFavorite(scanId)
}
}
fun onDelete(scanId: Long) {
viewModelScope.launch {
scanDao.delete(scanId)
}
}
}
The WhileSubscribed(5000) keeps the Flow active for 5 seconds after the last subscriber disappears. This handles configuration changes - if the user rotates the screen, the Flow doesn't restart immediately.
In your Composable, just collect the state:
@Composable
fun ScanHistoryScreen(viewModel: ScanHistoryViewModel = hiltViewModel()) {
val scans by viewModel.scans.collectAsStateWithLifecycle()
LazyColumn {
items(scans, key = { it.id }) { scan ->
ScanItem(
scan = scan,
onFavoriteClick = { viewModel.onToggleFavorite(scan.id) },
onDeleteClick = { viewModel.onDelete(scan.id) }
)
}
}
}
When you insert, delete, or update a scan, the UI updates automatically. No manual refresh needed.
Migrations
This is where I messed up badly once.
I wanted to add a label field so users could add notes to scans. Simple change, right? I added the field to the Entity, bumped the version to 2, and shipped. The app crashed for every existing user.
Room doesn't know how to transform version 1 to version 2 automatically. You have to tell it:
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE scan_history ADD COLUMN label TEXT")
}
}
// In your database builder
Room.databaseBuilder(context, ScanDatabase::class.java, "scan_database")
.addMigrations(MIGRATION_1_2)
.build()
The migration SQL runs once when a user with version 1 opens the app after updating. Their data stays intact.
You might see fallbackToDestructiveMigration() in tutorials. This deletes all user data when the schema changes. Fine for development, but never use it in production. Users will uninstall your app if their data disappears.
Testing Your Room Database
Most developers I know write Room code and trust it implicitly. The DAO compiles, the app runs, feels fine. Then a migration ships, a column changes, and a query that worked for six months silently returns wrong data.
Room has solid testing support. There's no good reason not to use it.
In-memory database for unit tests.
Room.inMemoryDatabaseBuilder gives you a fully functional Room database that lives only in memory. It gets destroyed when the test ends — no cleanup, no leftover files, no state leaking between test runs.
@RunWith(AndroidJUnit4::class)
class ScanDaoTest {
private lateinit var db: ScanDatabase
private lateinit var dao: ScanDao
@Before
fun setup() {
db = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
ScanDatabase::class.java
).allowMainThreadQueries().build()
dao = db.scanDao()
}
@After
fun teardown() { db.close() }
@Test
fun insertAndRetrieveScan() = runTest {
val scan = ScanRecord(
content = "https://example.com",
format = "QR_CODE",
timestamp = 1000L
)
dao.insert(scan)
val result = dao.getAllScans().first()
assertEquals("https://example.com", result[0].content)
}
}
allowMainThreadQueries() is fine in tests — you're not worried about blocking the UI thread in a test environment. Don't use it in production code.
The runTest block from kotlinx-coroutines-test handles the coroutine context. If your DAO returns Flow, calling .first() suspends until the first emission and returns it.
Testing migrations.
Room provides MigrationTestHelper in the room-testing artifact. You point it at your migration, run it against a real database file on the test device, and verify your schema looks correct on the other side.
If you're not testing migrations, you're shipping blind. I've seen a missing ALTER TABLE statement wipe user data on upgrade. The migration compiled fine, looked fine, and only failed at runtime on the device. Add migration tests to your CI pipeline — it's one test file and it's saved me twice.
Mistakes I Made
Querying on the main thread. Room blocks this by default and crashes. If you see "Cannot access database on the main thread", you forgot to use a coroutine or background thread. All my DAO functions are either suspend or return Flow which handles threading automatically.
Forgetting @Transaction. If you're doing multiple database operations that should succeed or fail together, wrap them:
@Transaction
suspend fun replaceAllScans(scans: List<ScanRecord>) {
deleteAll()
insertAll(scans)
}
Without @Transaction, a crash between delete and insert leaves the database empty.
Not testing migrations. Room provides MigrationTestHelper for this. I didn't use it at first. Then I shipped a broken migration. Test your migrations.
Indexing. If you're querying by a specific column frequently (like searching by format), add an index:
@Entity(
tableName = "scan_history",
indices = [Index(value = ["format"])]
)
I didn't need this for scan history since the dataset is small, but it matters for larger tables.
When Room Isn't the Answer
Room is great for structured data with relationships. But sometimes it's overkill:
- Simple key-value pairs: Use DataStore instead. User preferences, settings, feature flags.
- Files or images: Store them in the filesystem, keep only the path in Room.
- Huge datasets with complex queries: Consider SQLDelight or direct SQLite if you need more control.
For most apps though, Room handles everything you need.
Room turned database code from my least favorite part of Android development into something I don't think about much anymore. It just works. The compile-time SQL checking alone has saved me from countless production bugs.
Start simple - one Entity, one DAO, basic CRUD. Add complexity when you actually need it. You probably don't need FTS, triggers, or database views for your first feature. I certainly didn't for scan history.