feat(android): Room foundation — AppDatabase + TypeConverters + Gradle plugin

M8 phase 4.1. Pre-flight research (per the feedback_m8_preflight_research
rule) found two needed adjustments to the original plan:

  1. Room 2.6.1 -> 2.8.4. Room 2.6.1 (Oct 2023) predates Kotlin 2.0
     mainstream; Room 2.7+ explicitly supports Kotlin 2.0+ and KSP2.
     Room 2.8.4 is current stable; minSdk 23 (we're at 26) and AGP 8.4+
     (we're on 9), both compatible.
  2. Use the androidx.room Gradle plugin's `room { schemaDirectory(...) }`
     block instead of the legacy `ksp { arg("room.schemaLocation", ...) }`
     pattern. Cleaner schema-export plumbing in Room 2.7+. Audit-deferred
     item; trigger condition (first Room entity) met here.

Files:
  - cache/db/AppDatabase.kt — @Database stub, schemaVersion 1
  - cache/db/TypeConverters.kt — Instant <-> Long, CacheSource enum
  - cache/db/DatabaseModule.kt — Hilt-provided AppDatabase singleton
  - cache/db/entities/SyncMetadataEntity.kt — pulled forward from
    Task 4.2 slice 7 to satisfy Room's "needs >=1 entity" compile check;
    its consumer (SyncController) lands in Phase 12.4
  - cache/db/dao/SyncMetadataDao.kt — minimal observe/get/upsert
  - libs.versions.toml — Room 2.8.4, androidx-room plugin alias
  - app/build.gradle.kts — apply androidx.room plugin, add room {}
    block, drop ksp room.schemaLocation arg

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 13:22:11 -04:00
parent 4cbbdbfef9
commit a10079e8ef
7 changed files with 155 additions and 6 deletions
+12 -5
View File
@@ -7,6 +7,7 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.ksp)
alias(libs.plugins.androidx.room)
alias(libs.plugins.hilt)
alias(libs.plugins.ktlint)
alias(libs.plugins.detekt)
@@ -24,13 +25,12 @@ android {
versionName = "0.1.0-native"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
arg("room.incremental", "true")
}
}
// Room schema export is handled by the androidx.room Gradle plugin via
// the room {} block below — replaces the legacy
// `ksp { arg("room.schemaLocation", ...) }` pattern in Room 2.7+.
signingConfigs {
create("release") {
val keystorePath: String? = System.getenv("ANDROID_KEYSTORE_PATH")
@@ -89,6 +89,13 @@ kotlin {
}
}
// Room schema export — generated JSON dumps live under android/app/schemas/
// for migration-test fixtures. Replaces the legacy
// `ksp { arg("room.schemaLocation", ...) }` arg-passing.
room {
schemaDirectory("$projectDir/schemas")
}
detekt {
toolVersion = libs.versions.detekt.get()
config.setFrom(files("$rootDir/config/detekt.yml"))
@@ -0,0 +1,34 @@
package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.fabledsword.minstrel.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
/**
* Single Room database for the whole app. Mirrors the Flutter client's
* Drift `AppDb` — same logical schema, one entity-family per @Entity.
*
* Native v1 starts at schema version 1 (the Flutter Drift schemaVersion 11
* is an internal client detail; the server is the source of truth and the
* sync controller refills on first launch). Future schema bumps land per
* change with explicit `Migration` entries; `fallbackToDestructiveMigration`
* in `DatabaseModule` is the safety net while we're pre-v1.
*
* Most entity families land in Task 4.2 (one commit per Drift table
* family). `SyncMetadataEntity` was pulled forward into Task 4.1 to
* satisfy Room's "at least one entity required" compile-time check;
* its consumer (SyncController) arrives in Phase 12.4.
*/
@Database(
entities = [
SyncMetadataEntity::class,
],
version = 1,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun syncMetadataDao(): SyncMetadataDao
}
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.cache.db
import android.content.Context
import androidx.room.Room
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
// Pre-v1 safety net: rebuild on any schema mismatch. The sync
// controller refills the cache from the server on first
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
private const val DATABASE_NAME = "minstrel.db"
}
@@ -0,0 +1,35 @@
package com.fabledsword.minstrel.cache.db
import androidx.room.TypeConverter
import kotlinx.datetime.Instant
/**
* Source category for a cached audio file. Mirrors the Drift `CacheSource`
* enum used by the Flutter client's `audio_cache_index` table.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `INCIDENTAL`: cached as a side effect of playback streaming
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
*/
enum class CacheSource { MANUAL, INCIDENTAL, AUTO_PREFETCH }
/**
* Cross-cutting Room type converters. Registered on `@Database` via
* `@TypeConverters(MinstrelTypeConverters::class)`.
*
* `kotlinx.datetime.Instant` ↔ `Long` epoch millis: the same convention the
* Flutter client uses (Drift `text()` column with ISO-8601 strings would
* also work but Long is leaner; we don't need human-readable rows in the
* SQLite file).
*/
class MinstrelTypeConverters {
@TypeConverter fun instantToLong(i: Instant?): Long? = i?.toEpochMilliseconds()
@TypeConverter fun longToInstant(l: Long?): Instant? =
l?.let { Instant.fromEpochMilliseconds(it) }
@TypeConverter fun sourceToName(s: CacheSource?): String? = s?.name
@TypeConverter fun nameToSource(n: String?): CacheSource? =
n?.let { CacheSource.valueOf(it) }
}
@@ -0,0 +1,20 @@
package com.fabledsword.minstrel.cache.db.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface SyncMetadataDao {
@Query("SELECT * FROM sync_metadata WHERE id = 0")
fun observe(): Flow<SyncMetadataEntity?>
@Query("SELECT * FROM sync_metadata WHERE id = 0")
suspend fun get(): SyncMetadataEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: SyncMetadataEntity)
}
@@ -0,0 +1,24 @@
package com.fabledsword.minstrel.cache.db.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.datetime.Instant
/**
* Single-row table that tracks where the SyncController left off.
*
* Mirrors the Flutter Drift `sync_metadata` table. There is only ever
* one row at `id = 0`; the SyncController upserts on every successful
* `/api/sync` call.
*
* Pulled into Task 4.1 to give Room a compileable @Database (it needs
* at least one entity); the SyncController itself lands in Phase 12.4.
*/
@Entity(tableName = "sync_metadata")
data class SyncMetadataEntity(
@PrimaryKey val id: Int = 0,
/** Server-issued cursor used as `cursor` query param on the next sync. */
val cursor: Long = 0,
/** Wall-clock time of the last successful sync — diagnostic only. */
val lastSyncAt: Instant? = null,
)
+2 -1
View File
@@ -14,7 +14,7 @@ hilt = "2.59.2"
hilt-androidx = "1.2.0"
compose-bom = "2026.05.01"
nav-compose = "2.8.3"
room = "2.6.1"
room = "2.8.4"
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinx-serialization = "1.7.3"
@@ -81,6 +81,7 @@ compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
androidx-room = { id = "androidx.room", version.ref = "room" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }