Files
subtracks/AGENTS.md
T
austinried 592c522244 tidy the integration harness and its comments
Trim the explanatory comments to short quirk notes, drop the module headers, and fail cleanly: navidrome/gonic now kill their process if they never become ready and the entrypoint always tears down. The prune test waits for the removal to appear in the album list rather than for the scan flag. Correct the AGENTS token-auth note (lms answers 42, Nextcloud 41) and give CI more headroom.
2026-10-04 00:30:05 +00:00

6.9 KiB

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 default devshell provides JDK 21, Gradle 9, the Android SDK (platform 37, build-tools 37.0.0, platform-tools 37.0.1) and nushell.
  • The integration harness needs real servers, so it lives in a separate nix develop .#integration shell (navidrome, gonic, lms, php, Nextcloud with the Music app, sqlite). Keeping them out of the default shell avoids pulling that closure into everyday builds.
  • 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
  • Release build: gradle :app:assembleRelease only when build config, R8/ProGuard or dependencies change, and once before a merge; not part of routine verification
  • Unit tests: gradle :app:testDebugUnitTest
  • Lint: gradle :app:ktlintCheck :app:lintDebug
  • Format: gradle :app:ktlintFormat
  • Integration tests: nix develop .#integration --command nu tools/integration-test.nu (starts navidrome, gonic, lms and a Nextcloud Music instance, then runs :app:integrationTest)
  • Screenshots: gradle :app:recordRoborazziDebug renders the *Screen composables to app/src/test/screenshots/ (gitignored) plus an HTML report in app/build/reports/roborazzi/, for local review. No golden images are committed and verifyRoborazziDebug is not part of CI.

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.
  • A test earns its place only if it fails when the behaviour it covers breaks, and passes when the behaviour works. A test that passes either way is worse than no test: it looks like coverage while hiding the gap. So when you add one, break the behaviour (or run the test against the pre-fix code), watch it fail, then restore the fix and watch it pass.

CI

  • Forgejo Actions, .forgejo/workflows/ci.yml, a single ci job that runs the lint, unit test and integration test steps (one cache restore/save and Gradle warm-up instead of three).
  • 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).
  • ktlint (org.jlleitschuh.gradle.ktlint) enforces style and flags unused imports; run gradle :app:ktlintFormat before committing. .editorconfig exempts @Composable functions from the lowerCamelCase function-naming rule.
  • Room 3 (androidx.room3) on AndroidX sqlite-bundled (BundledSQLiteDriver, SQLite 3.50+). 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. Large lists page with Paging 3 over Room PagingSource rather than loading the whole table.
  • The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
  • Playback is Media3: PlaybackService is a MediaSessionService owning an ExoPlayer, and PlaybackController (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a queue_entries list of references with optional ranges plus a playback_cursor row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by CoverArtRef cache key, not by URL.
  • UI state lives in androidx.lifecycle.ViewModel, wired with Koin (koinViewModel()); screens split into a stateful *Route and a stateless *Screen for screenshot tests.
  • UI and playback preferences go in DataStore (UserPreferences), not 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 changes the on-demand Roborazzi renders (there are no committed goldens to re-record).
  • Room 3 does not map PagingSource automatically: a DAO that returns it needs @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class).
  • Material icons are not pulled in by material3; use the BOM-managed material-icons-core/material-icons-extended (frozen at 1.7.8). Extended is large in debug builds but R8 strips unused icons from release.
  • Robolectric creates a fresh Application per test in one JVM, so SubtracksApp.onCreate stops any running Koin before startKoin.
  • 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), lms on 5082 (admin/subtracks-lms), Nextcloud Music on 8090 (admin/subtracks-nextcloud, base URL http://localhost:8090/index.php/apps/music/subsonic/). The test music is cached in .integration/music (gitignored).
  • lms and Nextcloud Music authenticate the Subsonic API with per-user API keys instead of passwords. The harness seeds a key and most tests use useTokenAuth = false; SubsonicAuthIntegrationTest drives useTokenAuth = true and asserts the fallback to plaintext (lms answers code 42, Nextcloud 41).
  • The integration shell takes its server packages from a pinned stable nixpkgs (nixpkgs-stable), because lms 3.80 in nixpkgs-unstable segfaults at startup with wt 4.14.3.
  • 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.