# Campfire Kotlin Multiplatform app (Android + iOS + JVM desktop + wasmJs web) for viewing and editing song lyrics and chords. Compose UI is shared between all platforms. The app owns a library folder of plain [ChordPro](https://www.chordpro.org) files on every platform, which the user fills by writing songs in the built-in editor or by importing files and zip archives. **The only thing that ever reaches the network is sync**, which is off until the user connects a cloud folder of their own in Settings, and which still involves no server of Campfire's own — see the Sync section below. (The Android build also asks Play whether a newer version of itself exists, but that question is answered over IPC by the Play Store app; Campfire's own process makes no request — see Updates below.) ## Architecture Strict `api` / `implementation` module split at every layer. Only `:app:*` modules see implementations; everything else depends on `api` modules and gets wiring via Koin. ``` app:android / app:desktop / app:ios / app:web entry points, Koin startup, platform chrome, "open with" and share intents (app:ios also holds the Xcode project, app:web the index.html) presentation CampfireViewModel, Navigation 3 back stack, Material 3 theme + every screen (Songs, Setlists, Settings, SongDetails, SongEditor), string resources; the platform shells (system bars, file pickers, drag and drop, URL opening, desktop key handling) are its platform source sets domain:api / :implementation use cases (single-method interfaces) data:repository:api / :implementation data:source:local:api -> :implementation files on Android/desktop/iOS, OPFS on web (see Web below); also holds the pure-Kotlin zip reader/writer data:source:remote:api -> :implementation the sync contracts and the Dropbox provider; the only module in the project that makes a network call (see Sync below) data:model domain models, shared by everything chordpro dependency-free ChordPro model, parser, serializer, transposer, tag editor and highlighter. Depends on nothing; used by :data:source:local:implementation (metadata for the song list), :domain:api and :presentation ``` Data flow: `FileStorage` (one flat directory per kind of file) -> `LocalSource` (files in, models out) -> `Repository` (emits `DataState`, reads once and caches) -> use cases (`GetScreenDataUseCase` combines the song and setlist repositories into one `ScreenData` flow) -> `CampfireViewModel` (a lifecycle `ViewModel` exposing `StateFlow`s and the Navigation 3 back stack) -> screens (`collectAsStateWithLifecycle`). The library layout, inside the app-private data directory of each platform: ``` library/songs/*.cho one song per file; the file name is the song's identity library/setlists/*.setlist.json one setlist per file, exported together with the songs preferences/preferences.json everything in UserPreferences; outside library/, so it is never exported preferences/sync-credentials.json the connected account's tokens, and an unfinished authorization preferences/sync-index.json what the last successful sync run saw ``` ## Conventions - Library modules apply the convention plugins from `gradle/build-logic` (`campfire-library`, or `campfire-compose-library` when they contain Compose). These configure the Android, `desktop` (JVM), `iosArm64`, `iosSimulatorArm64` and `wasmJs` (browser) targets and derive the Android namespace from the Gradle path. Sources live in `src/commonMain/kotlin`; platform code goes in `androidMain` / `desktopMain` / `iosMain` / `wasmJsMain` via `expect`/`actual`. - Shared code must stay JVM-free: no `java.*`, `KoinJavaComponent`, or JVM-only libraries. Use `kotlin.uuid.Uuid`, `androidx.compose.ui.text.intl.Locale`, `KoinPlatform.getKoin()`, and `import kotlinx.coroutines.IO` for `Dispatchers.IO`. - UI strings live in `presentation/src/commonMain/composeResources/values[-hu]/strings.xml`. Read them with `com.pandulapeter.campfire.presentation.localization.stringResource(Res.string.x)` (generated by the `com.hyperether.localization` plugin, switchable at runtime via `currentLanguage`), never with the `org.jetbrains.compose.resources` variant, which ignores the in-app language. Add every new string to both files; formatted strings must always be called with their arguments. A counted sentence whose singular reads differently is a `` with `one` and `other` items, read with `pluralStringResource`, rather than a second key. - The UI is Material 3 Expressive (`org.jetbrains.compose.material3:material3`, versioned separately from Compose Multiplatform in `jetbrains-compose-material3`); don't add `androidx.compose.material` (M2) back. - `:app:android` is a plain Android module, `:app:desktop` a plain JVM one, `:app:ios` Kotlin/Native-only and `:app:web` Kotlin/Wasm-only; every other module (`:presentation` and `:chordpro` included) is a multiplatform library. - Every module's Koin wiring lives in a top-level `Module.kt` exposing one `val xxxModule = module { ... }`. New bindings go there. `:chordpro` has none: it is a set of stateless objects, reached through use cases. - Implementation classes are `internal` and named `Impl`. Use cases are `operator fun invoke`. - Repositories extend `BaseLocalDataRepository`, which holds the cached `DataState` and the read-once logic. - Layer boundaries are crossed via mappers (`mapper/` packages), never by leaking document/entity types. - **A setlist shows every song it names**, whatever the Songs screen is filtered to: the filters narrow a view of the library, while a setlist is the list somebody wrote down. What the Setlists screen's own controls ask is the order the setlists come in and whether the archived ones are among them. Archiving is how a setlist that has been played is put away without the songs in it being lost; it is a field of the `*.setlist.json` file rather than a preference, so it travels through an export, an import or a sync run the way a tag does. - **Tags are part of the song file**, not a store of their own: ChordPro `{tag}` directives, read by `:chordpro` into `Song.tags` at scan time and written back into the text the same way, so a tag travels with the file through an export, an import or a sync run. The library's set of tags is whatever the songs carry; the Songs screen's filter offers them counted and most used first, and the song details header is where one is put on or taken off. - **The language of a song is carried the same way, and is its own category rather than one more tag**: a `{meta: language en}` directive per language, read into `Song.languages` as a lowercase ISO code — 639-2's three letter codes included, folded to their 639-1 equivalent where the standard has one (`eng` is `en`) and kept as they are where it does not (`rom`, Romani), so one language is one code however the file spells it. It gets its own filter group on the Songs screen — but only once the library holds more than one language, with an "Unknown" chip for the songs that declare none — and it is shown wherever a tag is: next to them under a song in the lists, and as a chip in the song details header, which is also what opens the picker. The **names are never shipped**: the app carries a list of codes and nothing else, and asks the platform what each is called in the language the app is set to (`java.util.Locale`, `NSLocale`, `Intl.DisplayNames` behind `:presentation`'s `languageDisplayName`), falling back to the code in capitals where it cannot say. - **The app is shipped with a handful of songs and one setlist**, in `presentation/src/commonMain/composeResources/files/demo`: public domain campfire standards, bundled as the plain ChordPro and setlist files they are and reaching the library through the ordinary import, so they collide, are numbered and are disregarded when the same file is already there like anything else. They are planted once, on a run that finds no preferences document *and* an empty library — which is what a fresh installation looks like from the inside, and is why a library somebody has been using is never touched — and Settings offers to add them for as long as the library is missing any of them, so a deleted one comes back by being asked for rather than on its own. Each file is named exactly as the library would name the song inside it, which is what lets one list both read the resources and answer whether they are already there. - The file name is a song's (and a setlist's) identity. Nothing is ever overwritten implicitly: a new or imported file that collides gets a `_2`, `_3`… suffix (`FileNames.kt`). An **import decides before it writes**: every incoming file is held against the name it wants (`PrepareImportUseCase` -> `ImportPlan`), a name taken by something with exactly the same content is disregarded rather than copied, and the ones taken by something *different* are put to the user as one question about the whole batch — keep both, replace, skip, or cancel the import. Replacing is the only thing in the app that ever overwrites a library file, and it takes an answer to that dialog. - **Every name the app writes is normalized** — lowercase unaccented words joined with underscores (`LibraryFiles.normalizedName`), a song's `artist` and `title` folded one at a time so the dash between them survives as structure: `tukorfurogep-arviz.cho`, `summer_set_2026.setlist.json`, colliding as `_2`. Three of the folding rules are there so that the same song written down by two people arrives at one name: an apostrophe is dropped rather than folded to a separator (`dont_cry`), `&` and `+` are spelled out (`rock_and_roll`), and a credit is filed under `ft` however it was abbreviated. The rule is idempotent, which it has to be, since a name that left the app is normalized again on its way back in. - **A song is named by its own header, wherever it came from**: `{artist}`, `{title}` and `{subtitle}`, the subtitle joining the title half (`green_day-good_riddance_time_of_your_life.cho`) because it is part of the title everywhere else in the app. That holds for a song written in the editor, one that arrives through an import (`SongLocalSource.importFileName`) and one handed out by an export (`ExportFileNames.kt`) alike — the name a file arrives under counts for nothing except where the song inside it declares no `{title}`, in which case it stands in as the title, since that is what would title the song in the library anyway. So the invariant worth stating plainly is that **a file name is reproducible from its header alone**, and `Song.canUpdateFileName` is what notices where that has stopped being true. Inside an exported archive the entries keep their library names, since a setlist points at its songs by file name. - **A file is only ever renamed by the app when the user asks for it, or when nothing is lost by it.** A setlist's file follows its title, because that title is written inside the document and the file name records nothing (`RenameSetlistUseCase`). A song's does not: its name is what titles it wherever the file declares no `{title}`, it is what a setlist points at, and on the platforms where the library is a folder the user may have chosen it — an import is not one of those cases, since nothing has pointed at the incoming name yet. Where a song's name and its metadata have drifted apart, `Song.canUpdateFileName` puts an **Update file name** entry in its menu, and taking it moves the file and everything that named it — every setlist entry, the saved transposition, the open screens (`RenameSongFileUseCase`). Files that were named before any of this keep their names until one of those two things happens to them. - A rename reaches **sync** as a deletion and a new file, since `SyncPlanner` is keyed by name and knows no moves. The "an edit beats a deletion" rule then applies: a device that edited the file under its old name since the last run puts that file back, leaving both. - Only pure logic is tested: `commonTest` unit tests in `:chordpro`, `:data:source:local:implementation` (zip and the JVM file storage), `:data:source:remote:*` (hashing, encoders, the OAuth authorization URL) and `:data:repository:implementation` (`SyncPlanner`, which decides what happens to every file in a sync run), run on the desktop target with `./gradlew :chordpro:desktopTest :data:source:local:implementation:desktopTest :data:source:remote:api:desktopTest :data:source:remote:implementation:desktopTest :data:repository:implementation:desktopTest`. The UI is untested. ## Build - Dependency versions in `gradle/libs.versions.toml` (including `android-compileSdk` / `android-minSdk`). The iOS version lives in the Xcode project. - **Everything configurable is a `campfire.*` Gradle property**, declared with a default in `gradle.properties` and read with `project.property("campfire.x")`: the app version and version code, the Android release signing values, and the Dropbox app key. `property` rather than `findProperty`, so a typo fails the build instead of writing the string "null" into an APK. Inside a `tasks.registering { }` block it has to be `project.property(...)`, or the lookup goes to the task. - **`local.properties` overrides any of them, and is never committed.** `settings.gradle.kts` loads it and writes each entry onto every project before it is configured, so no build file knows the mechanism exists — they all just read a property. That is the whole secret story: nothing private is in the repository, and a fresh clone still builds every variant, because the checked-in defaults point at the debug keystore committed next to them and at an empty sync key. A release built that way is installable but not publishable, and Settings says sync is not configured. To sign for real, or to build with sync, add the keys to `local.properties`: ```properties campfire.android.keyAlias=... campfire.android.keyPassword=... campfire.android.keystoreFile=release.keystore # relative to app/android, or an absolute path campfire.android.keystorePassword=... campfire.dropbox.appKey=... ``` CI has no `local.properties`, so it passes the same names with `-Pcampfire.android.keyAlias=…` or writes the file from its own secret store; the latter keeps the values out of the process list. - The `campfire-library` convention plugin sets each module's `archivesName` from its Gradle path, because a klib carries the name of the artifact it is built into and half the modules here are called `api` or `implementation`. - `./gradlew :app:android:assembleDebug` — Android APK - `./gradlew :app:desktop:run` — desktop app; `:app:desktop:packageDistributionForCurrentOS` for installers - `./gradlew :app:ios:linkDebugFrameworkIosSimulatorArm64` — compile/link check of the iOS framework; run the app from Xcode (`app/ios/iosApp/iosApp.xcodeproj`) or with `xcodebuild -project app/ios/iosApp/iosApp.xcodeproj -target iosApp -sdk iphonesimulator -arch arm64 SYMROOT= OBJROOT= build`, then `xcrun simctl install/launch`. - `./gradlew :app:web:wasmJsBrowserDevelopmentRun` — web app on a dev server; `:app:web:wasmJsBrowserDistribution` writes the deployable site to `app/web/build/dist/wasmJs/productionExecutable`. - Releases go out through two manually dispatched workflows in `.github/workflows`, both of them the local build command plus the secrets a checkout does not have. Every build passes `campfire.dropbox.appKey` from the `DROPBOX_APP_KEY` secret, because a published app built without it would quietly have no sync provider at all. - `web-publish.yml` builds the distribution and copies it over `campfire/` in the `pandulapeter.github.io` repository, which it reaches with the deploy key in `WEBSITE_DEPLOY_KEY`. The copy is an `rsync --delete`, so the folder holds nothing but the distribution — the privacy policy and the rest of the site live elsewhere there. - `android-publish.yml` writes the keystore out of `ANDROID_KEYSTORE_BASE64`, builds `assembleRelease` signed with the other three `ANDROID_*` secrets, and uploads the APK and its mapping file to the production track with `PLAY_SERVICE_ACCOUNT_JSON`. It is an **APK** and not an app bundle because the Play listing predates the bundle requirement and was never migrated; a `bundleRelease` would be rejected on upload. The "what's new" text comes from the workflow's two inputs, one per listing language: the English one falls back to the commit log since the last successful run, and the Hungarian one to the English text, since nothing can translate a commit log and a listing saying something true in the wrong language beats one saying nothing. Its `update_priority` input is what decides whether the new version says anything about itself inside the old one — see Updates below. ## Sync Off until the user connects a cloud folder in Settings, and built so that Dropbox is the first provider rather than the only possible one. The per-module `CLAUDE.md` files carry the detail; the short version: - `SyncProvider` sees one flat remote folder addressed by `(kind, name)`, the same shape the library has. Revisions are **opaque strings** the engine never parses, and a service's content hash stays in the provider — which is what keeps Drive's file ids and MD5s out of the engine when it arrives. - `SyncPlanner` is a pure function of (local hashes, remote listing, the index of what the last run saw) and is the part that is tested. Content decides what changed, never a clock: the platforms disagree about modification times and the web has none. An edit always beats a deletion. - A run belongs to the **app**, not to the screen that started it: `SyncRepository` is a singleton with its own scope, so a run carries on while the user moves around or leaves. Android keeps the process alive with a foreground service and iOS with a background task, both driven by `SyncNotifier`, which each app shell provides the way it provides `FilePicker`. The strings are resolved in the UI so the notification follows the language chosen *in the app*, not the system's. - `SyncEngine` runs the plan a few files at a time rather than one after another (which made a first sync one round trip per file), and retries when the service asks it to slow down — being rate limited is the expected answer to a first sync of a whole library, not a reason to give up on it. - The index carries an "a run was going" marker, written before anything moves and cleared when it finishes, so a run the app never came back from — killed, swiped away, suspended by iOS — is reported as interrupted next time rather than silently forgotten. - A file changed on both sides is never merged: the local one keeps the name and the incoming one lands next to it as ` (2)` — a name of the other device's making, numbered the way any document is, rather than with the underscore a name the app derived itself collides with (`_2`). - Authorization is OAuth 2.0 with PKCE and no client secret, which is what lets this work with no backend. The four platforms get back from the consent page in four different ways, all behind `SyncAuthenticator`. ## Updates Play's in-app updates, and only on Android: `:presentation`'s `ui/platform/AppUpdate.kt` is the contract and `ui/AppUpdateGate.kt` the UI, with the Play Core implementation in `androidMain` and a no-op actual on the other three. The gate wraps the whole app inside `CampfireApp`, so it speaks the theme and the language chosen in the app. - The **Play release's `updatePriority` is the entire policy** and it is chosen per release rather than in the code: 0–1 is left to Play's own schedule, 2–3 offers a dismissible flexible update that downloads in the background, 4–5 covers the app with a screen that cannot be dismissed until the update is there. The thresholds live in `AppUpdate.android.kt`, and `android-publish.yml` asks for the number as its `update_priority` input, defaulting to 0 — the number belongs to the release being published, not to the code being published. - Back on the blocking screen closes the app. The app it covers is still composed behind it, so the gesture has to be taken rather than allowed through, and leaving is the only thing it can honestly mean there. - The blocking screen is drawn **over** the app rather than in place of it, so a required update that turns out not to install leaves the library exactly where the user was. - Nothing of this exists outside a Play-installed build: a debug APK, a sideloaded release or a device with no Play answers every check with an error, which is why the flow can only be exercised from an internal testing track. - **iOS has no equivalent.** Apple ships no API that tells an app the store has a newer build; the only way to ask is to poll their public lookup endpoint for the published version, which would make it the second thing in the app that reaches the network. iOS updates apps on its own, so the iOS actual stays `NotAvailable`. Desktop and the web answer to no store at all, and the web build is downloaded again every time it is opened. ## Web The web build differs from the other three in where the files are. `FileStorage` has a `wasmJsMain` actual backed by the **Origin Private File System**, so the library is a real directory tree in the browser's own storage, private to the origin and invisible in the user's downloads. It is also the only build that has to be downloaded before it can start, which is what the rest of `app/web` is about — see its `CLAUDE.md`. - The library is the only copy of the user's own work, and the browser's storage for an origin is evictable until it is asked not to be, so `requestLibraryPersistence()` (in `:presentation`) asks for persistence as the app starts. Whether it is granted is the browser's business — engagement, a bookmark, an install — so the answer is reported in Settings rather than insisted on: a refusal says so there, next to the export that is the way to keep a copy elsewhere. Clearing the site's data still removes the library, as it does for anything a page stores. - The loading screen has a determinate progress bar, fed by a `fetch` wrapper that counts the bytes of the binaries against the total the build wrote into the page. It is a page and not an installable app on purpose: there is no web app manifest and no service worker, because every platform that should have an installable Campfire has a native build. - `finishWebDistribution` (in `app/web/build.gradle.kts`) finalizes `wasmJsBrowserDistribution`: it writes that total into `index.html`, and precompresses everything worth compressing. - OPFS, the file input and the download link are reached through `js(...)` blocks rather than through typed wrappers: one crossing of the Kotlin/Wasm boundary per operation is far cheaper than one per element, and several of these APIs have no binding. A Kotlin lambda cannot be passed into a `js(...)` block, so callbacks (file drops) come back as promises instead. - `settings.gradle.kts` uses `RepositoriesMode.PREFER_SETTINGS` rather than `FAIL_ON_PROJECT_REPOS` because the Kotlin/Wasm tooling adds the Node.js, Yarn and Binaryen download repositories to the root project; those are declared in settings instead.