From 2dc6d499482f489794ac2deedf901f906ef5ed4a Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:30:30 +0000 Subject: [PATCH] document the architecture, implementation plan, and agent guidance adds docs/architecture.md (stack, layers, data model, sync, search, networking, testing, CI), docs/implementation-plan.md (phases and status), and AGENTS.md for agents and contributors --- AGENTS.md | 50 +++++++++++++++++++++++ docs/architecture.md | 81 +++++++++++++++++++++++++++++++++++++ docs/implementation-plan.md | 52 ++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/architecture.md create mode 100644 docs/implementation-plan.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..020bd1c1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,50 @@ +# AGENTS.md + +Guidance for agents (and humans) working in this repository. + +## What this is + +A native Android rewrite of Subtracks, a client for Subsonic-compatible servers (Navidrome, gonic, Airsonic, ...), in Kotlin and Jetpack Compose. The `main` history still contains the earlier Flutter app; the native app is being built fresh under `app/` and ships as `com.subtracks.next` for side-by-side beta. The aim is a clean, F-Droid-friendly client: free/libre dependencies only, no telemetry. + +## Environment + +Everything comes from the Nix flake devshell; do not install toolchains by hand. + +- Enter it with `nix develop`, or let direnv load it into your shell (`.envrc` runs `use flake`). +- The devshell provides JDK 21, Gradle 9, the Android SDK (platform 37, build-tools 37.0.0, platform-tools 37.0.1), nushell, navidrome and gonic. +- Build with the devshell `gradle`, not `./gradlew`. CI uses the nix Gradle pinned by `flake.lock`; the wrapper is kept only for people without Nix. + +## Commands + +- Build: `gradle :app:assembleDebug` +- Unit tests: `gradle :app:testDebugUnitTest` +- Lint: `gradle :app:lintDebug` +- Integration tests: `./tools/integration-test.nu` (starts navidrome and gonic, then runs `:app:integrationTest`) +- Screenshots: `gradle :app:recordRoborazziDebug` to record, `gradle :app:verifyRoborazziDebug` to check + +## Testing layout + +- `app/src/test` - JVM unit tests (JUnit4, Robolectric, Roborazzi, MockWebServer). Run by `:app:testDebugUnitTest`. +- `app/src/integrationTest` - tests that need real servers. Compiled with the unit test sources but run by the dedicated `:app:integrationTest` task, and excluded from every `*UnitTest` task. The nushell harness starts the servers for you. + +## CI + +- Forgejo Actions, `.forgejo/workflows/ci.yml`, with `unit`, `lint` and `integration` jobs. +- Runs on the `nix-docker` runner (the `localhost/nix-ci` image built by the separate `nix-home` repo) inside the devshell, restoring the Nix store via `cache-nix-action` and caching Gradle and the integration test music. +- Pull requests from a fork need approval before the workflow runs. + +## Conventions + +- Prefer self-explanatory code; do not add comments unless the reason cannot be inferred from the code itself (docstrings for functions and exports are fine). +- Room 3 (`androidx.room3`) on AndroidX `sqlite-bundled` (`BundledSQLiteDriver`, SQLite 3.50+, FTS5). Do not reintroduce SQLDelight or the platform SQLite. +- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default. +- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. +- Verify current dependency versions and their compatibility before adding or pinning anything. + +## Gotchas + +- Robolectric runs at SDK 35 while `targetSdk` is 37; raising it means re-recording the Roborazzi goldens. +- `applicationId` is `com.subtracks.next` for the beta; change it to `com.subtracks` before any store release. +- The CI image provides `nix-ld`, `jq`, `sqlite`, `node`, `zstd` and a `runner` user that `cache-nix-action` expects. None of that is needed locally beyond the devshell. +- Integration servers: navidrome on 4533 (`admin`/`password`), gonic on 4747 (`admin`/`admin`). The test music is cached in `.integration/music` (gitignored). +- `nix develop` does not source your shell rc files (so tools like atuin are not active); use direnv if you want your normal interactive shell. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..15a9f3e4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,81 @@ +# Architecture + +Subtracks is a native Android client for Subsonic-compatible servers (Navidrome, gonic, Airsonic, ...). It is a Kotlin/Jetpack Compose app that keeps a local mirror of the server library in SQLite and plays from a self-hosted server. + +## Goals and constraints + +- Talk to any Subsonic-compatible server over its REST API. +- Behave well on a poor connection: a local Room mirror, paging, and (later) offline playback. +- F-Droid friendly: free/libre dependencies only, no telemetry, buildable from source without proprietary services. +- `minSdk 24`. The app bundles its own SQLite so behaviour is identical across devices and FTS5 is always available. + +## Stack + +| Concern | Choice | +|---|---| +| UI | Jetpack Compose, Material 3 | +| Navigation | Navigation Compose (planned) | +| DI | Koin, constructor injection (planned) | +| Persistence | Room 3 (`androidx.room3`) on AndroidX `sqlite-bundled` (SQLite 3.50+, FTS5) | +| Networking | OkHttp + DOM XML parsing | +| Async | Kotlin coroutines / `Flow` | +| Images | Coil 3 (planned) | +| Media | AndroidX Media3 / ExoPlayer (planned) | +| Settings | DataStore (planned) | +| Build | AGP 9.4.1, Gradle 9.7.1, Kotlin 2.3.10 (AGP built-in Kotlin), compileSdk/targetSdk 37 | + +## Source layout + +A single `app` module for now, so the data layer can settle before any module split. If the UI grows large, split `:core:data`, `:core:model` and `:feature:*`. + +``` +app/src/main/kotlin/com/subtracks + MainActivity.kt, SubtracksApp.kt + ui/ Compose screens and theme (early) + data/ + model/Models.kt Room entities, including the search_index FTS5 table + db/ SubtracksDatabase, DAOs, bundled-SQLite builder + source/ MusicSource abstraction + subsonic/ SubsonicClient, SubsonicXml, SubsonicSource + sync/SyncService.kt fetch, then one transaction of upsert + prune +``` + +## Layers + +``` +Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic) + \-> Room (local mirror, Flow) +``` + +- The remote side sits behind `MusicSource` so additional backends can be added later. +- The local side is Room; reads are `Flow`s, so the UI updates as sync writes. +- A repository layer (planned in Phase 2) joins the two: read from Room, refresh via `MusicSource` and `SyncService`. + +## Data model + +`sources` and `subsonic_sources` hold the configured servers, and `app_settings` holds playback preferences. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`. `search_index` is an FTS5 table (trigram tokenizer) over titles, maintained by sync. + +## Sync + +`SyncService.sync()` is serialized with a mutex. It fetches the whole library first (artists, albums, songs, playlists and their entries), then writes in a single `immediateTransaction`: upserts, deletes rows no longer present on the server (diffed against the DB), rebuilds `playlist_songs`, and rebuilds the FTS index for that source. Because fetching happens outside the transaction, a network failure never leaves a half-applied state. + +## Search + +`search_index` is FTS5 with the trigram tokenizer, so `MATCH` supports substring queries. Queries shorter than three characters are rejected by `SearchDao` (below the trigram minimum). Rows are keyed by `(sourceId, type, itemId)`; the DAO scopes results to a source and orders by rank. + +## Networking and auth + +`SubsonicClient` speaks the Subsonic REST API over OkHttp. Auth is the token scheme (`t = md5(password + salt)`, with a fresh salt per request); plaintext `p=` is supported as a fallback for old servers. Responses are parsed with a hardened `DocumentBuilderFactory` (no DTDs, no external entities), and the client rejects any document that is not a ``, so a captive portal or login page can never be mistaken for an empty library and wipe the local mirror. + +## Security and privacy + +No analytics or third-party telemetry. Credentials are stored locally in app data and are only ever sent to the configured server. Cleartext HTTP is permitted because self-hosted servers often run on a LAN; users can still configure HTTPS. + +## Testing + +- Unit tests use Robolectric, Roborazzi (screenshot goldens) and MockWebServer (client), plus an in-memory Room database on the bundled SQLite JVM driver (Robolectric's SQLite has no FTS5). +- Integration tests run `SubsonicSourceIntegrationTest` against real navidrome and gonic instances, driven by `tools/integration-test.nu`. + +## Build and CI + +The Nix flake devshell provides the whole toolchain, and `direnv` loads it into the working shell. CI (`.forgejo/workflows/ci.yml`) runs `unit`, `lint` and `integration` on a containerized Nix runner, restoring the Nix store from the Actions cache and caching Gradle and the integration test music. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 00000000..abb3fc6a --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,52 @@ +# Implementation plan + +The rewrite is delivered in vertical slices. Each phase leaves the app in a working, testable state. + +## Phase 0 - Foundation (done) + +Nix devshell (JDK, Gradle, Android SDK, nushell, navidrome, gonic), Gradle Kotlin DSL at the repo root, a Compose skeleton, Roborazzi/Robolectric screenshot testing, and the carried-over launcher and notification assets. The app ships as `com.subtracks.next`. + +## Phase 1 - Data foundation (done) + +Everything needed to mirror a server library locally: + +- `MusicSource` abstraction with `SubsonicClient`, `SubsonicXml` and `SubsonicSource`. +- Room 3 schema on bundled SQLite, so the SQLite version and FTS5 are identical on every device. +- `SyncService`: fetch first, then a single transaction of upserts with diff-based pruning. +- FTS5 trigram search index over titles. +- Unit tests (client, XML, sync, search), native integration tests against navidrome and gonic, and CI (`unit`, `lint`, `integration`) with warm caches. + +## Phase 2 - UI vertical slice (next) + +Goal: a usable app for browsing a server, end to end. + +- DI wiring (Koin) and DataStore-backed settings. +- App shell: root navigation and a Material 3 theme with cover-art tonal colour extraction. +- Source setup: add/edit sources (URL and credentials, token auth), ping/test, trigger a sync, show progress. +- Library browsing: artists, albums and songs with Paging 3 and Coil 3 cover art, reading from Room. +- Roborazzi screenshot tests and goldens for the new screens. + +## Phase 3 - Playback + +- Media3/ExoPlayer playback service, queue and now-playing UI. +- Streaming with `maxBitRate`/format taken from settings, a gapless queue, media notifications and headset controls. +- Scrobbling and "now playing" back to the server. +- Optional download/caching. + +## Phase 4 - Search, playlists, offline + +- Search UI over the FTS5 index (substring, ranked). +- Playlist browsing and editing (create/add/remove), syncing changes back to the server. +- Offline mode and downloads. + +## Phase 5 - Release + +- Change `applicationId` from `com.subtracks.next` to `com.subtracks`. +- Signing, versioning and Fastlane metadata. +- F-Droid submission. Their buildserver must have Gradle >= 9.6 and SDK 37, which this project targets; do not rely on the Gradle wrapper there (fdroidserver deletes `gradlew` and uses its own Gradle). + +## Deferred decisions + +- Whether to drop the plaintext-auth fallback or keep it behind an "insecure server" toggle. +- Robolectric runs at SDK 35 while `targetSdk` is 37; aligning them means re-recording the Roborazzi goldens. +- Whether to extract `:core:data` and friends once the UI has grown.