A full page at the cap is not proof of truncation, so a library whose size is an exact multiple of the page size failed for no reason. Each paged loop now keeps going one page past the cap and only throws when that page comes back non-empty; a library up to the cap syncs normally. Add a boundary test for the exact-cap case. Also note the accepted per-batch observer invalidation churn in the sync code and the architecture doc.
16 KiB
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 does not depend on the platform version.
Stack
| Concern | Choice |
|---|---|
| UI | Jetpack Compose, Material 3 |
| Navigation | Navigation Compose |
| DI | Koin, constructor injection |
| Persistence | Room 3 (androidx.room3) on AndroidX sqlite-bundled (SQLite 3.50+) |
| Lists | Paging 3 over Room PagingSource (local mirror, not remote paging) |
| Networking | OkHttp + streaming SAX XML parsing |
| Async | Kotlin coroutines / Flow |
| Images | Coil 3 |
| Media | AndroidX Media3 (media3-exoplayer, media3-session) |
| Settings | DataStore Preferences (UI and playback prefs) |
| Build | AGP 9.4.1, Gradle 9.7.1, Kotlin 2.3.10 (AGP built-in Kotlin), compileSdk/targetSdk 37 |
Versions live in gradle/libs.versions.toml; check current releases before bumping (Koin 4.2.2, Navigation Compose 2.10.1, Paging 3.5.1, DataStore 1.2.1, Coil 3.6.3, Media3 1.11.1 at the time of writing).
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 Composition root: starts Koin and Coil
di/AppModule.kt Koin module
ui/
AppRoot.kt Root gate, NavHost and the mini player
library/ Tabbed LibraryScreen, album/playlist detail
playback/ Mini player and Now Playing screen
settings/ Server list and add-server form
components/ Cover art and empty/loading states
theme/ Material 3 theme (stark black/white)
playback/
PlaybackService.kt MediaSessionService hosting ExoPlayer
PlaybackController.kt MediaController wrapper: state, queue, transport
data/
model/Models.kt Room entities
db/ SubtracksDatabase, DAOs, bundled-SQLite builder
prefs/UserPreferences.kt DataStore-backed UI prefs
repo/ SourceRepository, LibraryRepository
source/ MusicSource abstraction
subsonic/ SubsonicClient, SubsonicXml, SubsonicSource
sync/ SyncService (stream + per-batch writes + prune), SyncManager (status)
Layers
Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic)
\-> Room (local mirror, Flow / PagingSource)
- The remote side sits behind
MusicSourceso additional backends can be added later. SourceRepositoryowns the configured servers and builds aSubsonicSourcefrom the active one; it also serves cover-art URLs.LibraryRepositoryreads the local mirror.- The local side is Room; reads are
Flows, so the UI updates as sync writes. SyncManagertriggers syncs off the UI (its own scope) and exposes aStateFlow<SyncStatus>;SyncServicedoes the actual work.- ViewModels are wired with Koin; screens are split into a stateful
*Route(defaultkoinViewModel()) and a stateless*Screenso the latter can be screenshot-tested directly.
UI shell
The library is one LibraryScreen with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Songs, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material TabRow (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
The Albums tab is a three-column grid of covers only — a later preference will toggle captions, and sorting will hang off a FAB, so Settings only manages servers and neither shows sort nor sync controls.
The theme is monochrome — black surface, white content and accent — with no colour extracted from cover art (that was a Flutter feature and is not ported yet).
Playback
Audio plays through an ExoPlayer owned by PlaybackService (a MediaSessionService), so the system media notification, headset/Bluetooth controls and background playback come from the framework. The UI never touches the player directly: PlaybackController is an app-scoped singleton that connects a MediaController to the session, exposes a StateFlow<PlaybackState> (current item, playing flag, position, has-next/previous) and the transport calls. It reaches the session through a small PlayerConnection/PlayerHandle seam rather than Media3 types, so the queue and window logic is covered by JVM unit tests with a fake player. It connects lazily on first observation or play, and restores the persisted queue (paused) on launch.
The queue lives in SQL, not memory. queue_entries is an ordered list of references — a whole playlist, an album, or a single song — each with an optional inclusive ordinal range (rangeStart/rangeEnd; both null means the whole referenced list). A queue can therefore be "album X tracks 1-5, song Y, then album X tracks 6-10" without copying a row per track, and inserting, moving or removing an entry is a single row. A one-row playback_cursor table holds the current position; both are written as playback advances.
QueueRepository resolves a queue position to a song lazily: it walks the entries summing their lengths (a COUNT(*) per entry) and then fetches one row with LIMIT 1 OFFSET. PlaybackController keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently 2 * 25 + 1 items) centred on the cursor. A player index maps back to a queue position as windowStart + index. The window is loaded once with setMediaItems and then shifted incrementally with addMediaItems/removeMediaItem as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. hasNext/hasPrevious come from the SQL queue size, not the player's playlist. The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
Because the resolver reports which entry a position falls in, PlaybackState also carries the current queue context (kind and refId). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
Stream URLs come from SourceRepository.streamUri, which asks the active SubsonicSource for a freshly salted stream URL; because the URL changes every request, artwork is attached to the media item by identity (the CoverArtRef cache key) rather than by URL.
The window is also the queue the media notification and Android Auto can browse — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses sh.calvin.reorderable, which floats the lifted row and reorders the loaded list live (animating with animateItem), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. queue_entries is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with removeMediaItem/moveMediaItem so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. Timed lyrics/gapless format preferences, scrobbling, downloads and adding to the queue from the library are not wired yet.
Data model
sources and subsonic_sources hold the configured servers. Library tables are keyed by (sourceId, id) and cascade from sources: artists, albums, playlists, playlist_songs, songs.
UI and playback preferences live in DataStore (user_prefs), not in SQLite: they are not relational, nothing joins against them, and keeping them out of Room avoids a schema migration per preference. Room is for the library only.
Every schema version step ships a hand-written Migration (data/db/Migrations.kt), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under app/schemas/ are the source of truth for the migration SQL, and MigrationsTest runs each step against a real SQLite connection and asserts the resulting tables, columns and indices.
Library screens page over Room with PagingSource<Int, T> queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Paging is local, but it keeps memory bounded on large libraries and lets a huge song or playlist list render incrementally. Room 3 requires @DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class) on the DAO for this.
Song rows show the album's cover art, fetched by joining songs to albums into a SongListItem rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle.
Images: grids and lists request a 256 px getCoverArt thumbnail; the 120 dp detail headers request the original. Because every request is freshly salted, the URL changes constantly, so each image is wrapped in a CoverArtRef carrying a memoryCacheKey/diskCacheKey of sourceId:coverArtId:thumbnail — caching is keyed on the identity of the art, not the request URL, and so survives the salt and app restarts.
Sync
SyncService.sync() is serialized by a mutex in SourceRepository, so overlapping sync requests cannot interleave. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold Flow from MusicSource that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one immediateTransaction per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so neither the raw XML nor the whole library ever sits in memory at once.
Songs have a primary and a fallback fetch. The primary probes search3 with an empty query once (memoized per source); if the server supports it, the library is paged through search3 500 songs at a time. Otherwise the fallback pages getAlbumList2 and then issues one getAlbum per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per (album, disc) for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. The one hazard is a scan that stops early — a server returning a short page by mistake, or a library larger than the page cap — because the prune trusts that the stream actually reached the end. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on SyncStatus.Running if that is ever noticeable.
Search
Search is a case-insensitive substring filter on the library queries (instr(lower(column), lower(:search)) > 0 in LibraryDao), scoped to the active tab and with the matching rows ordered by the tab's sort. There is no separate search index; the filter runs directly against the library tables.
Networking and auth
SubsonicClient speaks the Subsonic REST API over OkHttp. Auth is the token scheme (t = md5(password + salt)), salted freshly on every request including media URLs; plaintext p= is supported as a fallback for old servers. Responses are parsed as a stream with SAX (SAXParserFactory, hardened best-effort — Android's parser rejects some flags, so unsupported ones are ignored), so entities are mapped as elements close rather than through a DOM. The client rejects any document that is not a <subsonic-response> and classifies malformed or truncated bodies as a SubsonicException, 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 (screenshots) and MockWebServer (client), plus an in-memory Room database on the bundled SQLite JVM driver (Robolectric's SQLite differs from the bundled one).
- Roborazzi screenshots exercise the stateless
*Screencomposables with fake data; paging screens are fedPagingData.from(...). They are rendered on demand withgradle :app:recordRoborazziDebug(into the gitignoredapp/src/test/screenshots/) for local review; no images are committed and nothing verifies them in CI. - Cover art in the screenshots is generated deterministically in the test and served through Coil's
FakeImageLoaderEngine(synchronous, and no third-party images are committed); a realImageLoaderis installed for the run and reset afterwards. Each image is two or three flat colours in a pattern that identifies its type — stripes for albums, dots for playlists, rings for artists. - Integration tests run
SubsonicSourceIntegrationTestagainst real navidrome and gonic instances, driven bytools/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.