mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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
This commit is contained in:
@@ -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.
|
||||
@@ -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 `<subsonic-response>`, 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user