From a10079e8ef30bf57492e5d2c026787692ce90b7e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 13:22:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(android):=20Room=20foundation=20=E2=80=94?= =?UTF-8?q?=20AppDatabase=20+=20TypeConverters=20+=20Gradle=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- android/app/build.gradle.kts | 17 ++++++--- .../minstrel/cache/db/AppDatabase.kt | 34 ++++++++++++++++++ .../minstrel/cache/db/DatabaseModule.kt | 28 +++++++++++++++ .../minstrel/cache/db/TypeConverters.kt | 35 +++++++++++++++++++ .../minstrel/cache/db/dao/SyncMetadataDao.kt | 20 +++++++++++ .../cache/db/entities/SyncMetadataEntity.kt | 24 +++++++++++++ android/gradle/libs.versions.toml | 3 +- 7 files changed, 155 insertions(+), 6 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/SyncMetadataDao.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/SyncMetadataEntity.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 95c1f63c..b9057c5e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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")) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt new file mode 100644 index 00000000..6d572e36 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/AppDatabase.kt @@ -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 +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt new file mode 100644 index 00000000..c3a66e0a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/DatabaseModule.kt @@ -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" +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt new file mode 100644 index 00000000..289cc15a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/TypeConverters.kt @@ -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) } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/SyncMetadataDao.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/SyncMetadataDao.kt new file mode 100644 index 00000000..f9a71df0 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/dao/SyncMetadataDao.kt @@ -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 + + @Query("SELECT * FROM sync_metadata WHERE id = 0") + suspend fun get(): SyncMetadataEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(row: SyncMetadataEntity) +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/SyncMetadataEntity.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/SyncMetadataEntity.kt new file mode 100644 index 00000000..6cb775d7 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/db/entities/SyncMetadataEntity.kt @@ -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, +) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index a2cb845d..1d8d6615 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" }