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:
@@ -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
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+35
@@ -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) }
|
||||
}
|
||||
+20
@@ -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)
|
||||
}
|
||||
+24
@@ -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,
|
||||
)
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user