From c0d73f86c0d15883b5fc06ce10e8715d64eb7937 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 7 Jul 2026 22:45:41 +0200 Subject: [PATCH] new: replace in-memory record repository with JSON-backed implementation --- .gitignore | 3 + README.md | 7 +- data/records/build.gradle.kts | 12 ++ .../records/data/InMemoryRecordRepository.kt | 121 ------------------ .../records/data/JsonRecordRepository.kt | 61 +++++++++ .../data/local/model/RecordDataModel.kt | 19 +++ .../records/data/mappers/RecordMapper.kt | 28 ++++ .../chombev/records/di/RecordsModule.kt | 10 +- .../records/data/JsonRecordRepositoryTest.kt | 118 +++++++++++++++++ data/resources/README.md | 49 +++++++ .../files/records.example.json | 18 +++ gradle/libs.versions.toml | 3 + 12 files changed, 324 insertions(+), 125 deletions(-) delete mode 100644 data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/InMemoryRecordRepository.kt create mode 100644 data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepository.kt create mode 100644 data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/local/model/RecordDataModel.kt create mode 100644 data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/mappers/RecordMapper.kt create mode 100644 data/records/src/commonTest/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepositoryTest.kt create mode 100644 data/resources/README.md create mode 100644 data/resources/src/commonMain/composeResources/files/records.example.json diff --git a/.gitignore b/.gitignore index a8ac461..2f80f28 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ node_modules/ # Firebase config (App Distribution) — per-developer, not tracked (see google-services.example.json) androidApp/google-services.json +# Records data — course content kept out of Version Control (see records.example.json for the schema). +data/resources/src/commonMain/composeResources/files/records.json + # Android signing — must NOT be checked into Version Control keystore.properties *.jks diff --git a/README.md b/README.md index 0f796d6..75d3dc0 100644 --- a/README.md +++ b/README.md @@ -76,4 +76,9 @@ is shorter on iOS with OGG. Therefore, subtitles are not correctly synchronized. ## Subtitles -To know more about subtitles, see: [SUBTITLES.md](docs/SUBTITLES.md) \ No newline at end of file +To know more about subtitles, see: [SUBTITLES.md](docs/SUBTITLES.md) + +## Resources + +See [Resource's README.md](data/resources/README.md) to handle the resource files (list of records, audio files, +and subtitles). \ No newline at end of file diff --git a/data/records/build.gradle.kts b/data/records/build.gradle.kts index 573104b..3a83291 100644 --- a/data/records/build.gradle.kts +++ b/data/records/build.gradle.kts @@ -1,5 +1,6 @@ plugins { id("gwenedeg.kmp.library") + alias(libs.plugins.kotlinSerialization) } kotlin { @@ -11,6 +12,17 @@ kotlin { commonMain.dependencies { // Core modules implementation(projects.core.model) + implementation(projects.core.coroutines) + implementation(projects.core.logging) + + // Reads the un-versioned records.json bundled with the app resources + implementation(projects.data.resources) + + // JSON parsing + implementation(libs.kotlinx.serializationJson) + } + commonTest.dependencies { + implementation(libs.kotlinx.coroutinesTest) } } } diff --git a/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/InMemoryRecordRepository.kt b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/InMemoryRecordRepository.kt deleted file mode 100644 index 6bc3c68..0000000 --- a/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/InMemoryRecordRepository.kt +++ /dev/null @@ -1,121 +0,0 @@ -package bzh.ajaury.chombev.records.data - -import bzh.ajaury.chombev.core.model.Phrase -import bzh.ajaury.chombev.records.domain.RecordRepository -import bzh.ajaury.chombev.records.model.Record -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf - -internal class InMemoryRecordRepository : RecordRepository { - private val records = - listOf( - "🙏" to Phrase( - transcription = "An onestiz", - translation = "La politesse", - ), - "👋" to Phrase( - transcription = "En em brezantiñ", - translation = "Se présenter", - ), - "🌦️" to Phrase( - transcription = "An amzer a ra", - translation = "Le temps qu'il fait", - ), - "💬" to Phrase( - transcription = "E komz get unan arall", - translation = "Parler avec quelqu'un d'autre", - ), - "📖" to Phrase( - transcription = "Me hag ar brezhoneg", - translation = "Moi et le breton", - ), - "🩺" to Phrase( - transcription = "Ar yec'hed", - translation = "La santé", - ), - "🔍" to Phrase( - transcription = "Deskriviñ", - translation = "Décrire", - ), - "❤️" to Phrase( - transcription = "Me 'gar / Ne garan ket", - translation = "J'aime / Je n'aime pas", - ), - "✨" to Phrase( - transcription = "C'hoantoù", - translation = "Les envies", - ), - "🤔" to Phrase( - transcription = "Penaos 'peus kavet...?", - translation = "Comment as-tu trouvé... ?", - ), - "🎨" to Phrase( - transcription = "Oberiantizoù", - translation = "Les activités", - ), - "🍽️" to Phrase( - transcription = "Debriñ hag evet", - translation = "Manger et boire", - ), - "🚶🏻" to Phrase( - transcription = "Bale, pourmen ha beajiñ", - translation = "Marcher, se promener et voyager", - ), - "🧍🏼‍♀️" to Phrase( - transcription = "Ma c'horf (1)", - translation = "Mon corps (1)", - ), - "👦🏾" to Phrase( - transcription = "Ma c'horf (2)", - translation = "Mon corps (2)", - ), - "⏰" to Phrase( - transcription = "Dle eo / ret eo / dav eo / mall eo...", - translation = "Il faut que... / Il est temps que (de) ...", - ), - "📅" to Phrase( - transcription = "Deizioù ar sizhun", - translation = "Les jours de la semaine", - ), - "🍁" to Phrase( - transcription = "Mizioù ha kourzoù ar blez", - translation = "Les mois et les saisons de l'année", - ), - "📦" to Phrase( - transcription = "Bez'zo / N'eus ket...", - translation = "Il y a / Il n'y a pas...", - ), - "🤏" to Phrase( - transcription = "Banne.../Tamm...", - translation = "Un peu...", - ), - "🏠" to Phrase( - transcription = "Stad a vuhez (1)", - translation = "Situation de vie (1)", - ), - "🧑🏻‍❤️‍💋‍🧑🏽" to Phrase( - transcription = "Stad a vuhez (2)", - translation = "Situation de vie (2)", - ), - "🧭" to Phrase( - transcription = "Lec'hiiñ", - translation = "Situer", - ), - ).mapIndexed { index, (emoji, phrase) -> - val number = index + 1 - val paddedNumber = number.toString().padStart(2, '0') - Record( - id = number, - index = number, - emoji = emoji, - title = phrase, - audioResourcePath = "files/Chom_bev_$paddedNumber.m4a", - subtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_bzh.lrc", - translationSubtitleResourcePath = "files/Chom_bev_${paddedNumber}_st_fr.lrc", - ) - } - - override fun getRecords(): Flow> = flowOf(records) - - override suspend fun getRecord(id: Int): Record? = records.firstOrNull { it.id == id } -} diff --git a/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepository.kt b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepository.kt new file mode 100644 index 0000000..2002b5a --- /dev/null +++ b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepository.kt @@ -0,0 +1,61 @@ +package bzh.ajaury.chombev.records.data + +import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider +import bzh.ajaury.chombev.core.logging.domain.Logger +import bzh.ajaury.chombev.records.data.local.model.RecordDataModel +import bzh.ajaury.chombev.records.data.mappers.RecordMapper +import bzh.ajaury.chombev.records.domain.RecordRepository +import bzh.ajaury.chombev.records.model.Record +import bzh.ajaury.chombev.resources.domain.ResourceReader +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json + +/** + * Loads the record catalogue from the un-versioned `files/records.json` data file (read through + * [ResourceReader], the same mechanism used for subtitle files). The parsed list is cached after the + * first read so the file is only read and decoded once. + * + * A read or parse failure is logged and degrades to an empty list rather than crashing the app. + */ +internal class JsonRecordRepository( + private val resourceReader: ResourceReader, + private val json: Json, + private val recordMapper: RecordMapper, + private val dispatcherProvider: DispatcherProvider, + private val logger: Logger, +) : RecordRepository { + private val mutex = Mutex() + private var cachedRecords: List? = null + + override fun getRecords(): Flow> = flow { emit(loadRecords()) } + + override suspend fun getRecord(id: Int): Record? = loadRecords().firstOrNull { it.id == id } + + private suspend fun loadRecords(): List = + mutex.withLock { + cachedRecords ?: readRecords().also { cachedRecords = it } + } + + private suspend fun readRecords(): List = + try { + val content = resourceReader.read(resourcePath = RECORDS_RESOURCE_PATH) + withContext(dispatcherProvider.default) { + val dataModels = json.decodeFromString>(content) + recordMapper.map(dataModels) + } + } catch (exception: Exception) { + logger.error( + message = "Failed to load records from $RECORDS_RESOURCE_PATH", + throwable = exception, + ) + emptyList() + } + + private companion object { + const val RECORDS_RESOURCE_PATH = "files/records.json" + } +} diff --git a/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/local/model/RecordDataModel.kt b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/local/model/RecordDataModel.kt new file mode 100644 index 0000000..85ddcca --- /dev/null +++ b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/local/model/RecordDataModel.kt @@ -0,0 +1,19 @@ +package bzh.ajaury.chombev.records.data.local.model + +import kotlinx.serialization.Serializable + +/** + * A single record entry as stored in the un-versioned `files/records.json` data file. + * + * This mirrors the JSON schema exactly; it is mapped to the domain [bzh.ajaury.chombev.records.model.Record] + * by [bzh.ajaury.chombev.records.data.mappers.RecordMapper]. + */ +@Serializable +internal data class RecordDataModel( + val emoji: String, + val transcription: String, + val translation: String? = null, + val audio: String, + val subtitle: String, + val translationSubtitle: String? = null, +) diff --git a/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/mappers/RecordMapper.kt b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/mappers/RecordMapper.kt new file mode 100644 index 0000000..ef722e6 --- /dev/null +++ b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/data/mappers/RecordMapper.kt @@ -0,0 +1,28 @@ +package bzh.ajaury.chombev.records.data.mappers + +import bzh.ajaury.chombev.core.model.Phrase +import bzh.ajaury.chombev.records.data.local.model.RecordDataModel +import bzh.ajaury.chombev.records.model.Record + +/** + * Maps the serialized [RecordDataModel]s from the data file to domain [Record]s, assigning a + * 1-based [Record.id]/[Record.index] from each entry's position in the file. + */ +internal class RecordMapper { + fun map(dataModels: List): List = + dataModels.mapIndexed { position, dataModel -> + val number = position + 1 + Record( + id = number, + index = number, + emoji = dataModel.emoji, + title = Phrase( + transcription = dataModel.transcription, + translation = dataModel.translation, + ), + audioResourcePath = dataModel.audio, + subtitleResourcePath = dataModel.subtitle, + translationSubtitleResourcePath = dataModel.translationSubtitle, + ) + } +} diff --git a/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/di/RecordsModule.kt b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/di/RecordsModule.kt index bf34e08..dbfd31b 100644 --- a/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/di/RecordsModule.kt +++ b/data/records/src/commonMain/kotlin/bzh/ajaury/chombev/records/di/RecordsModule.kt @@ -1,12 +1,16 @@ package bzh.ajaury.chombev.records.di -import bzh.ajaury.chombev.records.data.InMemoryRecordRepository +import bzh.ajaury.chombev.records.data.JsonRecordRepository +import bzh.ajaury.chombev.records.data.mappers.RecordMapper import bzh.ajaury.chombev.records.domain.RecordRepository +import kotlinx.serialization.json.Json import org.koin.core.module.Module import org.koin.core.module.dsl.bind -import org.koin.core.module.dsl.factoryOf +import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val recordsModule: Module = module { - factoryOf(::InMemoryRecordRepository) { bind() } + single { Json { ignoreUnknownKeys = true } } + singleOf(::RecordMapper) + singleOf(::JsonRecordRepository) { bind() } } diff --git a/data/records/src/commonTest/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepositoryTest.kt b/data/records/src/commonTest/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepositoryTest.kt new file mode 100644 index 0000000..6b13911 --- /dev/null +++ b/data/records/src/commonTest/kotlin/bzh/ajaury/chombev/records/data/JsonRecordRepositoryTest.kt @@ -0,0 +1,118 @@ +package bzh.ajaury.chombev.records.data + +import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider +import bzh.ajaury.chombev.core.logging.domain.Logger +import bzh.ajaury.chombev.records.data.mappers.RecordMapper +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class JsonRecordRepositoryTest { + private val validJson = + """ + [ + { + "emoji": "🙏", + "transcription": "An onestiz", + "translation": "La politesse", + "audio": "files/Chom_bev_01.m4a", + "subtitle": "files/Chom_bev_01_st_bzh.lrc", + "translationSubtitle": "files/Chom_bev_01_st_fr.lrc" + }, + { + "emoji": "👋", + "transcription": "En em brezantiñ", + "translation": "Se présenter", + "audio": "files/Chom_bev_02.m4a", + "subtitle": "files/Chom_bev_02_st_bzh.lrc" + } + ] + """.trimIndent() + + @Test + fun maps_json_entries_to_records_with_one_based_ids() = runTest { + val repository = repository(content = validJson) + + val records = repository.getRecords().first() + + assertEquals(2, records.size) + val first = records[0] + assertEquals(1, first.id) + assertEquals(1, first.index) + assertEquals("🙏", first.emoji) + assertEquals("An onestiz", first.title.transcription) + assertEquals("La politesse", first.title.translation) + assertEquals("files/Chom_bev_01.m4a", first.audioResourcePath) + assertEquals("files/Chom_bev_01_st_bzh.lrc", first.subtitleResourcePath) + assertEquals("files/Chom_bev_01_st_fr.lrc", first.translationSubtitleResourcePath) + + val second = records[1] + assertEquals(2, second.id) + // translationSubtitle is optional and absent in the second entry. + assertNull(second.translationSubtitleResourcePath) + } + + @Test + fun getRecord_returns_the_entry_with_the_matching_id() = runTest { + val repository = repository(content = validJson) + + assertEquals("👋", repository.getRecord(id = 2)?.emoji) + assertNull(repository.getRecord(id = 99)) + } + + @Test + fun malformed_json_degrades_to_an_empty_list() = runTest { + val repository = repository(content = "{ not valid json ") + + assertEquals(emptyList(), repository.getRecords().first()) + } + + @Test + fun a_read_failure_degrades_to_an_empty_list() = runTest { + val repository = repository(readError = IllegalStateException("missing resource")) + + assertEquals(emptyList(), repository.getRecords().first()) + } + + private fun repository( + content: String = "[]", + readError: Throwable? = null, + ): JsonRecordRepository = + JsonRecordRepository( + resourceReader = FakeResourceReader(content = content, readError = readError), + json = Json { ignoreUnknownKeys = true }, + recordMapper = RecordMapper(), + dispatcherProvider = UnconfinedDispatcherProvider, + logger = NoOpLogger, + ) + + private class FakeResourceReader( + private val content: String, + private val readError: Throwable?, + ) : bzh.ajaury.chombev.resources.domain.ResourceReader { + override suspend fun read(resourcePath: String): String { + readError?.let { throw it } + return content + } + + override fun uri(resourcePath: String): String = resourcePath + } + + private object UnconfinedDispatcherProvider : DispatcherProvider { + override val main: CoroutineDispatcher = Dispatchers.Unconfined + override val default: CoroutineDispatcher = Dispatchers.Unconfined + override val io: CoroutineDispatcher = Dispatchers.Unconfined + } + + private object NoOpLogger : Logger { + override fun debug(message: String, throwable: Throwable?) = Unit + override fun info(message: String, throwable: Throwable?) = Unit + override fun warning(message: String, throwable: Throwable?) = Unit + override fun error(message: String, throwable: Throwable?) = Unit + } +} diff --git a/data/resources/README.md b/data/resources/README.md new file mode 100644 index 0000000..7897b87 --- /dev/null +++ b/data/resources/README.md @@ -0,0 +1,49 @@ +# `:data:resources` + +Owns the app's Compose Multiplatform resources (`Res`) and exposes them to other modules through the +`ResourceReader` interface, so consumer modules stay free of any platform/resource dependency. + +## Un-versioned content + +Some content is **not** checked into git (it is large and/or licensed course material). It must be +present locally under `src/commonMain/composeResources/files/` for the app to work, but is excluded +via `.gitignore`: + +| Content | Files | Git-ignored by | +|------------------|-----------------------|-------------------------------| +| Audio recordings | `Chom_bev_*.m4a` | `*.m4a` | +| Subtitles | `Chom_bev_*_st_*.lrc` | `*.lrc` | +| Record catalogue | `records.json` | explicit path in `.gitignore` | + +A fresh checkout will not contain these files; obtain them from another team member / the asset store +and drop them into `src/commonMain/composeResources/files/`. + +## Records data (`records.json`) + +The list of lessons shown in the app is loaded at runtime from +`src/commonMain/composeResources/files/records.json` (read via `ResourceReader`, then parsed with +`kotlinx.serialization` in `:data:records`). It is a JSON array of objects: + +| Field | Type | Notes | +|-----------------------|----------|----------------------------------------------| +| `emoji` | string | Shown next to the record to illustrate it. | +| `transcription` | string | Breton title. | +| `translation` | string? | French translation (optional). | +| `audio` | string | Resource path, e.g. `files/Chom_bev_01.m4a`. | +| `subtitle` | string | Breton subtitle path (`.lrc`). | +| `translationSubtitle` | string? | Translation subtitle path (optional). | + +The record `id`/`index` are assigned from each entry's 1-based position in the array. + +**Getting started on a fresh checkout:** copy the versioned template and fill it in (or replace it +with the real catalogue): + +```bash +cp data/resources/src/commonMain/composeResources/files/records.example.json \ + data/resources/src/commonMain/composeResources/files/records.json +``` + +`records.example.json` is versioned and documents the schema. `records.json` is git-ignored. + +Adding/reordering a record is a pure data edit in `records.json` — no code change needed. Make sure +the referenced `.m4a`/`.lrc` files exist in the same `files/` directory. diff --git a/data/resources/src/commonMain/composeResources/files/records.example.json b/data/resources/src/commonMain/composeResources/files/records.example.json new file mode 100644 index 0000000..b607c61 --- /dev/null +++ b/data/resources/src/commonMain/composeResources/files/records.example.json @@ -0,0 +1,18 @@ +[ + { + "emoji": "🙏", + "transcription": "An onestiz", + "translation": "La politesse", + "audio": "files/Chom_bev_01.m4a", + "subtitle": "files/Chom_bev_01_st_bzh.lrc", + "translationSubtitle": "files/Chom_bev_01_st_fr.lrc" + }, + { + "emoji": "👋", + "transcription": "En em brezantiñ", + "translation": "Se présenter", + "audio": "files/Chom_bev_02.m4a", + "subtitle": "files/Chom_bev_02_st_bzh.lrc", + "translationSubtitle": "files/Chom_bev_02_st_fr.lrc" + } +] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 79647f8..30523d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ kotlinWrappers = "2026.7.0" kotlinxBrowser = "0.5.0" kotlinxCoroutines = "1.11.0" kotlinSerialization = "2.4.0" +kotlinxSerializationJson = "1.11.0" material3 = "1.11.0-alpha07" koin = "4.2.2" kermit = "2.1.0" @@ -55,6 +56,8 @@ kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotl kotlinx-browser = { module = "org.jetbrains.kotlinx:kotlinx-browser", version.ref = "kotlinxBrowser" } kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" } +kotlinx-coroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } +kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycle" }