Compare commits

..
50 Commits
Author SHA1 Message Date
bvandeusen 0192c0744c Merge pull request 'Main builds and bundles its own signed APK, so a merge ships the app without a release (#6465)' (#157) from dev into main
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m33s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m24s
release / integration (push) Successful in 5m29s
release / Build signed APK (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 38s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-26 13:22:20 -04:00
bvandeusenandClaude Opus 5.5 be642cd53f ci: main builds and bundles its own signed APK, so a merge ships the app without a release (#6465)
release / web (push) Successful in 1m51s
release / go (push) Successful in 2m6s
release / govulncheck (push) Successful in 20s
release / integration (push) Successful in 6m53s
release / android (push) Successful in 5m41s
release / Build signed APK (push) Successful in 5m38s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 18s
release / Verify release artifacts (tag releases only) (push) Skipped
android-release now runs on main as well as dev and tags, and image-release
requires its success on every ref. :latest carries the stable APK built from
main's own commit instead of the newest release's, so a merged app change
reaches stable phones on the next pull. Releases stay as optional bookmarks
that attach the APK for download.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:49:39 -04:00
bvandeusen 7529266f5b Merge pull request 'DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542)' (#156) from dev into main
release / web (push) Successful in 1m29s
release / go (push) Successful in 1m49s
release / govulncheck (push) Successful in 26s
release / android (push) Successful in 6m19s
release / integration (push) Successful in 6m47s
release / Build signed APK (releases and dev) (push) Successful in 6m17s
release / Attach APK to the Release (tag releases only) (push) Successful in 3s
release / Build + push container image (push) Successful in 23s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-10-26 12:32:32 -04:00
bvandeusenandClaude Opus 5.5 7d5dcd454e docs: two comments state the rule rather than its history (#6277)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m47s
release / go (push) Successful in 2m2s
release / android (push) Successful in 5m49s
release / Build signed APK (releases and dev) (push) Successful in 6m1s
release / integration (push) Successful in 6m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 18s
release / Verify release artifacts (tag releases only) (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 11:20:17 -04:00
bvandeusenandClaude Opus 5.5 3fa5d9fc3a fix: an app update is offered only when the app changed; casting no longer sticks on "loading" when the same queue plays again (#6455, #6456)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m30s
release / go (push) Successful in 1m46s
release / android (push) Successful in 5m2s
release / Build signed APK (releases and dev) (push) Successful in 5m13s
release / integration (push) Successful in 5m22s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
ci/version.sh --apk names the APK from android/ alone, and the APK lane
calls it. The app offers an update when the server's APK name differs from
its own, so a name derived from the whole repo offered every phone a rebuild
of the app it already had on each server-only deploy. The image keeps the
union set: it bundles the APK under client/.

Casting (#6456):
- setMediaItems with the tracks already queued (play the queued album from
  another track) cleared the cast and waited for a resync that the id-keyed
  collector never ran. It is now a move within the queue: Seek TRACK_NR on a
  Sonos, start that track on a one-track renderer; the play() goes through.
- A queue replaced through setMediaItems could take the Sonos incremental
  diff, which cleared target without restoring active, so the app thought it
  was back on the phone while the speaker played on, and the chosen start
  track was ignored. The diff now runs only while the route is still active;
  a replaced queue reloads in full at its start index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:08:02 -04:00
bvandeusenandClaude Opus 5.5 fa75bd08d7 feat: a newer app is told in a snackbar on Home and installed from a sheet holding Settings' card, fetched ahead on Wi-Fi (#6452)
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m41s
release / go (push) Successful in 1m55s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m31s
release / integration (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
Mirrors Librarian (#6446, #6448), after Gallery's update tray (canon #6367).

- Home shows "Version X of the app is on your server." / "…is downloaded
  and ready." with Update / Install; the button opens a bottom sheet
  holding the same About card Settings shows, so there is one install
  path. Each build is told at most once a day, kept on disk.
- The shell-wide update banner and its in-memory dismissals are gone.
- AppUpdates (singleton) holds the offer and the install, so Home and
  Settings show the same state. Checks run when either screen opens, on
  every return to the front, every 5 minutes while shown, and on Home's
  pull to refresh.
- UpdateFetchWorker downloads the newer APK ahead on an unmetered network
  (battery and storage not low, three tries); the download writes a .part
  file and keeps it under the build's name only once its size and sha256
  match, so Install is usually one tap.
- Back from allowing installs, the update the listener asked for carries
  on (kept in SavedStateHandle) instead of asking them to tap again.
- An offer needs a higher code AND a different name: the same app built
  again at a later minute is not offered.
- Server: /api/client/version reports the APK's sha256, worked out from
  the served file and cached by size and mtime.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:46:01 -04:00
bvandeusenandClaude Opus 5.5 de41169ccb fix(android): detekt — no four-way destructuring, single-track loader within the function cap (#6451)
The library-duration fallback rides on the poll outcome instead of being its
own member, and the adopt path reads the target's fields by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:46:01 -04:00
bvandeusenandClaude Opus 5.5 b1e55adc14 feat(android): any UPnP/DLNA renderer plays, not only a Sonos (#6451)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m33s
release / android (push) Failing after 1m44s
release / go (push) Successful in 1m48s
release / Build signed APK (releases and dev) (push) Successful in 4m54s
release / integration (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Discovery already listed every MediaRenderer on the LAN, but playback only
spoke the Sonos native queue (AddURIToQueue, x-rincon-queue, Seek TRACK_NR,
Next/Previous), which a generic renderer faults on, so picking one fell
straight back to the phone.

A renderer that isn't a Sonos is now fed one track at a time by
SingleTrackLoader: SetAVTransportURI + Play for the current track, and
SetNextAVTransportURI for the one after it so the renderer moves on by
itself without a gap. A renderer without SetNext is advanced by the app
when the poll sees it stop (or report NO_MEDIA_PRESENT) at the end of a
track; a stop partway is still the stall watchdog's.

ActiveUpnp carries the queue mode, picked from the route at select. Skip,
previous and queue taps point a one-track renderer at the cursor's track,
the poll takes the queue position from the loader instead of the
renderer's Track=1, queue changes keep the playing track and re-arm the
next, and adopting a running session matches by track id. Sonos keeps its
native queue unchanged.

DIDL-Lite drops the Rincon metadata for non-Sonos renderers and declares
DLNA.ORG_OP=01 so renderers that need it will seek.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 07:38:29 -04:00
bvandeusenandClaude Opus 5.5 5dc6aa9c2f feat(android): Storage shows the Liked songs and Recently played budgets (#6309)
release / web (push) Successful in 1m46s
release / govulncheck (push) Successful in 18s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m39s
release / Build signed APK (releases and dev) (push) Successful in 5m53s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 13s
release / Verify release artifacts (tag releases only) (push) Skipped
Two cap selectors, each with its usage, and a "Keep liked songs
offline" switch replace the single cache limit and its next-launch
caveat; caps apply at once.

Docs that said the liked bucket did not exist are restated, and the
audio-cache index's unused per-source eviction queries are deleted:
eviction follows the liked set, not the index's source column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:23:49 -04:00
bvandeusenandClaude Opus 5.5 ec1e59921b feat(android): liking a track downloads its audio into the Liked budget on Wi-Fi (#6308)
LikedAudioPinner downloads liked tracks' whole audio when "Keep liked
songs offline" is on: most recently liked first, one at a time, only
on an unmetered network while the server is reachable, and only while
the Liked budget has room for another track at CD bitrate, so it never
pushes out liked audio already on the phone. Tracks liked before this
ships are covered by the same pass. Any change (a like, an unlike, the
switch, the network) restarts the pass and cancels a download in flight.

Supporting changes:
- ConnectivityObserver.unmetered: the default network's NOT_METERED bit.
- Liked track ids are read newest first, and refreshIds stamps the
  server's newest-first order into likedAt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:23:49 -04:00
bvandeusenandClaude Opus 5.5 7ec8a82871 feat(android): the audio cache keeps a Liked songs budget and a Recently played budget (#6307)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
SimpleCache ran one LRU evictor at the rolling cap, so liked audio was
dropped like anything else and the offline Liked pool shrank as other
music played. TwoBucketCacheEvictor replaces it:

- a span counts against Liked when its track is liked, else Recently
  played; a liked track that is also played is charged to Liked only;
- each budget has its own cap (0 = unlimited) and drops its own
  least-recently-used audio first;
- CacheBudgetSync feeds it the signed-in user's liked ids and the Storage
  caps, so a like, an unlike or a cap change applies at once;
- nothing is evicted until the liked set is known, so startup never trims
  liked audio as Recently played.

The decisions live in a Media3-free BucketLedger with unit tests.
CacheConfig and PlayerModule, which only snapshotted the caps at launch,
are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:16:36 -04:00
bvandeusenandClaude Opus 5.5 b61474744b chore(android): delete the unread deprecated FabledSwordTokens alias (#6295)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 5m14s
release / android (push) Successful in 5m20s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
UI code reads LocalFabledSwordTheme.current; nothing referenced the
dark-bound alias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:23:55 -04:00
bvandeusenandClaude Opus 5.5 1aba1a41e6 fix(tracks): removing a track writes a track_remove audit row (#6294)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m13s
release / android (push) Successful in 5m19s
release / Build signed APK (releases and dev) (push) Successful in 5m32s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
RemoveTrack took the admin's id and dropped it, so a merge left an
audit trail and the more destructive removal did not. It now reads the
track's title, artist, album and file path before the delete and writes
them, with whether the album and artist went too, under the new
track_remove action.

The audit action groups lose their plan-era U1/U2/U3 headings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:16:41 -04:00
bvandeusenandClaude Opus 5.5 0d0b04bff0 test(subsonic): album-list fixture users carry distinct api token hashes (#6293)
release / govulncheck (push) Successful in 18s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m45s
release / integration (push) Successful in 5m23s
release / android (push) Successful in 5m35s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token_hash is unique, so the second seeded user needs its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 14:05:45 -04:00
bvandeusenandClaude Opus 5.5 f3b124f9b2 feat(subsonic): getAlbumList2 recent and frequent read the caller's play history (#6293)
release / govulncheck (push) Successful in 20s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m45s
release / integration (push) Failing after 5m27s
release / android (push) Successful in 5m40s
release / Build signed APK (releases and dev) (push) Successful in 5m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
type=recent orders the caller's albums by their latest play (skips
included); type=frequent orders them by completed plays (skips
excluded). Plays of tracks whose file is gone do not count, and both
page by size/offset like the other types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:58:09 -04:00
bvandeusenandClaude Opus 5.5 09e78962b2 fix(subsonic): getUser answers an admin's lookup with the requested user (#6292)
An admin asking for another username got their own name and roles back.
The handler now looks the user up by exact username and answers with
that user's payload; an unknown username is ErrDataNotFound. A
non-admin asking about someone else is still ErrNotAuthorized.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:58:09 -04:00
bvandeusen 165c87047e Merge pull request 'Duplicates and video rips resolve themselves (M498)' (#155) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m20s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 4m15s
release / android (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 28s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 22:30:10 -04:00
bvandeusen 05bc85f2b8 Merge dev: scroll-loaded lists, Liked tabs, missing files kept out of the queue (#5416, #5417, #5418)
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m4s
release / go (push) Successful in 1m21s
release / integration (push) Successful in 3m56s
release / android (push) Successful in 4m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 20:08:04 -04:00
bvandeusen d1fe3758d5 Merge dev: Suspect sources report (#5410)
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m13s
release / go (push) Successful in 1m28s
release / govulncheck (push) Successful in 14s
release / integration (push) Successful in 4m46s
release / android (push) Successful in 5m29s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 16:03:53 -04:00
bvandeusen 664b129947 Merge pull request 'One track-list shape across the web app, drag reorder that snaps (M494)' (#152) from dev into main
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m36s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 27s
release / integration (push) Successful in 4m20s
release / android (push) Successful in 4m45s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m14s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 15:08:09 -04:00
bvandeusen c9fa18ecb7 Merge pull request 'Notifications inbox (M489), Discover taste-arm fix, web tooling majors' (#151) from dev into main
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m8s
release / Build signed APK (releases and dev) (push) Skipped
release / go (push) Successful in 1m27s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m34s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 11:11:08 -04:00
bvandeusen 3077ed75fe Merge pull request 'Readable accent text app-wide; Android queue drag math under test' (#144) from dev into main
release / govulncheck (push) Successful in 18s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m16s
release / go (push) Successful in 1m30s
release / integration (push) Successful in 4m2s
release / android (push) Successful in 4m20s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-08 06:29:48 -04:00
bvandeusen 97e84a79eb Merge pull request 'Multi-value genres for FLAC/Ogg/MP4, readable tinted and error text, no missing-track seeds' (#143) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m41s
release / android (push) Successful in 4m44s
release / integration (push) Successful in 17m13s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m20s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 23:53:09 -04:00
bvandeusen a29ceaaeab Merge pull request 'Similarity cache and round-robin artist arms (#5296, #5297)' (#142) from dev into main
release / android (push) Successful in 5m1s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / go (push) Successful in 2m28s
release / web (push) Successful in 57s
release / govulncheck (push) Successful in 19s
release / Build signed APK (releases and dev) (push) Skipped
release / integration (push) Successful in 5m9s
2026-10-07 20:02:59 -04:00
bvandeusen 54b2720d76 Merge pull request 'Fold stale missing tracks into their on-disk replacement (M485)' (#141) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m2s
release / go (push) Successful in 1m20s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
release / integration (push) Successful in 3m51s
release / android (push) Successful in 4m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
2026-10-07 18:17:51 -04:00
bvandeusen fe142e3451 Merge dev: request completion fix, x/text bump, full-logo tab icon (#140)
release / govulncheck (push) Successful in 22s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m54s
release / go (push) Successful in 2m22s
release / integration (push) Successful in 5m7s
release / android (push) Successful in 5m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 17s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 15:35:01 -04:00
bvandeusen 3bdfa94f39 Merge dev: M483 release-group ids for Lidarr (#139)
release / integration (push) Successful in 3m46s
release / android (push) Successful in 4m24s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m1s
release / go (push) Successful in 1m21s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 12:29:12 -04:00
bvandeusen 22ea27efff Merge pull request 'Lidarr: album adds that Lidarr accepts, artist monitoring that sticks' (#138) from dev into main
release / govulncheck (push) Successful in 27s
release / Build signed APK (releases and dev) (push) Skipped
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m48s
release / integration (push) Successful in 4m24s
release / android (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
2026-10-07 11:08:18 -04:00
bvandeusen 09bd71629a Merge pull request 'M401: AcoustID recording-id lookup' (#137) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m6s
release / go (push) Successful in 1m24s
release / integration (push) Successful in 4m3s
release / android (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m26s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-07 09:50:11 -04:00
bvandeusen c83670d216 Merge pull request 'M464 loudness leveling (steps 4–8), APK distribution and security baseline adoptions' (#136) from dev into main
release / web (push) Successful in 1m41s
release / go (push) Successful in 2m0s
release / Build signed APK (releases and dev) (push) Skipped
release / govulncheck (push) Successful in 21s
release / integration (push) Successful in 6m6s
release / android (push) Successful in 7m23s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 15s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-06 23:11:35 -04:00
bvandeusen 1e9aed214b Merge pull request 'M462 security hardening and M464 loudness analysis (steps 1–3)' (#135) from dev into main
release / go (push) Successful in 2m19s
release / web (push) Successful in 1m43s
release / govulncheck (push) Successful in 35s
release / Build signed APK (releases and dev) (push) Skipped
release / android (push) Successful in 6m10s
release / integration (push) Successful in 19m35s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 2m24s
release / Verify release artifacts (tag releases only) (push) Skipped
2026-10-06 14:19:58 -04:00
bvandeusen 3638d1d822 Merge pull request 'M400: acoustic duplicate detection, history-preserving merge, and fingerprinting settings' (#134) from dev into main
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 1m32s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m46s
test-go / integration (push) Successful in 4m19s
test-web / test (push) Successful in 33s
2026-09-11 20:56:55 -04:00
bvandeusen 18618bd135 Backfill fingerprints for the existing library (M400 #3908) (#133)
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m11s
test-go / integration (push) Successful in 3m16s
2026-09-11 15:34:19 -04:00
bvandeusen d2985f3841 Acoustic fingerprints at ingest, a delete that can't lose history, and a reproducible candidate draw (#132)
release / Build signed APK (releases and dev) (push) Skipped
release / Build + push container image (push) Successful in 20s
release / Verify release artifacts (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m0s
test-go / test (push) Successful in 1m20s
test-go / integration (push) Successful in 3m35s
android / Build + lint + test (push) Successful in 4m48s
2026-09-11 14:43:00 -04:00
bvandeusen f70df9f827 Recommendation relevance, the rollback unit, and a version that names what shipped (#131)
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m33s
test-go / integration (push) Successful in 4m25s
android / Build + lint + test (push) Successful in 5m33s
release / Build signed APK (releases and dev) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 23:37:57 -04:00
bvandeusen 439c8625d5 Fix the rebundle step: a non-matching grep must not kill it (#130)
test-go / test (push) Successful in 59s
test-go / integration (push) Successful in 3m1s
release / Build signed APK (releases and dev) (push) Successful in 4m41s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-10 10:49:40 -04:00
bvandeusen 1d67c160b2 Versioning rework, a dev channel, and the miniplayer gap (#129)
release / Build signed APK (releases and dev) (push) Skipped
android / Build + lint + test (push) Failing after 4s
release / Build + push container image (push) Failing after 5s
release / Verify release artifacts (tag releases only) (push) Skipped
test-go / test (push) Successful in 1m24s
test-go / integration (push) Successful in 6m25s
2026-09-10 08:33:26 -04:00
bvandeusen 237380b122 New brand mark, and both clients stop fetching their fonts at runtime (#128)
test-web / test (push) Successful in 54s
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m36s
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-09-09 14:36:15 -04:00
bvandeusen 4f077736b6 Merge PR #127: Sonos queue verification, cast double-download fix, stutter instrumentation
android / Build + lint + test (push) Successful in 4m16s
release / Build signed APK (tag releases only) (push) Successful in 5m25s
release / Build + push container image (push) Successful in 1m16s
release / Verify release artifacts (tag releases only) (push) Successful in 1s
2026-08-18 10:50:10 -04:00
bvandeusen 727f68950e Merge PR #126: missing-file lifecycle, UPnP stall recovery, Android browse parity, Flutter client removed
release / Build signed APK (tag releases only) (push) Skipped
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m26s
release / Build + push container image (push) Successful in 1m38s
release / Verify release artifacts (tag releases only) (push) Skipped
android / Build + lint + test (push) Successful in 4m54s
test-go / integration (push) Successful in 5m19s
2026-08-17 16:28:13 -04:00
bvandeusen aa9f534f3c Merge pull request 'Genre index: sort A–Z, and repair casing damage at scan time' (#124) from dev into main
test-web / test (push) Successful in 49s
test-go / test (push) Successful in 1m9s
test-go / integration (push) Successful in 5m1s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 21:40:33 -04:00
bvandeusen 011b4d9a9c Merge pull request 'ci(release): verify a tag release actually shipped its artifacts' (#123) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m38s
release / Build + push container image (push) Successful in 1m31s
release / Verify release artifacts (tag releases only) (push) Successful in 2s
2026-08-07 08:32:48 -04:00
bvandeusen d5aa081157 Merge pull request 'Recommendation metrics: publish the margin of error on every delta' (#122) from dev into main
test-web / test (push) Successful in 52s
test-go / test (push) Successful in 1m10s
test-go / integration (push) Successful in 4m53s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
release / Build + push container image (push) Successful in 1m37s
2026-08-06 21:50:41 -04:00
bvandeusen a99f855e98 Merge pull request 'Missing files: detect them, stop offering them, and follow them when they move' (#121) from dev into main
test-go / test (push) Successful in 56s
test-go / integration (push) Successful in 5m0s
release / Build signed APK (tag releases only) (push) Successful in 4m14s
release / Build + push container image (push) Successful in 15s
2026-08-06 20:40:39 -04:00
bvandeusen 7e4727fc49 Merge pull request 'Genre tags: read multi-value frames correctly, and repair existing rows' (#120) from dev into main
test-go / test (push) Successful in 57s
test-go / integration (push) Successful in 4m57s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 1m39s
2026-08-05 22:10:41 -04:00
bvandeusen 1b7fa635d8 Merge pull request 'Silent self-update, active sessions with real client IPs, genre/year browsing, handoff fix' (#119) from dev into main
test-web / test (push) Successful in 1m3s
test-go / test (push) Successful in 1m13s
test-go / integration (push) Successful in 5m29s
android / Build + lint + test (push) Successful in 5m34s
release / Build signed APK (tag releases only) (push) Successful in 5m5s
release / Build + push container image (push) Successful in 16s
2026-08-05 15:14:48 -04:00
bvandeusen 57d2299180 Merge pull request 'Queue row gestures: album art as grab surface + swipe-to-remove' (#118) from dev into main
test-web / test (push) Successful in 48s
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m38s
release / Build + push container image (push) Successful in 1m48s
2026-08-04 11:37:30 -04:00
bvandeusen fa7ea41ccf Merge pull request 'Minstrel gets a mark — favicon, header lockup, Android adaptive icon' (#117) from dev into main
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 8m36s
release / Build + push container image (push) Successful in 1m37s
2026-08-03 20:52:27 -04:00
bvandeusen 324059b2bd Merge pull request 'Discover request surface — taste-aware, rotating, snoozable, tag-targeted (milestone #268)' (#116) from dev into main
test-web / test (push) Successful in 1m5s
test-go / test (push) Successful in 1m30s
android / Build + lint + test (push) Successful in 5m1s
test-go / integration (push) Successful in 5m29s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 17s
2026-08-03 08:38:24 -04:00
bvandeusen 1138d75a45 Merge pull request 'Playlist-track atomic replace + ci-requirements true-up' (#115) from dev into main
release / Build signed APK (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m33s
android / Build + lint + test (push) Successful in 4m30s
2026-08-01 12:23:37 -04:00
68 changed files with 3594 additions and 1207 deletions
+40 -103
View File
@@ -2,9 +2,9 @@ name: release
# Builds and pushes the minstrel container image to the Gitea registry.
#
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :latest + :<sha> (latest-release APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
# push to dev → :dev (freshly-built dev APK bundled)
# push to main → :latest + :<sha> (freshly-built stable APK bundled)
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
# workflow_dispatch → manual trigger (same rules based on the ref)
#
# That is the whole tag map.
@@ -41,6 +41,12 @@ name: release
# image in the same run from the same commit, and the channel cannot go
# stale against its own artifact.
#
# Every branch build carries the app built from its own commit, so a merge to
# main is all it takes to put a new app in front of stable phones: `:latest`
# serves it from /api/client/apk and the in-app update offers it. A release
# is an optional bookmark — a named point in history with the APK attached
# for download — never a step the app has to wait for.
#
# Release model: the tag IS the artifact's version name with a `v` in front.
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
@@ -56,20 +62,14 @@ name: release
# :latest is updated by every main push AND every tag push, so it always
# reflects the newest blessed image.
#
# APK pipeline: on tag pushes the android-release job builds + signs the
# Android APK and uploads it as a workflow artifact. The image-release
# job declares `needs: android-release`, so the docker image cannot
# start building until the APK is guaranteed-ready — no polling, no
# race, no silent-failure mode. Attaching the APK to the gitea Release is
# its own job (release-assets), behind the test gate below.
#
# :latest always carries an APK. Because every main push also moves
# :latest (not just tags), a main build with no APK would silently strip
# the in-app update channel off :latest until the next release. So on
# non-tag builds image-release pulls the MOST RECENT release's signed APK
# AND the version sidecar published beside it — the recorded values, not
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
# keep bundling their own freshly-built APK.
# APK pipeline: on every push — dev, main and tags alike — the
# android-release job builds + signs the Android APK and uploads it as a
# workflow artifact. The image-release job declares `needs: android-release`
# and requires its success, so the docker image cannot start building until
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
# and no image that moves a channel without an app inside it. Attaching the
# APK to the gitea Release is its own job (release-assets), tags only, behind
# the test gate below.
#
# THE GATE. Every verifying lane lives in this file —
# Go (vet, lint, short tests), the Postgres integration suite, the web app
@@ -400,11 +400,14 @@ jobs:
# ------------------------------------------------------------ artifacts --
android-release:
name: Build signed APK (releases and dev)
# Also builds on `dev`, which is what makes a test channel possible at
# all: without it the only way to get a build onto a phone is to cut a
# release, which quietly turns `main` into the staging area.
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
name: Build signed APK
# Builds on every ref, so each channel's image carries the app from its
# own commit: `dev` for the test channel, `main` for stable phones, and a
# tag for the copy attached to its Release.
if: >-
startsWith(github.ref, 'refs/tags/v')
|| github.ref == 'refs/heads/dev'
|| github.ref == 'refs/heads/main'
runs-on: flutter-ci
container:
image: git.fabledsword.com/bvandeusen/ci-android:36
@@ -452,7 +455,7 @@ jobs:
# The derivation lives in ci/version.sh, not here, so it can be
# executed by a test on every push. Anything inline in this file is
# unverifiable until a release is already running.
out="$(ci/version.sh HEAD)"
out="$(ci/version.sh --apk HEAD)"
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
# The channel is a property of the LANE, not of the commit, which is
@@ -601,9 +604,9 @@ jobs:
path: release-apk/
- name: Attach APK to gitea Release
# Tag releases only. A dev build has no Release to hang assets on and
# does not need one — the :dev image bundles the APK, and the server
# serves it from /api/client/apk like any other.
# Tag releases only. A branch build has no Release to hang assets on
# and does not need one — its image bundles the APK, and the server
# serves it from /api/client/apk.
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
@@ -667,10 +670,9 @@ jobs:
image-release:
name: Build + push container image
# Every lane must have SUCCEEDED, each named here. Then the
# APK: tag and dev pushes build one and it must have succeeded; main
# pushes skip android-release and bundle the latest release's APK
# instead, so for main alone a skipped android-release is expected.
# Every lane must have SUCCEEDED, each named here, and so must the APK
# build: every ref bundles the APK built from its own commit, so an
# image without one would move a channel with no app for phones to take.
needs: [go, integration, web, android, govulncheck, android-release]
if: >-
${{
@@ -680,8 +682,7 @@ jobs:
&& needs.web.result == 'success'
&& needs.android.result == 'success'
&& needs.govulncheck.result == 'success'
&& (needs.android-release.result == 'success'
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
&& needs.android-release.result == 'success' }}
runs-on: go-ci
container:
image: git.fabledsword.com/bvandeusen/ci-go:1.26
@@ -747,9 +748,10 @@ jobs:
# publish the moving channel tag and never a commit-addressable
# one.
#
# :latest must move here rather than waiting for the next main
# push, or the channel would carry the PREVIOUS release's APK
# indefinitely — a channel that cannot refresh itself.
# :latest moves here so the server hands out the same APK build
# that is attached to the Release: a phone that installed from the
# Release page and one that updated in-app hold one build, with one
# ordering key. Cut a release on main's tip, as :latest follows it.
CHANNEL=stable
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
@@ -782,12 +784,8 @@ jobs:
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
- name: Download signed APK artifact
# Tag and dev pushes — android-release just produced this. Only `main`
# takes the "Bundle latest release APK" path below, because it is the
# one ref that moves a channel without building an APK of its own.
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
# android-release just produced this, on every ref.
if: steps.guard.outputs.ready == 'true'
# Consuming half of the pair: stock download-artifact, which works here for
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
# out of the bundle). v8 runs on node24, which every CI-runner image
@@ -798,9 +796,7 @@ jobs:
path: client/
- name: Stage bundled APK + version sidecar
if: >-
steps.guard.outputs.ready == 'true' &&
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
if: steps.guard.outputs.ready == 'true'
shell: bash
env:
# All three pulled from android-release's outputs so the sidecar the
@@ -821,65 +817,6 @@ jobs:
cat client/minstrel.apk.version
ls -lh client/
- name: Bundle latest release APK (non-tag :latest builds)
# Main pushes don't build an APK, but they DO move :latest — so
# without this the in-app update channel would vanish from :latest
# until the next tag. Pull the most-recent release's signed APK and
# the sidecar published beside it, so what the server reports is what
# that build actually recorded rather than something re-derived here.
# Degrades to an empty client/ (404 update channel) — never a wrong
# version — if no release or APK asset can be resolved. That
# degradation only actually works because the greps below carry
# `|| true`; under the runner's default pipefail a non-matching grep
# kills the step instead of falling through to the empty-case branch.
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
shell: bash
env:
CI_TOKEN: ${{ secrets.CI_TOKEN }}
run: |
set -eu
REPO="${GITHUB_REPOSITORY}"
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
if [ -z "${REL_JSON}" ]; then
echo "::notice::no published release — image ships without bundled APK"; exit 0
fi
# `|| true` on every one of these, and it is load-bearing rather
# than defensive habit. The runner already invokes this shell as
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
# exits non-zero even though `head` succeeded — and the step dies at
# the assignment, before ever reaching the `if` written to handle the
# empty case. Every "degrades gracefully" branch below is unreachable
# without this.
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
fi
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
# Take the version the release RECORDED rather than recomputing it.
# Re-deriving it from the tagged commit would keep the formula in two
# files that must stay in step, and could only ever recover the name —
# the ordering key is build-time minutes and exists nowhere after that
# build ends.
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
if [ -n "${SIDECAR_URL}" ]; then
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
cat client/minstrel.apk.version
else
# Releases published before sidecars were attached. Their name is
# still recoverable from the tag, but their ordering key genuinely
# is not — so it is reported ABSENT rather than guessed. A wrong
# key is an install the platform refuses; an absent one just tells
# the client to fall back to comparing names, which is exactly
# what those builds already do.
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
fi
echo "::notice::bundled release APK from ${TAG}"
ls -lh client/
- name: Build and push
if: steps.guard.outputs.ready == 'true'
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
@@ -8,6 +8,7 @@ import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.fabledsword.minstrel.cache.CacheIndexer
import com.fabledsword.minstrel.cache.audiocache.CacheBudgetSync
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.di.ApplicationScope
@@ -19,10 +20,10 @@ import com.fabledsword.minstrel.metadata.FreshnessSweeper
import com.fabledsword.minstrel.notifications.delivery.DeliveryLauncher
import com.fabledsword.minstrel.player.AudioPrefetcher
import com.fabledsword.minstrel.player.CoverPrefetcher
import com.fabledsword.minstrel.player.LikedAudioPinner
import com.fabledsword.minstrel.player.PlayEventsReporter
import com.fabledsword.minstrel.player.PlaybackErrorReporter
import com.fabledsword.minstrel.player.ResumeController
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
@@ -120,6 +121,21 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
/**
* Same construct-the-singleton trick — CacheBudgetSync's init block
* feeds the audio cache's evictor the liked set and the Storage caps.
* Without this @Inject the evictor would never arm, and the cache
* would grow without a limit.
*/
@Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync
/**
* Same construct-the-singleton trick — LikedAudioPinner's init block
* downloads liked tracks into the Liked songs budget. Without this
* @Inject, liking a track would not keep it offline.
*/
@Suppress("unused") @Inject lateinit var likedAudioPinner: LikedAudioPinner
/**
* Same construct-the-singleton trick — AudioPrefetcher's init
* block subscribes to PlayerController.uiState +
@@ -138,14 +154,6 @@ class MinstrelApplication :
*/
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
/**
* Same construct-the-singleton trick — UpdateBannerController polls
* /api/client/version at launch + every 24h and drives the shell's
* soft "update available" banner. Without this @Inject the poll
* loop would never start and the banner would never surface.
*/
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
/**
* Same construct-the-singleton trick — FreshnessSweeper's init
* block starts a 30-min sweep loop that re-fetches cached
@@ -0,0 +1,122 @@
package com.fabledsword.minstrel.cache.audiocache
import java.util.TreeSet
/** Which budget a cached track's bytes count against. */
enum class CacheBucket { LIKED, RECENT }
/**
* The bookkeeping behind [TwoBucketCacheEvictor]: which budget each cached
* span counts against, how full each budget is, and which span to drop
* next. It knows nothing of Media3, so it is tested directly.
*
* A span belongs to [CacheBucket.LIKED] when its key (the track id) is in
* the liked set, and to [CacheBucket.RECENT] otherwise. A track that is
* liked and also recently played is therefore charged to Liked only; the
* two budgets never count the same bytes. Each bucket drops its own
* least-recently-used span first, so filling one never evicts the other.
*
* Nothing is evicted until [setLiked] has been called once. Before then
* every span would read as Recently played, and trimming at the Recently
* played cap would throw away liked audio at startup.
*
* Not thread-safe; the evictor serialises every call.
*/
class BucketLedger<S : Any>(
private val keyOf: (S) -> String,
private val lengthOf: (S) -> Long,
lruOrder: Comparator<S>,
) {
private val spans = mapOf(
CacheBucket.LIKED to TreeSet(lruOrder),
CacheBucket.RECENT to TreeSet(lruOrder),
)
private val bytes = mutableMapOf(CacheBucket.LIKED to 0L, CacheBucket.RECENT to 0L)
private val spansByKey = HashMap<String, MutableSet<S>>()
private val bucketByKey = HashMap<String, CacheBucket>()
private var liked: Set<String> = emptySet()
private var armed = false
/** Cap on Liked bytes; 0 means unlimited. */
var likedCapBytes: Long = 0L
/** Cap on Recently played bytes; 0 means unlimited. */
var recentCapBytes: Long = 0L
fun bucketOf(key: String): CacheBucket =
if (key in liked) CacheBucket.LIKED else CacheBucket.RECENT
fun bytesIn(bucket: CacheBucket): Long = bytes.getValue(bucket)
/** Bytes the Liked budget can still take; [Long.MAX_VALUE] when unlimited. */
fun likedRoomBytes(): Long = when {
likedCapBytes <= 0L -> Long.MAX_VALUE
else -> (likedCapBytes - bytesIn(CacheBucket.LIKED)).coerceAtLeast(0L)
}
fun add(span: S) {
val key = keyOf(span)
val bucket = bucketByKey[key] ?: bucketOf(key).also { bucketByKey[key] = it }
if (spans.getValue(bucket).add(span)) {
bytes[bucket] = bytesIn(bucket) + lengthOf(span)
spansByKey.getOrPut(key) { HashSet() }.add(span)
}
}
fun remove(span: S) {
val key = keyOf(span)
val bucket = bucketByKey[key] ?: return
if (spans.getValue(bucket).remove(span)) {
bytes[bucket] = bytesIn(bucket) - lengthOf(span)
}
val forKey = spansByKey[key] ?: return
forKey.remove(span)
if (forKey.isEmpty()) {
spansByKey.remove(key)
bucketByKey.remove(key)
}
}
/**
* Replace the liked set and move every cached track whose bucket
* changed: a newly liked track's bytes leave Recently played for Liked,
* an unliked track's go the other way. Arms eviction.
*/
fun setLiked(ids: Set<String>) {
liked = ids
armed = true
val moves = bucketByKey.filter { (key, bucket) -> bucketOf(key) != bucket }.keys.toList()
for (key in moves) {
val from = bucketByKey.getValue(key)
val to = bucketOf(key)
for (span in spansByKey[key].orEmpty()) {
if (spans.getValue(from).remove(span)) {
bytes[from] = bytesIn(from) - lengthOf(span)
spans.getValue(to).add(span)
bytes[to] = bytesIn(to) + lengthOf(span)
}
}
bucketByKey[key] = to
}
}
/**
* The span to drop next, or null when both budgets fit. [pendingKey] and
* [pendingBytes] describe a write about to start, so room is made for it
* in its own bucket first. Recently played is trimmed before Liked.
*/
fun nextEviction(pendingKey: String? = null, pendingBytes: Long = 0L): S? {
if (!armed) return null
val pendingBucket = pendingKey?.let(::bucketOf)
return listOf(CacheBucket.RECENT, CacheBucket.LIKED).firstNotNullOfOrNull { bucket ->
val incoming = if (bucket == pendingBucket) pendingBytes.coerceAtLeast(0L) else 0L
oldestIfOver(bucket, incoming)
}
}
private fun oldestIfOver(bucket: CacheBucket, incoming: Long): S? {
val cap = if (bucket == CacheBucket.LIKED) likedCapBytes else recentCapBytes
val over = cap > 0L && bytesIn(bucket) + incoming > cap
return if (over) spans.getValue(bucket).firstOrNull() else null
}
}
@@ -0,0 +1,40 @@
package com.fabledsword.minstrel.cache.audiocache
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
/**
* Keeps [TwoBucketCacheEvictor] in step with the signed-in user's liked
* tracks and the Storage caps. A like moves that track's cached audio into
* the Liked budget, an unlike moves it out, and a cap change trims at once.
*
* The liked set only flows once a user is signed in, and the evictor drops
* nothing until it has one, so liked audio is never trimmed as Recently
* played while the session is still loading.
*
* Eager-constructed via the construct-the-singleton trick in
* `MinstrelApplication`.
*/
@Singleton
class CacheBudgetSync @Inject constructor(
likes: LikesRepository,
authStore: AuthStore,
evictor: TwoBucketCacheEvictor,
@ApplicationScope scope: CoroutineScope,
) {
init {
scope.launch(Dispatchers.IO) {
combine(likes.observeSignedInLikedTrackIds(), authStore.cacheSettings, ::Pair)
.collect { (ids, settings) ->
evictor.update(ids, settings.likedCapBytes, settings.rollingCapBytes)
}
}
}
}
@@ -1,23 +0,0 @@
package com.fabledsword.minstrel.cache.audiocache
/**
* Defaults for the audio cache; the user's Storage settings override them.
*
* - `likedCapBytes`: cap meant for a protected bucket of liked tracks'
* files. Nothing enforces it yet; SimpleCache evicts by the rolling
* cap alone.
* - `rollingCapBytes`: cap for incidental / auto-prefetch downloads,
* evicted least-recently-used first.
*
* The values are generous so first-time installs work well without
* configuration.
*/
data class CacheConfig(
val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES,
val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES,
) {
companion object {
const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB
const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB
}
}
@@ -9,17 +9,17 @@ private const val DEFAULT_PREFETCH_WINDOW = 5
* User-tunable audio cache settings. Persisted as a JSON
* blob on the auth_session single-row table via [AuthStore].
*
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
* unlimited. Nothing enforces it yet: SimpleCache evicts by
* [rollingCapBytes] alone, and the Storage card hides this control.
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* 0 means unlimited. Effective at next app start (SimpleCache is
* built once per process).
* - [likedCapBytes]: the Liked songs budget, for the cached audio of
* liked tracks. 0 means unlimited.
* - [rollingCapBytes]: the Recently played budget, for everything else
* streamed or prefetched. 0 means unlimited.
* A track that is liked and also recently played counts against Liked
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
* pre-downloads.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Nothing reads it yet, and the
* Storage card hides this control.
* - [cacheLikedTracks]: "Keep liked songs offline". When true,
* `LikedAudioPinner` downloads liked tracks into the Liked budget on
* Wi-Fi, most recently liked first, while the budget has room.
*
* Defaults: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@@ -0,0 +1,112 @@
package com.fabledsword.minstrel.cache.audiocache
import androidx.media3.common.C
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheEvictor
import androidx.media3.datasource.cache.CacheSpan
import javax.inject.Inject
import javax.inject.Singleton
/**
* Bytes held against each audio-cache budget, and how much more the Liked
* budget can take ([Long.MAX_VALUE] when it is unlimited).
*/
data class CacheUsage(val likedBytes: Long, val recentBytes: Long, val likedRoomBytes: Long)
/**
* The audio cache's eviction policy: two budgets, Liked songs and Recently
* played, each with its own cap and its own least-recently-used order (see
* [BucketLedger]). SimpleCache takes exactly one evictor, so this one
* carries both budgets.
*
* [CacheBudgetSync] feeds it the signed-in user's liked set and the caps
* from Storage settings, so a like, an unlike or a cap change applies at
* once, without restarting the app.
*
* Locking: SimpleCache calls every [CacheEvictor] method while holding its
* own monitor. Outside calls take the cache's monitor first and the
* ledger's second, the same order as those callbacks, so the two never
* deadlock.
*/
@Singleton
class TwoBucketCacheEvictor @Inject constructor() : CacheEvictor {
private val ledger = BucketLedger<CacheSpan>(
keyOf = { it.key },
lengthOf = { it.length },
lruOrder = compareBy<CacheSpan> { it.lastTouchTimestamp }.then(naturalOrder()),
)
@Volatile private var cache: Cache? = null
/** Called once the SimpleCache exists, so updates can trim it. */
fun attach(cache: Cache) {
this.cache = cache
}
/** Apply a new liked set and caps, then trim whatever no longer fits. */
fun update(likedIds: Set<String>, likedCapBytes: Long, recentCapBytes: Long) {
val c = cache
val apply = {
ledger.likedCapBytes = likedCapBytes
ledger.recentCapBytes = recentCapBytes
ledger.setLiked(likedIds)
}
if (c == null) {
synchronized(ledger) { apply() }
} else {
synchronized(c) {
synchronized(ledger) {
apply()
evict(c, pendingKey = null, pendingBytes = 0L)
}
}
}
}
fun usage(): CacheUsage = synchronized(ledger) {
CacheUsage(
likedBytes = ledger.bytesIn(CacheBucket.LIKED),
recentBytes = ledger.bytesIn(CacheBucket.RECENT),
likedRoomBytes = ledger.likedRoomBytes(),
)
}
override fun requiresCacheSpanTouches(): Boolean = true
override fun onCacheInitialized() = Unit
override fun onStartFile(cache: Cache, key: String, position: Long, length: Long) {
synchronized(ledger) {
evict(cache, key, if (length == C.LENGTH_UNSET.toLong()) 0L else length)
}
}
override fun onSpanAdded(cache: Cache, span: CacheSpan) {
synchronized(ledger) {
ledger.add(span)
evict(cache, pendingKey = null, pendingBytes = 0L)
}
}
override fun onSpanRemoved(cache: Cache, span: CacheSpan) {
synchronized(ledger) { ledger.remove(span) }
}
override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) {
synchronized(ledger) {
ledger.remove(oldSpan)
ledger.add(newSpan)
evict(cache, pendingKey = null, pendingBytes = 0L)
}
}
private fun evict(cache: Cache, pendingKey: String?, pendingBytes: Long) {
while (true) {
val span = ledger.nextEviction(pendingKey, pendingBytes) ?: return
cache.removeSpan(span)
// removeSpan reports back through onSpanRemoved; removing here as
// well guarantees the loop advances even if it did not.
ledger.remove(span)
}
}
}
@@ -4,18 +4,10 @@ import androidx.room.TypeConverter
import kotlinx.datetime.Instant
/**
* Source category for a cached audio file in `audio_cache_index`.
*
* - `MANUAL`: user explicitly pinned (e.g. "save for offline")
* - `AUTO_LIKED`: auto-cached because the track is liked
* - `AUTO_PLAYLIST`: auto-cached because it's in a pinned playlist
* - `AUTO_PREFETCH`: pre-warmed by background prefetch heuristics
* - `INCIDENTAL`: cached as a side effect of playback streaming
*
* Eviction priority (first to evict): INCIDENTAL → AUTO_PREFETCH
* → AUTO_PLAYLIST → AUTO_LIKED → MANUAL. Only INCIDENTAL is written
* today, and SimpleCache evicts by plain LRU, so nothing applies the
* order yet.
* How a row reached `audio_cache_index`. `CacheIndexer` writes
* `INCIDENTAL` (the track was played); the other values are stored names
* no writer uses. Eviction does not read this: the audio cache's two
* budgets follow the liked set (see `TwoBucketCacheEvictor`).
*/
enum class CacheSource { MANUAL, AUTO_LIKED, AUTO_PLAYLIST, AUTO_PREFETCH, INCIDENTAL }
@@ -4,7 +4,6 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.fabledsword.minstrel.cache.db.CacheSource
import com.fabledsword.minstrel.cache.db.entities.AudioCacheIndexEntity
import kotlinx.coroutines.flow.Flow
@@ -32,23 +31,6 @@ interface AudioCacheIndexDao {
@Query("SELECT trackId FROM audio_cache_index ORDER BY lastPlayedAt DESC NULLS LAST")
suspend fun trackIdsByRecency(): List<String>
/** Eviction-candidate ordering — oldest lastPlayedAt first within a source. */
@Query(
"SELECT * FROM audio_cache_index " +
"WHERE source = :source " +
"ORDER BY lastPlayedAt ASC NULLS FIRST, cachedAt ASC",
)
suspend fun evictionCandidates(source: CacheSource): List<AudioCacheIndexEntity>
/** Sum of bytes across rows; the rolling/liked bucket caps compare against this. */
@Query("SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index")
suspend fun totalBytes(): Long
@Query(
"SELECT COALESCE(SUM(sizeBytes), 0) FROM audio_cache_index WHERE source = :source",
)
suspend fun bytesBySource(source: CacheSource): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(row: AudioCacheIndexEntity)
@@ -10,12 +10,16 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface CachedLikeDao {
/** All track IDs the user has liked. Audio-cache eviction uses this set. */
/**
* The user's liked track IDs, most recently liked first. The audio
* cache's Liked budget and the liked-audio downloads read this.
*/
@Query(
"SELECT entityId FROM cached_likes " +
"WHERE userId = :userId AND entityType = 'track'",
"WHERE userId = :userId AND entityType = 'track' " +
"ORDER BY likedAt DESC",
)
fun observeLikedTrackIds(userId: String): Flow<List<String>>
fun observeLikedTrackIdsNewestFirst(userId: String): Flow<List<String>>
@Query(
"SELECT entityId FROM cached_likes " +
@@ -10,13 +10,11 @@ import kotlinx.datetime.Instant
* One row per track that has played through the audio cache (see
* `CacheIndexer`); SimpleCache, not this table, says what is on disk.
*
* The `source` column is meant to drive a 2-bucket eviction
* (incidental first, then auto-prefetch, manual last). Nothing evicts
* by it yet: SimpleCache evicts by plain LRU over the rolling cap.
* `cachedAt` = when we downloaded. `lastPlayedAt` = when the user last
* played it; drives the offline "recently played" ordering and is the
* tiebreaker for LRU eviction within a source bucket. Nullable on
* brand-new rows until the first post-cache play touches them.
* `source` says how the row arrived (see [CacheSource]); eviction does
* not read it. `cachedAt` = when the row was written. `lastPlayedAt` =
* when the user last played the track, which orders the offline
* "recently played" pool. Nullable on brand-new rows until the first
* post-cache play touches them.
*/
@Entity(tableName = "audio_cache_index")
data class AudioCacheIndexEntity(
@@ -74,6 +74,35 @@ class ConnectivityObserver @Inject constructor(
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
/**
* Whether the network the device is using now is unmetered: Wi-Fi or
* Ethernet, but not mobile data or a hotspot Android marks metered.
* Downloads the user did not start (the liked-song downloads) wait for
* this.
*/
val unmetered: Flow<Boolean> = callbackFlow {
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
trySend(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED))
}
override fun onLost(network: Network) {
trySend(false)
}
}
cm.registerDefaultNetworkCallback(callback)
trySend(isActiveNetworkUnmetered())
awaitClose { cm.unregisterNetworkCallback(callback) }
}.distinctUntilChanged()
private fun isActiveNetworkUnmetered(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
}
private fun hasActiveNetwork(): Boolean {
val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) }
return caps != null &&
@@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import com.fabledsword.minstrel.update.ui.UpdateNotices
import com.fabledsword.minstrel.update.ui.UpdateViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.Channel
@@ -553,6 +555,7 @@ class HomeViewModel @Inject constructor(
fun HomeScreen(
navController: NavHostController,
viewModel: HomeViewModel = hiltViewModel(),
updates: UpdateViewModel = hiltViewModel(),
) {
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
@@ -575,7 +578,11 @@ fun HomeScreen(
val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refreshFromPull() },
onRefresh = {
// A pull asks about a newer app too, so one shows up at once.
updates.check()
viewModel.refreshFromPull()
},
modifier = Modifier.fillMaxSize().padding(inner),
) {
Box(Modifier.fillMaxSize()) {
@@ -590,6 +597,7 @@ fun HomeScreen(
// hide behind an "Updating your mixes…" wipe that stays up
// until the screen has actually stopped moving.
UpdatingVeil(visible = updating)
UpdateNotices(updates)
}
}
}
@@ -14,14 +14,20 @@ import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.milliseconds
/**
* Cache-first reads + offline-first writes for likes.
@@ -117,6 +123,24 @@ class LikesRepository @Inject constructor(
fun observeLikedTrackIds(): Flow<Set<String>> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
/**
* The liked track ids of whoever is signed in, most recently liked
* first, following sign-in and account changes. Emits nothing while
* signed out, so a long-lived collector never mistakes the anonymous
* empty set for "no likes".
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun observeSignedInLikedTrackIdsNewestFirst(): Flow<List<String>> =
authController.currentUser
.map { it?.id }
.distinctUntilChanged()
.filterNotNull()
.flatMapLatest { userId -> likeDao.observeLikedTrackIdsNewestFirst(userId) }
/** [observeSignedInLikedTrackIdsNewestFirst] as a set. */
fun observeSignedInLikedTrackIds(): Flow<Set<String>> =
observeSignedInLikedTrackIdsNewestFirst().map { it.toSet() }
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
suspend fun likedTrackIds(): Set<String> =
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
@@ -166,14 +190,21 @@ class LikesRepository @Inject constructor(
* device would stay on the Liked tab forever. The replaceAllForUser
* DAO method runs a transactional delete-then-insert so the local
* set is exactly what the server reports.
*
* The server lists each kind most recently liked first but sends no
* times, so the rows are stamped a millisecond apart in that order;
* `likedAt` then sorts the way the server does.
*/
suspend fun refreshIds() {
val uid = currentUserId()
val wire = api.ids()
val now = Clock.System.now()
fun rowsOf(type: String, ids: List<String>) =
ids.mapIndexed { i, id -> CachedLikeEntity(uid, type, id, likedAt = now - i.milliseconds) }
val rows = mutableListOf<CachedLikeEntity>()
rows += wire.artistIds.map { CachedLikeEntity(uid, ENTITY_ARTIST, it) }
rows += wire.albumIds.map { CachedLikeEntity(uid, ENTITY_ALBUM, it) }
rows += wire.trackIds.map { CachedLikeEntity(uid, ENTITY_TRACK, it) }
rows += rowsOf(ENTITY_ARTIST, wire.artistIds)
rows += rowsOf(ENTITY_ALBUM, wire.albumIds)
rows += rowsOf(ENTITY_TRACK, wire.trackIds)
likeDao.replaceAllForUser(uid, rows)
}
@@ -15,7 +15,9 @@ package com.fabledsword.minstrel.models
* verbatim rather than validated, so an unexpected value is shown rather
* than dropped.
*
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
* [apkUrl] is server-relative (e.g. `/api/client/apk`). [sha256] is the
* served file's digest, in hex; empty from a server that predates it, and
* then a download is judged whole by its size alone.
*/
data class UpdateInfo(
val version: String,
@@ -23,4 +25,5 @@ data class UpdateInfo(
val channel: String?,
val apkUrl: String,
val sizeBytes: Long,
val sha256: String,
)
@@ -26,4 +26,5 @@ data class UpdateInfoWire(
val channel: String? = null,
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
@SerialName("size_bytes") val sizeBytes: Long = 0,
val sha256: String = "",
)
@@ -0,0 +1,132 @@
package com.fabledsword.minstrel.player
import android.net.Uri
import androidx.media3.common.C
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.CacheWriter
import androidx.media3.datasource.cache.ContentMetadata
import androidx.media3.datasource.okhttp.OkHttpDataSource
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
// The Liked budget must have room for a track at CD bitrate (1411 kbps)
// before one is downloaded, so a download never pushes out liked audio that
// is already on the phone. A track of unknown length is assumed to be ten
// minutes.
private const val CD_BYTES_PER_SECOND = 176_400L
private const val UNKNOWN_DURATION_SEC = 600
/**
* Keeps liked songs on the phone: when "Keep liked songs offline" is on,
* every liked track's whole audio is downloaded into the cache, where
* [TwoBucketCacheEvictor] counts it against the Liked songs budget.
*
* - Most recently liked first, one track at a time, so the newest likes get
* the room and the server is never asked for more than one file at once.
* - Only on an unmetered network (Wi-Fi or Ethernet) while the server is
* reachable. A like made on mobile data downloads once the phone is back
* on Wi-Fi.
* - Stops when the Liked budget cannot fit another track. Liked tracks
* played after that are still cached as they stream.
* - Any change (a like, an unlike, the switch, the network) restarts the
* pass, cancelling a download in flight. Tracks already fully on the
* phone are skipped without touching the network, and a track whose
* download failed is tried again on the next pass.
*
* Downloads are keyed by track id exactly like playback and
* [AudioPrefetcher], so the player reads what this writes.
*
* Eager-constructed via the construct-the-singleton trick in
* `MinstrelApplication`.
*/
@Singleton
class LikedAudioPinner @Inject constructor(
@ApplicationScope scope: CoroutineScope,
likes: LikesRepository,
authStore: AuthStore,
connectivity: ConnectivityObserver,
serverHealth: NetworkStatusController,
private val playerFactory: PlayerFactory,
private val evictor: TwoBucketCacheEvictor,
private val trackDao: CachedTrackDao,
okHttpClient: OkHttpClient,
) {
private val cacheDataSourceFactory: CacheDataSource.Factory =
CacheDataSource.Factory()
.setCache(playerFactory.simpleCache)
.setUpstreamDataSourceFactory(OkHttpDataSource.Factory(okHttpClient))
private data class Pass(val likedNewestFirst: List<String>, val enabled: Boolean, val canDownload: Boolean)
init {
scope.launch(Dispatchers.IO) {
combine(
likes.observeSignedInLikedTrackIdsNewestFirst(),
authStore.cacheSettings.map { it.cacheLikedTracks },
connectivity.unmetered,
serverHealth.state.map { it == ServerHealth.Healthy || it == ServerHealth.Unstable },
) { liked, enabled, unmetered, reachable ->
Pass(liked, enabled, unmetered && reachable)
}
.distinctUntilChanged()
.collectLatest { pass ->
if (pass.enabled && pass.canDownload) pinAll(pass.likedNewestFirst)
}
}
}
private suspend fun pinAll(likedNewestFirst: List<String>) {
for (trackId in likedNewestFirst) {
val track = downloadable(trackId)
if (track != null) {
val durationSec = track.durationSec.takeIf { it > 0 } ?: UNKNOWN_DURATION_SEC
if (evictor.usage().likedRoomBytes < durationSec * CD_BYTES_PER_SECOND) return
runCatching { runInterruptible { download(track.id, track.streamUrl) } }
.onFailure { Timber.d(it, "liked audio: download of %s failed; next pass retries", track.id) }
}
}
}
/** The track, when it still needs downloading and the server can serve it. */
private suspend fun downloadable(trackId: String): TrackRef? {
if (isFullyCached(trackId)) return null
val track = trackDao.getById(trackId)?.toDomain()
return track?.takeIf { !it.unavailable && it.streamUrl.isNotBlank() }
}
private fun isFullyCached(trackId: String): Boolean {
val cache = playerFactory.simpleCache
val length = ContentMetadata.getContentLength(cache.getContentMetadata(trackId))
return length != C.LENGTH_UNSET.toLong() && cache.isCached(trackId, 0, length)
}
private fun download(trackId: String, streamUrl: String) {
val dataSpec = DataSpec.Builder()
.setUri(Uri.parse(resolveServerUrl(streamUrl) ?: streamUrl))
.setKey(trackId)
.build()
CacheWriter(cacheDataSourceFactory.createDataSource(), dataSpec, null, null).cache()
}
}
@@ -14,6 +14,9 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.QueueMode
import com.fabledsword.minstrel.player.output.SingleTrackLoader
import com.fabledsword.minstrel.player.output.streamUriTrackId
import com.fabledsword.minstrel.player.output.upnp.PositionInfo
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
@@ -57,14 +60,21 @@ import timber.log.Timber
* wraps that callback into a SharedFlow consumed by the NowPlaying
* surface as a snackbar.
*
* Queue mode: OutputPickerController loads the full queue into Sonos's
* native queue via ClearQueue + AddURIToQueue, then points the
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively. PollLoop keeps the local cursor aligned to the
* track the renderer is actually playing, matched by track identity
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
* index drift -- see [syncLocalCursorToRemote].
* Queue mode ([ActiveUpnp.queueMode]):
* - NATIVE_QUEUE (Sonos): OutputPickerController loads the full queue
* into Sonos's native queue via ClearQueue + AddURIToQueue, then points
* the transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
* advance natively.
* - SINGLE_TRACK (any other UPnP / DLNA renderer): the renderer holds one
* track. Skip/prev/seekTo point it at the track the local cursor moved
* to, and each poll lets [SingleTrackLoader] follow or drive the advance
* to the next track and supply the queue position the renderer cannot.
*
* PollLoop keeps the local cursor aligned to the track the renderer is
* actually playing, matched by track identity (the id in
* GetPositionInfo's TrackURI) so it survives queue-reload index drift --
* see [syncLocalCursorToRemote].
*/
class MinstrelForwardingPlayer(
private val delegate: Player,
@@ -72,6 +82,7 @@ class MinstrelForwardingPlayer(
private val remoteState: RemotePlayerState,
private val castNetworkLock: CastNetworkLock,
private val networkStatus: NetworkStatusController,
private val singleTrack: SingleTrackLoader,
private val events: RemoteEvents = RemoteEvents(),
) : ForwardingPlayer(delegate) {
@@ -205,24 +216,56 @@ class MinstrelForwardingPlayer(
// drops via isLoadingUpnp() = true; the OutputPickerController
// observes the uiState.queue change and runs the resync (re-clears
// Sonos's native queue + AddURIToQueue the new tracks + Play).
//
// The SAME tracks set again (play on the album already queued, from
// another track) is not a new queue: the renderer already holds it, and
// the uiState collector -- which keys on the track ids -- would never see
// a change to resync on, leaving the cast stuck "loading". It is a move
// within the queue, so the renderer is pointed at the new track and the
// play() that follows goes through.
override fun setMediaItems(mediaItems: List<MediaItem>) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems, resetPosition)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
val sameQueue = holdsSameItems(mediaItems)
super.setMediaItems(mediaItems, startIndex, startPositionMs)
markPendingResyncIfRemote()
afterQueueSet(sameQueue)
}
private fun markPendingResyncIfRemote() {
val wasActive = holder.active.value ?: return
private fun holdsSameItems(mediaItems: List<MediaItem>): Boolean =
mediaItems.size == delegate.mediaItemCount &&
mediaItems.indices.all { mediaItems[it].mediaId == delegate.getMediaItemAt(it).mediaId }
private fun afterQueueSet(sameQueue: Boolean) {
val active = holder.active.value ?: return
if (!sameQueue) {
markPendingResync(active)
return
}
val index = delegate.currentMediaItemIndex
val positionMs = delegate.currentPosition.coerceAtLeast(0L)
Timber.w(
"setMediaItems with the queue %s already holds -- moving to track %d",
active.routeName,
index + 1,
)
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> startSingleTrack(active, index, positionMs)
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, index, positionMs)
}
}
private fun markPendingResync(wasActive: ActiveUpnp) {
Timber.w(
"setMediaItems while UPnP active (%s) -- marking pending resync",
wasActive.routeName,
@@ -319,18 +362,18 @@ class MinstrelForwardingPlayer(
return
}
super.seekTo(mediaItemIndex, positionMs)
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching {
retryTransport {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}.onFailure { handleTransportFailure(active, it) }
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, mediaItemIndex, positionMs)
}
}
private fun seekNativeQueue(active: ActiveUpnp, index: Int, positionMs: Long) {
sendNativeQueueCommand(active) {
active.avTransport.seekToTrack(index + 1)
if (positionMs > 0L) {
active.avTransport.seek(positionMs)
}
}
}
@@ -349,12 +392,9 @@ class MinstrelForwardingPlayer(
// then delegate to Sonos Next. PollLoop reconciles cursor via
// Track index if they diverge.
super.seekToNextMediaItem()
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.next() } }
.onFailure { handleTransportFailure(active, it) }
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.next() }
}
}
@@ -372,11 +412,56 @@ class MinstrelForwardingPlayer(
// Super first for immediate local cursor advance (UI feedback);
// then delegate to Sonos Previous. PollLoop reconciles.
super.seekToPreviousMediaItem()
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, delegate.currentMediaItemIndex)
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) { active.avTransport.previous() }
}
}
/**
* Send a track change to a Sonos native queue. The local cursor has
* already moved; the pending window holds the poll's cursor sync off
* until Sonos reports the new track, so it can't undo the press.
*/
private fun sendNativeQueueCommand(active: ActiveUpnp, command: suspend () -> Unit) {
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { active.avTransport.previous() } }
runCatching { retryTransport { command() } }
.onFailure { handleTransportFailure(active, it) }
}
}
/**
* Point a one-track renderer at [index] of the queue, the track the local
* cursor was just moved to. The same track as the renderer's is a seek
* within it, so next on the last track (the cursor cannot move) sends
* nothing rather than restarting the track. The seek after a new track is
* best-effort: a renderer still loading the URI may refuse it, and the
* track playing from the start beats it not playing.
*/
private fun playSingleTrackAt(active: ActiveUpnp, index: Int, positionMs: Long = 0L) {
if (index == singleTrack.currentIndex) {
if (positionMs > 0L) seekTo(positionMs)
} else {
startSingleTrack(active, index, positionMs)
}
}
private fun startSingleTrack(active: ActiveUpnp, index: Int, positionMs: Long) {
remoteState.beginPendingTransport(
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
)
scope.launch {
runCatching { retryTransport { singleTrack.playIndex(active.avTransport, index) } }
.onSuccess {
if (positionMs > 0L) {
runCatching { active.avTransport.seek(positionMs) }
.onFailure { Timber.w(it, "UPnP seek after track change refused") }
}
pollTrigger.trySend(Unit)
}
.onFailure { handleTransportFailure(active, it) }
}
}
@@ -611,6 +696,14 @@ class MinstrelForwardingPlayer(
*/
private suspend fun pollOnce(active: ActiveUpnp) {
val info = active.avTransport.getPositionInfo()
val transport = active.avTransport.getTransportInfo()
val single = pollSingleTrack(active, info, transport)
// The loader just started the next track, so this reading describes
// the one that ended. Report the new track from its start and leave
// the cursor to the new index; the next poll reads it for real.
val advanced = single?.advanced == true
val rendererTrack = single?.trackNumber ?: info.track
val trackUri = if (advanced) "" else info.trackUri
val now = SystemClock.elapsedRealtime()
val inSeekAckWindow = lastSeekIssuedAtMs > 0L &&
(now - lastSeekIssuedAtMs) < SEEK_ACK_WINDOW_MS
@@ -618,15 +711,52 @@ class MinstrelForwardingPlayer(
// seekTo -- the poll's reported position is stale until Sonos finishes
// processing the Seek SOAP. Other fields still refresh from the poll.
remoteState.applyPositionInfo(
positionMs = if (inSeekAckWindow) remoteState.positionMs else info.relTimeMs,
durationMs = info.trackDurationMs,
trackUri = info.trackUri,
trackNumber = info.track,
positionMs = when {
advanced -> 0L
inSeekAckWindow -> remoteState.positionMs
else -> info.relTimeMs
},
durationMs = durationFor(info, single),
trackUri = trackUri,
trackNumber = rendererTrack,
)
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
events.onRendererTrack(info.track)
val transport = active.avTransport.getTransportInfo()
when (transport.state) {
syncLocalCursorToRemote(sonosTrack = rendererTrack, trackUri = trackUri)
if (single == null) events.onRendererTrack(info.track)
if (advanced) {
applyTransportState(TransportState.PLAYING)
} else {
applyTransportState(transport.state)
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
}
notifyRemoteStateChanged()
}
/**
* A one-track renderer reports Track=1 whatever it plays; the loader
* knows the queue position, and may start the next track from here.
* Null for a Sonos, whose native queue answers for itself.
*/
private suspend fun pollSingleTrack(
active: ActiveUpnp,
info: PositionInfo,
transport: TransportInfo,
): SingleTrackLoader.PollOutcome? =
if (active.queueMode == QueueMode.SINGLE_TRACK) {
singleTrack.onPoll(
transport = active.avTransport,
trackUri = info.trackUri,
state = transport.state,
positionMs = info.relTimeMs,
durationMs = info.trackDurationMs,
playIntent = remoteState.lastPlayIntent,
)
} else {
null
}
private fun applyTransportState(state: TransportState) {
when (state) {
TransportState.PLAYING -> {
nonPlayingPollStreak = 0
remoteState.applyTransportPlaying()
@@ -643,13 +773,18 @@ class MinstrelForwardingPlayer(
remoteState.applyTransportStopped()
}
}
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
TransportState.TRANSITIONING, TransportState.NO_MEDIA, TransportState.UNKNOWN -> Unit
}
observeTransport(transport, info)
checkForStall(active, info.trackUri, transport)
notifyRemoteStateChanged()
}
/**
* The track's duration as the renderer reports it. A one-track renderer
* that reports none (some leave TrackDuration at 0:00:00) falls back to
* the library's duration, so the scrubber has an end to run to.
*/
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
if (single != null && info.trackDurationMs <= 0L) single.knownDurationMs else info.trackDurationMs
/**
* Ask the watchdog what to make of this poll, and act on its answer.
*
@@ -711,7 +846,7 @@ class MinstrelForwardingPlayer(
)
// Tell the user and the admin inbox. Silence here would be the
// original bug: playback simply ends and nobody finds out.
trackIdFromStreamUri(trackUri)?.let { handler.post { events.onStalled(it) } }
streamUriTrackId(trackUri)?.let { handler.post { events.onStalled(it) } }
}
RemoteStallWatchdog.Decision.None -> Unit
}
@@ -763,6 +898,9 @@ class MinstrelForwardingPlayer(
): RemoteStallWatchdog.QueueState {
val stalled = transport.state == TransportState.STOPPED || !transport.statusOk
if (!stalled) return RemoteStallWatchdog.QueueState.UNKNOWN
// A one-track renderer has no queue length to ask for; the loader
// knows whether it just played out the queue's last track.
if (active.queueMode == QueueMode.SINGLE_TRACK) return singleTrack.queueState()
val now = SystemClock.elapsedRealtime()
if (now - lastMediaInfoAtMs > MEDIA_INFO_TTL_MS) {
@@ -834,7 +972,7 @@ class MinstrelForwardingPlayer(
* Must run on the application looper -- reads the delegate timeline.
*/
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
val byId = streamUriTrackId(trackUri)?.let { id ->
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
}
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
@@ -860,20 +998,7 @@ class MinstrelForwardingPlayer(
return best
}
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
private fun trackIdFromStreamUri(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private companion object {
// Path segment that precedes the track-id in a stream URI
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
// back to a delegate MediaItem by identity.
const val TRACKS_PATH_MARKER = "/api/tracks/"
const val POLL_INTERVAL_MS = 1_000L
const val NON_PLAYING_CONFIRM = 2
const val SEEK_ACK_WINDOW_MS = 2_000L
@@ -10,7 +10,6 @@ import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSourceBitmapLoader
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
@@ -20,10 +19,11 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.CacheBitmapLoader
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.player.gain.GainAudioProcessor
import com.fabledsword.minstrel.player.gain.ReplayGainStore
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
import com.fabledsword.minstrel.player.output.SingleTrackLoader
import com.fabledsword.minstrel.player.output.SonosQueueLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.BufferOverflow
@@ -42,8 +42,8 @@ import javax.inject.Singleton
*
* `simpleCache` is exposed as a val for what needs the on-disk bytes:
* the cached-track ids, the offline pools, the prefetcher and the
* Storage card. The `LeastRecentlyUsedCacheEvictor` bounds it at the
* rolling cap; there is no separate liked-track bucket yet.
* Storage card. [TwoBucketCacheEvictor] bounds it by two budgets, Liked
* songs and Recently played.
*
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
@@ -56,21 +56,22 @@ import javax.inject.Singleton
class PlayerFactory @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val cacheConfig: CacheConfig,
private val cacheEvictor: TwoBucketCacheEvictor,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
private val authStore: AuthStore,
private val replayGains: ReplayGainStore,
private val sonosQueue: SonosQueueLoader,
private val singleTrack: SingleTrackLoader,
) {
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
val simpleCache: SimpleCache = SimpleCache(
cacheDir,
LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes),
cacheEvictor,
StandaloneDatabaseProvider(context),
)
).also(cacheEvictor::attach)
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
// of drop events (rapid SOAP failures during a single tear-down) surfaces
@@ -122,6 +123,7 @@ class PlayerFactory @Inject constructor(
remoteState = remoteState,
castNetworkLock = CastNetworkLock(context),
networkStatus = serverHealth,
singleTrack = singleTrack,
events = MinstrelForwardingPlayer.RemoteEvents(
onDrop = { name -> emitDrop(name) },
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
@@ -1,43 +0,0 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't
* need a @Provides here; this module provides the configuration values
* (CacheConfig) it depends on, and any player-related singletons that
* aren't @Inject-constructible.
*
* MinstrelPlayerService injects PlayerFactory and calls `build()` from
* `onCreate`.
*/
@Module
@InstallIn(SingletonComponent::class)
object PlayerModule {
/**
* CacheConfig snapshot from the user's persisted CacheSettings.
* SimpleCache is constructed once per process, so changes via the
* Settings UI take effect on next app launch (not mid-flight).
* AuthStore's StateFlow hydrates async from Room — when the
* factory @Provides runs, the value is either the persisted
* setting (if Room finished first) or CacheSettings.DEFAULT.
* Worst case the user's tuning lands on next-next launch, which
* is acceptable for a single-user device pref.
*/
@Provides
@Singleton
fun provideCacheConfig(authStore: AuthStore): CacheConfig {
val s = authStore.cacheSettings.value
return CacheConfig(
likedCapBytes = s.likedCapBytes,
rollingCapBytes = s.rollingCapBytes,
)
}
}
@@ -13,14 +13,31 @@ import javax.inject.Singleton
* + rendering clients. Decouples [com.fabledsword.minstrel.player.MinstrelForwardingPlayer]
* from [OutputPickerController] -- the picker writes; the forwarding
* player reads. Null = no UPnP active (local ExoPlayer path).
*
* [queueMode] says how the renderer is fed tracks, which decides what a
* skip sends and where the poll reads the queue position from.
*/
data class ActiveUpnp(
val routeId: String,
val routeName: String,
val avTransport: AVTransportClient,
val rendering: RenderingControlClient?,
val queueMode: QueueMode,
)
enum class QueueMode {
/** A Sonos holds the whole queue: [SonosQueueLoader]. */
NATIVE_QUEUE,
/** Any other renderer holds one track at a time: [SingleTrackLoader]. */
SINGLE_TRACK,
;
companion object {
fun forRoute(isSonos: Boolean): QueueMode = if (isSonos) NATIVE_QUEUE else SINGLE_TRACK
}
}
@Singleton
class ActiveUpnpHolder @Inject constructor() {
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
import com.fabledsword.minstrel.player.output.upnp.SoapClient
import com.fabledsword.minstrel.player.output.upnp.TransportState
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
import com.fabledsword.minstrel.player.output.upnp.bareUdn
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
@@ -28,7 +29,6 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import timber.log.Timber
import javax.inject.Inject
@@ -58,10 +58,11 @@ data class RouteSnapshot(
* Selection branches on [OutputRoute.Protocol]:
* - [OutputRoute.Protocol.SYSTEM] — MediaRouter.selectRoute (built-in,
* wired, Bluetooth)
* - [OutputRoute.Protocol.UPNP] — mint a signed stream token via
* [SonosQueueLoader], drive the discovered renderer with
* AVTransport.SetAVTransportURI + Play, pause local playback so
* audio yields to the network speaker
* - [OutputRoute.Protocol.UPNP] — pause local playback so audio yields
* to the network speaker, then hand the renderer the queue: a Sonos
* takes all of it through [SonosQueueLoader]; any other UPnP / DLNA
* renderer takes one track at a time through [SingleTrackLoader]
* (see [QueueMode])
* - [OutputRoute.Protocol.CAST] / [OutputRoute.Protocol.SONOS] —
* not implemented; selecting one does nothing.
*/
@@ -73,6 +74,7 @@ class OutputPickerController @Inject constructor(
private val playerController: PlayerController,
private val playerFactory: PlayerFactory,
private val sonosQueue: SonosQueueLoader,
private val singleTrack: SingleTrackLoader,
private val activeUpnpHolder: ActiveUpnpHolder,
private val remoteState: RemotePlayerState,
private val okHttp: OkHttpClient,
@@ -330,6 +332,12 @@ class OutputPickerController @Inject constructor(
Timber.w("Sonos queue repair: route or transport gone for %s", routeId)
return@withLock
}
if (!upnpRoute.isSonos) {
// Only a native queue can be truncated; a one-track renderer
// never asks for this.
Timber.w("Sonos queue repair skipped: %s holds no queue", upnpRoute.name)
return@withLock
}
val outputRoute = OutputRoute.fromUpnpRoute(upnpRoute)
Timber.w(
"Sonos queue repair: reloading %d tracks on %s (resuming at index %d)",
@@ -371,23 +379,31 @@ class OutputPickerController @Inject constructor(
selectedUpnpRouteIdInternal.value = null
return@withLock
}
val handledIncrementally = runCatching {
if (!upnpRoute.isSonos) {
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
return@withLock
}
// The incremental diff is for a queue changed under a cast that kept
// running (play next, an append, a removal): it leaves the playing
// track alone. A queue REPLACED through setMediaItems has already
// cleared active, and the listener asked for its start track and for
// it to play -- both of which the diff would ignore, and with active
// never restored the app would think it had gone back to the phone
// while the speaker played on. That case reloads in full below.
val stillActive = activeUpnpHolder.active.value?.routeId == routeId
val handledIncrementally = stillActive && runCatching {
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
}.getOrElse { e ->
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
false
}
if (handledIncrementally) {
// Active was never cleared on the incremental path; clear any
// target that markPendingResyncIfRemote set (it didn't, for
// incremental cases that don't go through setMediaItems, but
// belt-and-suspenders).
activeUpnpHolder.setTarget(null)
return@withLock
}
// Full rebuild: ensure active is cleared so transport calls drop
// (markPendingResyncIfRemote may already have done this on the
// setMediaItems path).
// (the forwarding player has already done this on the setMediaItems
// path).
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(routeId)
@@ -403,6 +419,7 @@ class OutputPickerController @Inject constructor(
routeName = outputRoute.name,
avTransport = transport,
rendering = rendering,
queueMode = QueueMode.NATIVE_QUEUE,
),
)
activeUpnpHolder.setTarget(null)
@@ -413,6 +430,57 @@ class OutputPickerController @Inject constructor(
}
}
/**
* The one-track counterpart of the Sonos resync. A mutation that left the
* cast running (play next, radio append, a removal) keeps the current
* track playing and only re-arms what follows it. A queue replaced
* through setMediaItems -- the forwarding player has already cleared
* active, and dropped the play() that came with it -- starts the new
* queue at its current index, as the listener asked. So does a mutation
* that removed the playing track.
*/
private suspend fun resyncSingleTrack(
upnpRoute: UpnpRoute,
transport: AVTransportClient,
newQueue: List<TrackRef>,
newCurrentIndex: Int,
) {
val stillActive = activeUpnpHolder.active.value?.routeId == upnpRoute.id
if (stillActive && requeueKeepsTrack(transport, newQueue)) {
activeUpnpHolder.setTarget(null)
return
}
if (activeUpnpHolder.active.value != null) {
activeUpnpHolder.set(null)
activeUpnpHolder.setTarget(upnpRoute.id)
}
Timber.w("UPnP single-track resync: starting track %d on %s", newCurrentIndex + 1, upnpRoute.name)
runCatching {
singleTrack.load(transport, newQueue, newCurrentIndex)
activeUpnpHolder.set(
ActiveUpnp(
routeId = upnpRoute.id,
routeName = upnpRoute.name,
avTransport = transport,
rendering = renderingClientFor(upnpRoute.id),
queueMode = QueueMode.SINGLE_TRACK,
),
)
activeUpnpHolder.setTarget(null)
remoteState.setPlayIntent(true)
}.onFailure { e ->
Timber.w(e, "UPnP single-track resync failed -- dropping to local")
activeUpnpHolder.setTarget(null)
selectedUpnpRouteIdInternal.value = null
}
}
/** True when the playing track survived the change and was kept playing. */
private suspend fun requeueKeepsTrack(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
runCatching { singleTrack.requeue(transport, newQueue) }
.onFailure { Timber.w(it, "UPnP single-track requeue failed; restarting the queue") }
.getOrDefault(false)
/**
* Called when the active UPnP route drops unexpectedly (the poll loop's
@@ -496,11 +564,10 @@ class OutputPickerController @Inject constructor(
}
/**
* Drive the UPnP renderer using Sonos native queue mode:
* clear the device's queue, load every track from our local queue
* via AddURIToQueue, point the transport at the queue URI, seek to
* the current index, and play. Wrapped in `runCatching` — SOAP
* failure abandons the selection cleanly.
* Hand the renderer the local queue and play it. A Sonos gets its native
* queue loaded ([SonosQueueLoader]); any other renderer is started on the
* current track with the next one armed ([SingleTrackLoader]). Wrapped in
* `runCatching` — SOAP failure abandons the selection cleanly.
*
* Order of operations is deliberate:
* 1. Pause local so the user doesn't keep hearing local audio.
@@ -531,6 +598,7 @@ class OutputPickerController @Inject constructor(
return@withLock
}
val rendering = renderingClientFor(effectiveRoute.id)
val queueMode = queueModeFor(effectiveRoute.id)
// Pause local before flipping UI state -- user shouldn't keep hearing
// local audio while we queue up Sonos.
playerController.pause()
@@ -539,7 +607,12 @@ class OutputPickerController @Inject constructor(
// session we got disconnected from), attach to it in place instead of
// wiping + reloading -- no jarring restart, and skip/seek land on its
// live queue. Falls through to the clear+reload path when it isn't ours.
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
val adopted = tryAdoptRunningSession(
AdoptTarget(transport, rendering, effectiveRoute, queueMode),
uiState.queue,
uiState.queueIndex,
)
if (adopted) {
return@withLock
}
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
@@ -547,7 +620,12 @@ class OutputPickerController @Inject constructor(
// taps don't hit Sonos's stale state from a prior session.
activeUpnpHolder.setTarget(effectiveRoute.id)
runCatching {
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
when (queueMode) {
QueueMode.NATIVE_QUEUE ->
sonosQueue.load(transport, effectiveRoute, uiState.queue, uiState.queueIndex)
QueueMode.SINGLE_TRACK ->
singleTrack.load(transport, uiState.queue, uiState.queueIndex)
}
// Wire active LAST -- SOAP path is now safe to use.
activeUpnpHolder.set(
ActiveUpnp(
@@ -555,6 +633,7 @@ class OutputPickerController @Inject constructor(
routeName = effectiveRoute.name,
avTransport = transport,
rendering = rendering,
queueMode = queueMode,
),
)
// Selecting a route loads the queue and Plays on the renderer, so
@@ -570,38 +649,49 @@ class OutputPickerController @Inject constructor(
/**
* The renderer's current position + transport state, validated as an
* adoptable continuation of our local queue: same track id at the same
* 1-based index. Null means "not ours / not playing" -> caller reloads.
* adoptable continuation of our local queue. Null means "not ours / not
* playing" -> caller reloads.
*/
private data class AdoptPlan(
val localIndex: Int,
val positionMs: Long,
val durationMs: Long,
val trackUri: String,
val trackNumber: Int,
val playing: Boolean,
)
/** The renderer a select is about to drive, and how. */
private data class AdoptTarget(
val transport: AVTransportClient,
val rendering: RenderingControlClient?,
val route: OutputRoute,
val queueMode: QueueMode,
)
/**
* Attach to a renderer that is *already* playing our queue (a session the
* phone got disconnected from but the speaker kept streaming) instead of
* clearing + reloading it. Probes the renderer's live state, and if it's
* mid-playback on the same track id at the same queue index, syncs the
* mid-playback on one of our tracks where our queue has it, syncs the
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
* skips a beat, and skip/seek now drive its live queue. Returns false (and
* the caller does the full clear+reload) when the renderer isn't playing
* our queue. The local player is already paused by the caller.
* skips a beat, and skip/seek now drive it. Returns false (and the caller
* does the full load) when the renderer isn't playing our queue. The
* local player is already paused by the caller.
*/
private suspend fun tryAdoptRunningSession(
transport: AVTransportClient,
rendering: RenderingControlClient?,
route: OutputRoute,
target: AdoptTarget,
localQueue: List<TrackRef>,
localIndex: Int,
): Boolean {
val plan = probeAdoptable(transport, route, localQueue) ?: return false
val route = target.route
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
if (target.queueMode == QueueMode.SINGLE_TRACK) {
// The renderer holds only this track; arm the one after it.
singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri)
}
Timber.w(
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
route.name, plan.trackNumber, plan.positionMs,
route.name, plan.localIndex + 1, plan.positionMs,
)
// active is still null here, so this aligns the *local* cursor without
// emitting SOAP; the holder.set below then starts the poll loop, which
@@ -611,45 +701,54 @@ class OutputPickerController @Inject constructor(
positionMs = plan.positionMs,
durationMs = plan.durationMs,
trackUri = plan.trackUri,
trackNumber = plan.trackNumber,
trackNumber = plan.localIndex + 1,
)
remoteState.setPlayIntent(plan.playing)
activeUpnpHolder.set(
ActiveUpnp(
routeId = route.id,
routeName = route.name,
avTransport = transport,
rendering = rendering,
avTransport = target.transport,
rendering = target.rendering,
queueMode = target.queueMode,
),
)
return true
}
/**
* A Sonos must be on our track at the same 1-based index of its native
* queue, since skips drive that queue. A one-track renderer has no index
* to agree on: it is ours if it is playing one of our tracks, taken
* nearest the local cursor.
*/
private suspend fun probeAdoptable(
transport: AVTransportClient,
route: OutputRoute,
target: AdoptTarget,
localQueue: List<TrackRef>,
localIndex: Int,
): AdoptPlan? {
val probe = runCatching {
transport.getTransportInfo() to transport.getPositionInfo()
target.transport.getTransportInfo() to target.transport.getPositionInfo()
}.getOrElse {
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", target.route.name)
return null
}
val (info, pos) = probe
val idx0 = pos.track - 1
val playingId = trackIdFromStreamUrl(pos.trackUri)
val playingId = streamUriTrackId(pos.trackUri)
val midPlayback =
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
val aligned = midPlayback && playingId != null &&
idx0 in localQueue.indices && localQueue[idx0].id == playingId
return if (aligned) {
val idx0 = when {
playingId == null -> null
target.queueMode == QueueMode.SINGLE_TRACK ->
nearestIndexOf(localQueue, playingId, localIndex)
else -> (pos.track - 1).takeIf { it in localQueue.indices && localQueue[it].id == playingId }
}
return if (midPlayback && idx0 != null) {
AdoptPlan(
localIndex = idx0,
positionMs = pos.relTimeMs,
durationMs = pos.trackDurationMs,
trackUri = pos.trackUri,
trackNumber = pos.track,
playing = info.state == TransportState.PLAYING,
)
} else {
@@ -657,12 +756,9 @@ class OutputPickerController @Inject constructor(
}
}
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
private fun trackIdFromStreamUrl(url: String): String? {
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
val i = segments.indexOf("tracks")
return if (i >= 0) segments.getOrNull(i + 1) else null
}
/** How the route is fed tracks; a route gone from discovery reads as not-Sonos. */
private fun queueModeFor(routeId: String): QueueMode =
QueueMode.forRoute(upnpDiscovery.routes.value.firstOrNull { it.id == routeId }?.isSonos == true)
private fun renderingClientFor(routeId: String): RenderingControlClient? {
@@ -0,0 +1,266 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.RemoteStallWatchdog
import com.fabledsword.minstrel.player.StreamTokenProvider
import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
import com.fabledsword.minstrel.player.output.upnp.TransportState
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
import javax.inject.Inject
import kotlin.math.abs
import javax.inject.Singleton
/**
* Drives a UPnP / DLNA renderer that has no queue of its own — anything that
* isn't a Sonos. [SonosQueueLoader] hands a Sonos the whole queue; the queue
* verbs it uses are Sonos extensions a generic renderer faults on. Here the
* app holds the queue and the renderer holds one track at a time:
*
* - SetAVTransportURI + Play starts a track.
* - SetNextAVTransportURI hands over the one after it, so a renderer that
* supports it moves on by itself, without a gap. The poll sees the
* renderer's URI change to that one and arms the next.
* - A renderer without SetNext (it faults) or one that accepts it and
* ignores it stops at the end of the track; [TrackEndDetector] tells that
* from a stop partway, and the app starts the next track itself.
*
* The renderer reports Track=1 for whatever it is playing, so the 1-based
* queue position everything else keys on comes from here ([PollOutcome]).
*
* One mutex covers every write, because the poll's auto-advance and the
* listener's skip both point the renderer somewhere and must not interleave.
*/
@Singleton
class SingleTrackLoader @Inject constructor(
private val streamTokens: StreamTokenProvider,
) {
private val mutex = Mutex()
@Volatile private var queue: List<TrackRef> = emptyList()
/** 0-based index of the track the renderer was last pointed at; -1 when none. */
@Volatile var currentIndex: Int = -1
private set
private var currentUri: String = ""
private var armedNextIndex: Int = -1
private var armedNextUri: String = ""
// Set when the renderer faults SetNextAVTransportURI: it does not have the
// action, and asking again per track would only collect the same fault.
// Cleared on every load, since a load may be a different renderer.
private var nextUnsupported = false
// Polls to wait before re-trying a SetNext that failed in transit. Each
// try mints a URL from the server, so a failure is not retried every
// second.
private var armRetryPolls = 0
// Whether the last poll found the current track played to its end. Read
// by [queueState] for the stall watchdog.
private var endedOnLastPoll = false
private val endDetector = TrackEndDetector()
/**
* What a poll learned. [trackNumber] is the 1-based queue position the
* renderer is playing (0 when nothing is loaded). [advanced] is true when
* this poll started the next track itself, so the reading it was given is
* already stale and the caller should not judge a stall from it.
* [knownDurationMs] is the library's duration for that track, for a
* renderer that reports none.
*/
data class PollOutcome(val trackNumber: Int, val advanced: Boolean, val knownDurationMs: Long = 0L)
/** Take [queue] as the one to play, and start [index] on the renderer. */
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
mutex.withLock {
this.queue = queue
nextUnsupported = false
start(transport, index)
}
/**
* Start [index] of the queue already held — a skip, a previous, or a tap
* on a queue row. Returns false when [index] is outside the queue.
*/
suspend fun playIndex(transport: AVTransportClient, index: Int): Boolean =
mutex.withLock { start(transport, index) }
/**
* Attach to a renderer already playing [index] of [queue] at [uri] — a
* session the phone lost touch with and the renderer carried on with —
* without restarting it. Only arms the next track.
*/
suspend fun adopt(transport: AVTransportClient, queue: List<TrackRef>, index: Int, uri: String) =
mutex.withLock {
this.queue = queue
nextUnsupported = false
pointedAt(index, uri)
armNext(transport)
}
/**
* The local queue changed under a playing track (play next, radio
* append, a removal). Keep playing the current track and re-arm what
* follows it. Returns false when the current track is gone from
* [newQueue]; the caller then starts the new queue with [load].
*/
suspend fun requeue(transport: AVTransportClient, newQueue: List<TrackRef>): Boolean =
mutex.withLock {
val playingId = queue.getOrNull(currentIndex)?.id
val idx = playingId?.let { nearestIndexOf(newQueue, it, currentIndex) }
queue = newQueue
if (idx == null) return@withLock false
currentIndex = idx
// What was armed was chosen from the old queue; SetNext replaces it.
armedNextIndex = -1
armedNextUri = ""
armNext(transport)
true
}
/**
* One poll's reading of the renderer. Follows a renderer that moved on by
* itself, re-tries arming the next track, and starts the next track when
* the current one has played to its end.
*/
suspend fun onPoll(
transport: AVTransportClient,
trackUri: String,
state: TransportState,
positionMs: Long,
durationMs: Long,
playIntent: Boolean,
): PollOutcome = mutex.withLock {
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
val moved = followRenderer(trackUri)
armNext(transport)
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex))
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex))
val next = currentIndex + 1
val advance = endedOnLastPoll && playIntent && next in queue.indices
if (advance) {
Timber.w("UPnP single-track: track %d ended; starting %d", currentIndex + 1, next + 1)
runCatching { start(transport, next) }
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
}
PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex))
}
/**
* For the stall watchdog: a renderer stopped at the end of the queue's
* last track has simply finished. Anything else is left for the watchdog
* to judge, as it would for a renderer that says nothing.
*/
fun queueState(): RemoteStallWatchdog.QueueState =
if (endedOnLastPoll && currentIndex == queue.lastIndex) {
RemoteStallWatchdog.QueueState.COMPLETE
} else {
RemoteStallWatchdog.QueueState.UNKNOWN
}
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
if (index !in queue.indices) return false
val token = mint(index)
transport.setAVTransportURIWithMetadata(token.url, token.mime, token.title)
transport.play()
pointedAt(index, token.url)
armNext(transport)
return true
}
private fun pointedAt(index: Int, uri: String) {
currentIndex = index
currentUri = uri
armedNextIndex = -1
armedNextUri = ""
armRetryPolls = 0
endedOnLastPoll = false
endDetector.reset()
}
/**
* Update [currentIndex] when the renderer's URI says it is playing
* something other than the track we started. The armed next URI is the
* common case (it advanced by itself); otherwise the track id in the URI
* is looked up, for a renderer that was re-pointed some other way.
* Returns true when the index moved.
*/
private fun followRenderer(trackUri: String): Boolean {
val idx = when {
trackUri.isEmpty() || trackUri == currentUri -> null
trackUri == armedNextUri -> armedNextIndex
else -> streamUriTrackId(trackUri)?.let { nearestIndexOf(queue, it, currentIndex + 1) }
}
if (idx == null || idx == currentIndex) return false
Timber.w("UPnP single-track: renderer moved on to track %d", idx + 1)
pointedAt(idx, trackUri)
return true
}
private suspend fun armNext(transport: AVTransportClient) {
val next = currentIndex + 1
if (nextUnsupported || next !in queue.indices || next == armedNextIndex) return
if (armRetryPolls > 0) {
armRetryPolls -= 1
return
}
runCatching {
val token = mint(next)
transport.setNextAVTransportURI(token.url, token.mime, token.title)
token.url
}.onSuccess { url ->
armedNextIndex = next
armedNextUri = url
}.onFailure { e ->
if (e is SoapFaultException) {
// The renderer answered: it has no SetNext. The app advances it.
Timber.w("UPnP single-track: SetNext refused (%s); advancing from the app", e.code)
nextUnsupported = true
} else {
Timber.w(e, "UPnP single-track: SetNext failed; retrying later")
armRetryPolls = ARM_RETRY_POLLS
}
}
}
// Prerender both: the current track is about to be fetched, and the next
// one is fetched the moment the renderer moves on.
private suspend fun mint(index: Int) = streamTokens.mint(
queue[index].id,
asAlbum = SonosQueueLoader.playingAsAlbum(queue, index),
prerender = true,
)
private companion object {
const val ARM_RETRY_POLLS = 10
}
}
/** The library's duration of [queue]'s track at [index], or 0 when there is none. */
private fun knownDurationMs(queue: List<TrackRef>, index: Int): Long =
queue.getOrNull(index)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
/**
* Index in [queue] of the track with [id], taking the occurrence nearest
* [preferNear] so a track queued twice does not send the cursor across the
* queue. Null when [id] is not queued.
*/
internal fun nearestIndexOf(queue: List<TrackRef>, id: String, preferNear: Int): Int? =
queue.indices.filter { queue[it].id == id }.minByOrNull { abs(it - preferNear) }
/** The track id in a `.../api/tracks/{id}/stream...` URL, or null for any other URL. */
internal fun streamUriTrackId(uri: String): String? {
val start = uri.indexOf(TRACKS_PATH_MARKER)
if (start < 0) return null
val idStart = start + TRACKS_PATH_MARKER.length
val idEnd = uri.indexOf('/', idStart)
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
}
private const val TRACKS_PATH_MARKER = "/api/tracks/"
private const val MILLIS_PER_SECOND = 1_000L
@@ -0,0 +1,63 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.TransportState
/**
* Tells a renderer that finished its track from one that stopped partway.
*
* A renderer with no queue of its own reports the same STOPPED either way.
* Finishing means the app should start the next track; stopping partway is
* the stall watchdog's business (the stream died, or someone pressed stop on
* the device), and starting the next track then would skip music.
*
* The test is where the renderer was last seen playing: within [END_SLACK_MS]
* of the end counts as finished. The slack covers the 1 s poll cadence plus a
* renderer that stops a little short of the duration it reported. A track is
* only judged once it has been seen playing, so the brief STOPPED a renderer
* passes through while it loads a new URI never reads as an ending.
*
* Pure state, no SOAP: [SingleTrackLoader] feeds it each poll.
*/
internal class TrackEndDetector {
private var sawPlaying = false
private var lastPlayingPositionMs = 0L
private var durationMs = 0L
/**
* Feed one poll, learn whether the current track has ended.
* [reportedDurationMs] is the renderer's TrackDuration, which some
* renderers leave at zero; [knownDurationMs] is the library's duration
* for the same track, used when the renderer says nothing.
*/
fun onPoll(
state: TransportState,
positionMs: Long,
reportedDurationMs: Long,
knownDurationMs: Long,
): Boolean {
val duration = reportedDurationMs.takeIf { it > 0L } ?: knownDurationMs
if (duration > 0L) durationMs = duration
if (state == TransportState.PLAYING) {
sawPlaying = true
lastPlayingPositionMs = positionMs
return false
}
val stopped = state == TransportState.STOPPED || state == TransportState.NO_MEDIA
return stopped &&
sawPlaying &&
durationMs > 0L &&
lastPlayingPositionMs >= durationMs - END_SLACK_MS
}
/** A new track was handed to the renderer: judge it from scratch. */
fun reset() {
sawPlaying = false
lastPlayingPositionMs = 0L
durationMs = 0L
}
private companion object {
const val END_SLACK_MS = 5_000L
}
}
@@ -4,15 +4,21 @@ import okhttp3.HttpUrl
/**
* High-level wrapper for the UPnP AVTransport service.
* Covers SetAVTransportURI / Play / Pause / Stop / Seek /
* GetPositionInfo / GetTransportInfo / queue management
* (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous, SeekToTrack).
* Covers SetAVTransportURI / SetNextAVTransportURI / Play / Pause / Stop /
* Seek / GetPositionInfo / GetTransportInfo, plus the Sonos queue
* extensions (RemoveAllTracksFromQueue, AddURIToQueue, Next, Previous,
* SeekToTrack).
*
* [sonos] says which kind of renderer is on the other end. It shapes the
* DIDL-Lite every track is described with, so it is fixed at construction
* rather than chosen per call.
*/
// One method per AVTransport SOAP verb; splitting would obscure the 1:1 protocol mapping.
@Suppress("TooManyFunctions")
class AVTransportClient(
private val soap: SoapClient,
private val controlUrl: HttpUrl,
private val sonos: Boolean,
) {
suspend fun setAVTransportURI(uri: String, metadata: String = "") {
soap.call(
@@ -39,15 +45,34 @@ class AVTransportClient(
* <res protocolInfo="http-get:*:<mime>:*">...</res>
* </item>
* </DIDL-Lite>
* Generic UPnP renderers tolerate this shape too — there's no
* downside to always sending it. Title falls back to "Minstrel"
* when the caller doesn't supply one.
* The Sonos-only parts are left out for other renderers; see
* [buildDidlLite]. Title falls back to "Minstrel" when the caller
* doesn't supply one.
*/
suspend fun setAVTransportURIWithMetadata(uri: String, mime: String, title: String) {
val safeTitle = title.ifBlank { "Minstrel" }
setAVTransportURI(uri, buildDidlLite(uri, mime, safeTitle))
}
/**
* Hand the renderer the track to play once the current one ends, so it
* can move on without a gap and without waiting for us. Optional in the
* UPnP spec: a renderer without it faults (401 Invalid Action, or 602
* Not Implemented), and the caller then advances the renderer itself.
*/
suspend fun setNextAVTransportURI(uri: String, mime: String, title: String) {
soap.call(
controlUrl = controlUrl,
serviceType = SERVICE_TYPE,
action = "SetNextAVTransportURI",
args = mapOf(
"InstanceID" to "0",
"NextURI" to uri,
"NextURIMetaData" to buildDidlLite(uri, mime, title),
),
)
}
/**
* Remove all tracks from the renderer's queue. UPnP action name is
* `RemoveAllTracksFromQueue`. Used at activation time to clear out any
@@ -247,6 +272,7 @@ class AVTransportClient(
"PAUSED_PLAYBACK" -> TransportState.PAUSED
"STOPPED" -> TransportState.STOPPED
"TRANSITIONING" -> TransportState.TRANSITIONING
"NO_MEDIA_PRESENT" -> TransportState.NO_MEDIA
else -> TransportState.UNKNOWN
}
// CurrentTransportStatus is the renderer's own verdict on whether it is
@@ -267,22 +293,31 @@ class AVTransportClient(
// discards our metadata content and regenerates its own with
// class=object.item and the URL query string as the title
// instead. Match SoCo's pattern.
//
// Other renderers get plain DIDL with DLNA.ORG_OP=01 in the
// protocolInfo, which declares that the stream takes byte-range
// requests. Several DLNA renderers (TVs, AV receivers) refuse to
// seek without it; the server's streams all serve Range.
val safeTitle = title.ifBlank { "Minstrel" }
val fourthField = if (sonos) "*" else DLNA_SEEKABLE
return buildString {
append("<DIDL-Lite ")
append("xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\" ")
append("xmlns:dc=\"http://purl.org/dc/elements/1.1/\" ")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" ")
append("xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\"")
if (sonos) append(" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\"")
append(">")
append("<item id=\"-1\" parentID=\"-1\" restricted=\"true\">")
append("<dc:title>").append(xmlEscape(safeTitle)).append("</dc:title>")
append("<upnp:class>object.item.audioItem.musicTrack</upnp:class>")
append("<res protocolInfo=\"http-get:*:").append(xmlEscape(mime))
append(":*\">").append(xmlEscape(uri)).append("</res>")
append("<desc id=\"cdudn\" ")
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("RINCON_AssociatedZPUDN")
append("</desc>")
append(":").append(fourthField).append("\">").append(xmlEscape(uri)).append("</res>")
if (sonos) {
append("<desc id=\"cdudn\" ")
append("nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">")
append("RINCON_AssociatedZPUDN")
append("</desc>")
}
append("</item>")
append("</DIDL-Lite>")
}
@@ -308,6 +343,7 @@ class AVTransportClient(
private companion object {
const val SERVICE_TYPE = "urn:schemas-upnp-org:service:AVTransport:1"
const val DLNA_SEEKABLE = "DLNA.ORG_OP=01"
const val MILLIS_PER_SECOND = 1000L
const val SECONDS_PER_MINUTE = 60L
const val SECONDS_PER_HOUR = 3600L
@@ -330,7 +366,12 @@ data class PositionInfo(
*/
data class MediaInfo(val nrTracks: Int, val currentUri: String)
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
/**
* [NO_MEDIA] is NO_MEDIA_PRESENT: nothing loaded. Several DLNA renderers land
* there instead of STOPPED when a track plays out, so a one-track renderer's
* ending reads it as a stop; it is not treated as a stall.
*/
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, NO_MEDIA, UNKNOWN }
/**
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
@@ -135,13 +135,14 @@ class UpnpDiscoveryController @Inject constructor(
return AVTransportClient(
loggingSoapClient(controlHttp, route.name),
route.avTransportControlUrl,
sonos = route.isSonos,
)
}
private suspend fun handleDiscovery(locationUrl: String) {
val route = fetchRoute(locationUrl) ?: return
upsertRoute(route)
if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
if (route.isSonos) {
refreshSonosTopology(route)
}
}
@@ -25,4 +25,13 @@ data class UpnpRoute(
val avTransportControlUrl: HttpUrl,
val renderingControlUrl: HttpUrl?,
val zoneGroupTopologyControlUrl: HttpUrl? = null,
)
) {
/**
* Whether this renderer is a Sonos, which is driven through its native
* queue. Anything else gets one track at a time — the Sonos queue verbs
* are vendor extensions a generic renderer faults on.
*/
val isSonos: Boolean
get() = zoneGroupTopologyControlUrl != null ||
manufacturer.contains("Sonos", ignoreCase = true)
}
@@ -1,137 +0,0 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateRepository
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.isUpdateAvailable
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
/**
* One of three terminal states the Check-for-updates button surfaces.
* `Idle` is the pre-check state; `Latest` means the installed build
* matches or exceeds the server's bundled APK; `UpdateAvailable`
* surfaces an "Install vX.Y.Z" button that downloads the APK and
* installs it via [ApkInstaller].
*/
sealed interface UpdateCheckResult {
data object Idle : UpdateCheckResult
data object Latest : UpdateCheckResult
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult
data class Error(val message: String) : UpdateCheckResult
}
data class AboutUiState(
val installedVersion: String = BuildConfig.VERSION_NAME,
// The value the platform installs by, and therefore the one the update
// check must decide on. Held in state rather than read inline so a test
// can drive the comparison without a BuildConfig.
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
// A debug build is signed with this machine's debug key, so the server's
// release-signed APK can never install over it. It updates from Android
// Studio instead.
val selfUpdates: Boolean = !BuildConfig.DEBUG,
val isChecking: Boolean = false,
val installStage: InstallStage = InstallStage.IDLE,
val installMessage: String? = null,
val result: UpdateCheckResult = UpdateCheckResult.Idle,
)
/**
* Backs the About card's update controls. "Check for updates" calls
* [UpdateRepository.getLatest], compares versus this build via
* [isUpdateAvailable] — on the ordering key where the server reports one,
* on the name otherwise — and reports the terminal state.
* When an update is available, [install] downloads the APK via
* [ApkInstaller] and installs it — routing the user to the "install
* unknown apps" settings page first when that permission hasn't been
* granted.
*/
@HiltViewModel
class AboutCardViewModel @Inject constructor(
private val repository: UpdateRepository,
private val installer: ApkInstaller,
) : ViewModel() {
private val internal = MutableStateFlow(AboutUiState())
val state: StateFlow<AboutUiState> = internal.asStateFlow()
fun checkForUpdates() {
if (internal.value.isChecking || !internal.value.selfUpdates) return
viewModelScope.launch {
internal.update { it.copy(isChecking = true, installMessage = null) }
val installed = internal.value.installedVersion
val installedCode = internal.value.installedCode
val result = runCatching { repository.getLatest() }
.map { latest ->
if (
isUpdateAvailable(
serverCode = latest.code,
serverName = latest.version,
installedCode = installedCode,
installedName = installed,
)
) {
UpdateCheckResult.UpdateAvailable(latest)
} else {
UpdateCheckResult.Latest
}
}
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
internal.update { it.copy(isChecking = false, result = result) }
}
}
fun install(info: UpdateInfo) {
if (internal.value.installStage.isBusy()) return
if (!installer.canInstall()) {
installer.requestInstallPermission()
internal.update {
it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.")
}
return
}
viewModelScope.launch {
internal.update {
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
}
val apk = download(info.apkUrl)
if (apk != null) {
// The install half now suspends on the platform's verdict, so it
// gets its own stage — reporting "Downloading…" through it would
// be a lie once a confirm dialog is on screen.
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
val outcome = installer.install(apk)
internal.update {
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
}
}
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
internal.update {
it.copy(
installStage = InstallStage.ERROR,
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
}
.getOrNull()
}
@@ -51,7 +51,6 @@ import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.LogOut
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Shield
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.NotificationSettings
import com.fabledsword.minstrel.nav.Requests
@@ -61,8 +60,9 @@ import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.theme.ThemeMode
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.ui.AppVersionCard
import com.fabledsword.minstrel.update.ui.CheckWhileShown
import com.fabledsword.minstrel.update.ui.UpdateViewModel
@Composable
fun SettingsScreen(
@@ -367,112 +367,14 @@ private fun displayName(mode: ThemeMode): String = when (mode) {
ThemeMode.DARK -> "Dark"
}
/**
* The About card is the app-version card Home's update sheet holds too; on
* Settings it also asks the server again while the screen is up.
*/
@Composable
private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Installed: Minstrel ${state.installedVersion} " +
"(${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
UpdateControls(state = state, viewModel = viewModel)
}
}
}
@Composable
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
if (!state.selfUpdates) {
Text(
text = "Debug build: updates install from Android Studio.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return
}
UpdateCheckLine(result = state.result)
Button(
onClick = viewModel::checkForUpdates,
enabled = !state.isChecking && !state.installStage.isBusy(),
modifier = Modifier.fillMaxWidth(),
) {
if (state.isChecking) {
ButtonSpinner()
}
Text(if (state.isChecking) "Checking…" else "Check for updates")
}
val available = state.result as? UpdateCheckResult.UpdateAvailable
if (available != null) {
InstallButton(
version = available.info.version,
stage = state.installStage,
onClick = { viewModel.install(available.info) },
)
}
if (state.installMessage != null) {
Text(
text = state.installMessage,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = !stage.isBusy(),
modifier = Modifier.fillMaxWidth(),
) {
if (stage.isBusy()) {
ButtonSpinner()
}
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install $version"
},
)
}
}
@Composable
private fun ButtonSpinner() {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.size(8.dp))
}
@Composable
private fun UpdateCheckLine(result: UpdateCheckResult) {
val text = when (result) {
UpdateCheckResult.Idle -> return
UpdateCheckResult.Latest -> "You're on the latest version."
is UpdateCheckResult.UpdateAvailable ->
"Update available: ${result.info.version}"
is UpdateCheckResult.Error -> "Couldn't check: ${result.message}"
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
private fun AboutCard(model: UpdateViewModel = hiltViewModel()) {
CheckWhileShown(model)
AppVersionCard(model)
}
@Composable
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -16,6 +17,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -25,6 +27,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -55,21 +58,26 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
UsageRow(usedBytes = state.usedBytes)
// Only the rolling cap is shown. Nothing enforces a separate
// liked cap or pins liked tracks yet, and a control that silently
// does nothing is worse than none. The prefetch window has no
// control either; AudioPrefetcher uses the stored value (default
// 5). CacheSettings keeps every field so the controls can return.
// Two budgets: a liked track that is also recently played counts
// against Liked songs only. The prefetch window has no control;
// AudioPrefetcher uses the stored value (default 5).
CapDropdown(
label = "Cache limit",
label = "Liked songs",
hint = "${formatBytes(state.likedBytes)} used",
value = state.settings.likedCapBytes,
onSet = viewModel::setLikedCapBytes,
)
CapDropdown(
label = "Recently played",
hint = "${formatBytes(state.recentBytes)} used",
value = state.settings.rollingCapBytes,
onSet = viewModel::setRollingCapBytes,
)
Text(
text = "Takes effect on the next app launch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
SwitchRow(
label = "Keep liked songs offline",
hint = "Downloads on Wi-Fi",
checked = state.settings.cacheLikedTracks,
onCheckedChange = viewModel::setCacheLikedTracks,
)
StorageActions(
isSyncing = state.isSyncing,
@@ -91,41 +99,57 @@ fun StorageCard(viewModel: StorageViewModel = hiltViewModel()) {
}
@Composable
private fun UsageRow(usedBytes: Long) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "Used",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
text = formatBytes(usedBytes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
@Composable
private fun CapDropdown(label: String, value: Long, onSet: (Long) -> Unit) {
private fun CapDropdown(label: String, hint: String, value: Long, onSet: (Long) -> Unit) {
val selectedLabel = CAP_OPTIONS.firstOrNull { it.first == value }?.second
?: formatBytes(value)
LabeledDropdown(
label = label,
hint = hint,
selectedLabel = selectedLabel,
options = CAP_OPTIONS,
onSelect = onSet,
)
}
/** A setting's name, with one short line under it. */
@Composable
private fun RowLabel(label: String, hint: String, modifier: Modifier = Modifier) {
Column(modifier = modifier) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = hint,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun SwitchRow(label: String, hint: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
modifier = Modifier
.fillMaxWidth()
.toggleable(value = checked, role = Role.Switch, onValueChange = onCheckedChange),
verticalAlignment = Alignment.CenterVertically,
) {
RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f))
Spacer(Modifier.size(12.dp))
Switch(checked = checked, onCheckedChange = null)
}
}
/**
* Label on the left, button-with-current-value on the right that opens
* a DropdownMenu of [options]. Used by the cap selectors.
* Label and hint on the left, button-with-current-value on the right that
* opens a DropdownMenu of [options]. Used by the cap selectors.
*/
@Composable
private fun <T> LabeledDropdown(
label: String,
hint: String,
selectedLabel: String,
options: List<Pair<T, String>>,
onSelect: (T) -> Unit,
@@ -135,12 +159,7 @@ private fun <T> LabeledDropdown(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
RowLabel(label = label, hint = hint, modifier = Modifier.weight(1f))
OutlinedButton(onClick = { expanded = true }) {
Text(selectedLabel)
DropdownMenu(
@@ -227,7 +246,7 @@ private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit
)
}
private fun formatBytes(n: Long): String {
internal fun formatBytes(n: Long): String {
if (n <= 0L) return "0 B"
return when {
n < KIB -> "$n B"
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.player.PlayerFactory
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -18,25 +19,25 @@ import javax.inject.Inject
data class StorageUiState(
val settings: CacheSettings = CacheSettings.DEFAULT,
val usedBytes: Long = 0L,
val likedBytes: Long = 0L,
val recentBytes: Long = 0L,
val isSyncing: Boolean = false,
val isClearing: Boolean = false,
)
/**
* Backs the Storage section of the Settings screen. Exposes the
* persisted CacheSettings + the current SimpleCache usage, plus
* actions for "Sync now" and "Clear cache".
* persisted CacheSettings and how much of each audio budget (Liked
* songs, Recently played) is in use, plus "Sync now" and "Clear cache".
*
* Settings changes go through AuthStore.setCacheSettings (write-
* through to Room). The cap settings only take effect on next app
* launch because SimpleCache is constructed once per process; the
* UI surfaces that caveat (next commit).
* through to Room), and the evictor applies a new cap at once.
*/
@HiltViewModel
class StorageViewModel @Inject constructor(
private val authStore: AuthStore,
private val playerFactory: PlayerFactory,
private val cacheEvictor: TwoBucketCacheEvictor,
private val syncController: SyncController,
) : ViewModel() {
@@ -47,9 +48,10 @@ class StorageViewModel @Inject constructor(
viewModelScope.launch {
authStore.cacheSettings.collect { s ->
internal.update { it.copy(settings = s) }
// A smaller cap trims the cache, so re-read the usage.
refreshUsage()
}
}
refreshUsage()
}
fun setLikedCapBytes(bytes: Long) {
@@ -108,13 +110,11 @@ class StorageViewModel @Inject constructor(
}
}
/** Recomputes used bytes from SimpleCache. Cheap; just reads a long. */
/** Re-reads each budget's usage from the evictor. Cheap: two longs. */
private fun refreshUsage() {
viewModelScope.launch {
val used = withContext(Dispatchers.IO) {
runCatching { playerFactory.simpleCache.cacheSpace }.getOrDefault(0L)
}
internal.update { it.copy(usedBytes = used) }
val usage = withContext(Dispatchers.IO) { cacheEvictor.usage() }
internal.update { it.copy(likedBytes = usage.likedBytes, recentBytes = usage.recentBytes) }
}
}
@@ -21,7 +21,6 @@ import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
import com.fabledsword.minstrel.player.ui.MiniPlayer
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
import com.fabledsword.minstrel.update.ui.UpdateBanner
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
@@ -63,7 +62,7 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
* [onExpandPlayer] is the navigate-to-NowPlaying callback passed
* through to MiniPlayer's tap-handler. No NavHostController parameter
* because nothing inside the shell currently navigates - if a banner
* (UpdateBanner / VersionTooOldBanner / ConnectionErrorBanner) ever
* (VersionTooOldBanner / ConnectionErrorBanner) ever
* grows tap-to-navigate, plumb a NavHostController in at that point.
*/
@Composable
@@ -97,12 +96,10 @@ fun ShellScaffold(
// the status bar — no double inset, and no banner drawing under it.
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
// Banner slot. VersionTooOldBanner appears when the server's
// min_client_version exceeds our build; UpdateBanner softly
// nudges an available (newer-than-installed) APK; and
// ConnectionErrorBanner shows when the device loses usable
// internet.
// min_client_version exceeds our build, and ConnectionErrorBanner
// shows when the device loses usable internet. A newer app is not
// a banner: Home tells of it in a snackbar (UpdateNotices).
VersionTooOldBanner()
UpdateBanner()
ConnectionErrorBanner()
// The window is edge-to-edge, so something has to own the
// bottom navigation-bar inset, and the shell claims it once
@@ -63,34 +63,3 @@ object FabledSwordFlatTokens {
val radiusLg = 12.dp
val radiusXl = 16.dp
}
/**
* Deprecated alias (dark surfaces + flat) that binds to dark-mode values
* regardless of system mode. Nothing reads it; UI code reads
* `LocalFabledSwordTheme.current`.
*/
@Deprecated(
"Use LocalFabledSwordTheme.current — see FabledSwordTheme.kt",
level = DeprecationLevel.WARNING,
)
object FabledSwordTokens {
val obsidian = FabledSwordDarkTokens.obsidian
val iron = FabledSwordDarkTokens.iron
val slate = FabledSwordDarkTokens.slate
val pewter = FabledSwordDarkTokens.pewter
val parchment = FabledSwordDarkTokens.parchment
val vellum = FabledSwordDarkTokens.vellum
val ash = FabledSwordDarkTokens.ash
val moss = FabledSwordFlatTokens.moss
val bronze = FabledSwordFlatTokens.bronze
val oxblood = FabledSwordFlatTokens.oxblood
val warning = FabledSwordFlatTokens.warning
val error = FabledSwordFlatTokens.error
val info = FabledSwordFlatTokens.info
val accent = FabledSwordFlatTokens.accent
val onAction = FabledSwordFlatTokens.onAction
val radiusSm = FabledSwordFlatTokens.radiusSm
val radiusMd = FabledSwordFlatTokens.radiusMd
val radiusLg = FabledSwordFlatTokens.radiusLg
val radiusXl = FabledSwordFlatTokens.radiusXl
}
@@ -6,24 +6,13 @@ import android.net.Uri
import android.os.Build
import android.provider.Settings
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
private const val APK_FILENAME = "minstrel-update.apk"
/**
* Downloads the server-bundled APK and installs it over ourselves.
*
* The download goes through the shared [OkHttpClient] so it inherits
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). The APK lands in the
* cache dir; [SelfUpdateSession] streams it from there into a
* Installs a downloaded APK over ourselves. [UpdateRepository] brings it
* down; [SelfUpdateSession] streams it from there into a
* [android.content.pm.PackageInstaller] session.
*
* On Android O+ the user must have granted "install unknown apps"
@@ -34,26 +23,8 @@ private const val APK_FILENAME = "minstrel-update.apk"
@Singleton
class ApkInstaller @Inject constructor(
@ApplicationContext private val context: Context,
private val okHttpClient: OkHttpClient,
private val session: SelfUpdateSession,
) {
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("http://placeholder.invalid$apkUrl")
.build()
okHttpClient.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Download failed: HTTP ${response.code}")
}
val body = response.body ?: throw IOException("Empty download body")
val file = File(context.cacheDir, APK_FILENAME)
body.byteStream().use { input ->
file.outputStream().use { output -> input.copyTo(output) }
}
file
}
}
/** True when the system will accept an install intent without a permission detour. */
fun canInstall(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
@@ -0,0 +1,196 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UpdateInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class UpdateUiState(
/** Null when there is nothing newer to offer. */
val offer: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE,
val progress: Float = 0f,
val problem: String? = null,
/** Asking the server which app it offers. */
val checking: Boolean = false,
/** Why the last ask got no answer, when it did not. */
val unchecked: String? = null,
/** The offer is already downloaded and checked: Install is all that is left. */
val ready: Boolean = false,
)
/** A newer build to tell the listener of, and whether it is already here to install. */
data class UpdateNews(val offer: UpdateInfo, val ready: Boolean)
/**
* The one record of whether a newer app is on offer and where installing it
* has got to, shared by Home (which tells of it) and the About card (in
* Settings, and in the sheet Home's news opens). A singleton because each
* screen gets its own view model, and an install started from one must
* show on the other.
*
* Only the platform's answer retires an offer: declined, failed or refused,
* it stays on screen. A newer build is fetched ahead on Wi-Fi
* ([UpdateScheduler]), so the listener is usually told it is ready and
* Install is one tap. Home is told at most once a day for each build
* ([UpdateTold]).
*/
@Singleton
class AppUpdates @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val updates: UpdateRepository,
private val installer: ApkInstaller,
private val told: UpdateTold,
private val fetching: UpdateScheduler,
) {
private val mutable = MutableStateFlow(UpdateUiState())
val state: StateFlow<UpdateUiState> = mutable.asStateFlow()
private val telling = Channel<UpdateNews>(Channel.BUFFERED)
/** A newer build to tell of, once; a screen that shows it calls [tellHere] first. */
val news: Flow<UpdateNews> = telling.receiveAsFlow()
private var asking: Job? = null
private var tells = false
/**
* Asks the server which app it offers: when Home or Settings opens, on a
* pull, and every few minutes while one is up. A newer one being fetched
* or installed is never taken off the screen by a later answer.
*/
fun check(): Job {
asking?.takeIf { it.isActive }?.let { return it }
return scope.launch { refresh() }.also { asking = it }
}
private suspend fun refresh() {
mutable.update { it.copy(checking = true) }
val offer = updates.check()
mutable.update { now ->
val busy = now.stage != InstallStage.IDLE
when (offer) {
is Offer.Newer -> now.copy(offer = if (busy) now.offer else offer.info, unchecked = null)
Offer.Current -> now.copy(offer = if (busy) now.offer else null, unchecked = null)
is Offer.Problem -> now.copy(unchecked = offer.message)
}.copy(checking = false)
}
fetchOrTidy(offer)
lookForReady()
tellIfDue()
}
/** A newer build is fetched ahead; once none is offered, a package kept for one is only space. */
private suspend fun fetchOrTidy(offer: Offer) {
if (mutable.value.stage != InstallStage.IDLE) return
when (offer) {
is Offer.Newer -> fetching.fetchAhead()
Offer.Current -> updates.tidy()
is Offer.Problem -> Unit
}
}
/** Home is up: what is on offer now is told, if it is due. */
fun tellHere() {
tells = true
tellIfDue()
}
private fun tellIfDue() {
val now = mutable.value
val offer = now.offer
if (!tells || offer == null || now.stage != InstallStage.IDLE) return
val key = buildKey(offer.code, offer.version)
val at = System.currentTimeMillis()
if (dueToSay(key, told.build(), told.at(), at)) {
told.told(key, at)
telling.trySend(UpdateNews(offer, now.ready))
}
}
/** Whether the offer has been downloaded meanwhile, perhaps in the background. */
fun lookForReady() {
mutable.update { it.copy(ready = it.offer?.let(updates::ready) != null) }
}
fun mayInstall(): Boolean = installer.canInstall()
/** Sends the listener to allow installs; the update carries on when they come back. */
fun askToInstall() {
mutable.update { it.copy(problem = ALLOW) }
installer.requestInstallPermission()
}
/** Came back from allowing installs without allowing them. */
fun refused() {
mutable.update { it.copy(problem = REFUSED) }
}
/**
* Downloads the offer (or takes the one already here) and installs it.
* Runs in the app's scope, so leaving the screen does not stop it.
*/
fun fetchAndInstall() {
val offer = mutable.value.offer ?: return
if (mutable.value.stage != InstallStage.IDLE) return
mutable.update { it.copy(stage = InstallStage.DOWNLOADING, progress = 0f, problem = null) }
scope.launch {
val download = updates.download(offer) { done -> mutable.update { it.copy(progress = done) } }
when (download) {
is Download.Problem -> mutable.update {
it.copy(stage = InstallStage.IDLE, problem = download.message)
}
is Download.Ready -> {
mutable.update { it.copy(ready = true) }
install(download.apk)
}
}
}
}
private suspend fun install(apk: File) {
mutable.update { it.copy(stage = InstallStage.INSTALLING) }
// On a successful silent install this never returns: the process is replaced.
val outcome = installer.install(apk)
mutable.update { it.copy(stage = InstallStage.IDLE, problem = outcome.message()) }
}
private companion object {
const val ALLOW = "Allow Minstrel to install apps. The update carries on when you come back."
const val REFUSED =
"Minstrel is still not allowed to install apps, so nothing was installed. Tap Update to try again."
}
}
/**
* What coming back to the app does to an update the listener asked for and
* was sent to allow installs for.
*/
internal enum class AfterAsking {
/** Allowed now: carry on with the update they already asked for. */
FETCH,
/** Came back without allowing it. The offer stays; they are told. */
REFUSED,
/** Nothing asked, something already under way, or the offer not known yet. */
WAIT,
}
internal fun afterAsking(asked: Boolean, mayInstall: Boolean, offered: Boolean, busy: Boolean): AfterAsking =
when {
!asked || busy || !offered -> AfterAsking.WAIT
mayInstall -> AfterAsking.FETCH
else -> AfterAsking.REFUSED
}
@@ -26,37 +26,22 @@ sealed interface InstallOutcome {
}
/**
* Where an install has got to, for the two surfaces that show it: the shell's
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
* Settings About card.
* Where an install has got to, for the card that shows it (in Settings and
* in the sheet Home's update news opens).
*
* DOWNLOADING and INSTALLING are deliberately distinct: the install awaits
* the platform's verdict and can sit on a confirm dialog indefinitely, so
* one combined state would leave the UI claiming "Downloading…" through it.
* A failure is not a stage: it returns to IDLE with the reason beside it,
* and the offer stays so the listener can try again.
*/
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
/** True while an install is underway and a second tap should do nothing. */
fun InstallStage.isBusy(): Boolean =
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
/**
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
* rather than ERROR — the user chose it, so presenting it as a failure would
* be a lie with a red tint.
*/
fun InstallOutcome.stage(): InstallStage = when (this) {
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
is InstallOutcome.Failed -> InstallStage.ERROR
}
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING }
/**
* User-facing copy for an outcome; null when there is nothing worth saying.
*
* Lives beside the outcome rather than in either UI package because two
* separate screens surface the same verdicts and must not drift — the same
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
* outside the UI layer.
* Lives beside the outcome rather than in the UI package, the same reasoning
* that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] outside the
* UI layer.
*/
fun InstallOutcome.message(): String? = when (this) {
InstallOutcome.Installed -> null
@@ -1,78 +0,0 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.UpdateInfo
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
/**
* Drives the shell's soft "update available" banner. Polls
* `/api/client/version` at launch + every 24h and, when the bundled
* APK outranks this build — by ordering key where the server reports one,
* by name otherwise — exposes its [UpdateInfo] so
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
* install.
*
* Dismissals are kept in memory and keyed by version, so dismissing
* one release's banner still lets a later release re-surface — and a
* restart re-shows it, which is acceptable nudging. Server 404 /
* network errors stay silent. Constructed at
* launch via the construct-the-singleton trick in `MinstrelApplication`.
*
* A debug build never polls: it is signed with a local debug key, so the
* server's release-signed APK could never install over it.
*/
@Singleton
class UpdateBannerController @Inject constructor(
@ApplicationScope private val scope: CoroutineScope,
private val repository: UpdateRepository,
) {
private val latest = MutableStateFlow<UpdateInfo?>(null)
private val dismissed = MutableStateFlow<Set<String>>(emptySet())
/** The available update, or null when none / dismissed / unreachable. */
val available: StateFlow<UpdateInfo?> =
combine(latest, dismissed) { info, seen ->
info?.takeIf { it.version !in seen }
}.stateIn(scope, SharingStarted.Eagerly, null)
init {
if (!BuildConfig.DEBUG) startPolling()
}
private fun startPolling() {
scope.launch {
while (true) {
runOnce()
delay(POLL_INTERVAL_MS)
}
}
}
fun dismiss(version: String) {
dismissed.value = dismissed.value + version
}
private suspend fun runOnce() {
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
latest.value = info.takeIf {
isUpdateAvailable(
serverCode = it.code,
serverName = it.version,
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
)
}
}
}
@@ -0,0 +1,84 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Brings the newer app down before the listener is asked to install it, so
* Install is one tap rather than a wait. It never installs and shows
* nothing: whether to update stays the listener's.
*
* It asks the server again rather than being told what to fetch. WorkManager
* may run it hours after it was asked for, when Wi-Fi comes, and by then the
* server may offer another build.
*/
@HiltWorker
class UpdateFetchWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val updates: UpdateRepository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = when (val offer = updates.check()) {
// A server out of reach is a phone away from home; the next check asks for this again.
is Offer.Problem -> Result.success()
Offer.Current -> {
updates.tidy()
Result.success()
}
is Offer.Newer -> when (updates.download(offer.info) {}) {
is Download.Ready -> Result.success()
// A dropped connection is worth another go after WorkManager's
// backoff, which also outlasts the server's one-download-a-minute
// limit; a few, not forever.
is Download.Problem -> if (runAttemptCount < TRIES) Result.retry() else Result.success()
}
}
private companion object {
const val TRIES = 3
}
}
/**
* Asks the platform to fetch the newer app when it is free to: on an
* unmetered network, with the battery and storage not low. Nothing turns
* this onto mobile data, because the listener's Update still downloads at
* once over whatever network there is; this only saves the wait.
*
* Kept rather than replaced, so a check every few minutes never restarts a
* download already under way.
*/
@Singleton
class UpdateScheduler @Inject constructor(@ApplicationContext private val context: Context) {
fun fetchAhead() {
WorkManager.getInstance(context).enqueueUniqueWork(
FETCHING,
ExistingWorkPolicy.KEEP,
OneTimeWorkRequestBuilder<UpdateFetchWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build(),
)
.build(),
)
}
private companion object {
const val FETCHING = "update-fetch"
}
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.update.api.ClientVersionApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import retrofit2.Retrofit
import java.io.File
import java.io.IOException
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UpdateModule {
/**
* The download goes through the shared [OkHttpClient] so it inherits the
* auth cookie and the BaseUrlInterceptor host rewrite (apkUrl is
* server-relative, e.g. `/api/client/apk`). A refusal, such as the
* server's one-download-a-minute limit, reads as an IOException, which
* the repository reports and the fetch worker retries after a backoff.
*/
@Provides
@Singleton
fun provideUpdateRepository(
@ApplicationContext context: Context,
retrofit: Retrofit,
okHttpClient: OkHttpClient,
): UpdateRepository = UpdateRepository(
api = retrofit.create(ClientVersionApi::class.java),
apk = ApkSource { apkUrl ->
withContext(Dispatchers.IO) {
val request = Request.Builder().url("http://placeholder.invalid$apkUrl").build()
val response = okHttpClient.newCall(request).execute()
if (!response.isSuccessful) {
response.close()
throw IOException("Download failed: HTTP ${response.code}")
}
response.body ?: throw IOException("Empty download body")
}
},
dir = File(context.cacheDir, "update"),
installedCode = BuildConfig.VERSION_CODE.toLong(),
installedName = BuildConfig.VERSION_NAME,
offers = !BuildConfig.DEBUG,
legacyApk = File(context.cacheDir, "minstrel-update.apk"),
)
}
@@ -1,21 +1,158 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
import com.fabledsword.minstrel.update.api.ClientVersionApi
import retrofit2.Retrofit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import okhttp3.ResponseBody
import retrofit2.HttpException
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection.HTTP_NOT_FOUND
import java.security.MessageDigest
import kotlin.coroutines.cancellation.CancellationException
/** What asking the server for its app came to. */
sealed interface Offer {
/** The server's app is another build than this one, and installs over it. */
data class Newer(val info: UpdateInfo) : Offer
/** This is the server's app, or the same app built again, or the server has none to offer. */
data object Current : Offer
/** The server could not be asked. [message] says why, for the About card. */
data class Problem(val message: String) : Offer
}
/** What a download came to. */
sealed interface Download {
data class Ready(val apk: File) : Download
data class Problem(val message: String) : Download
}
/** Opens the server's APK for reading. The caller closes the body. */
fun interface ApkSource {
suspend fun open(apkUrl: String): ResponseBody
}
/**
* Thin facade over `/api/client/version`, used by the About card's
* Check for updates button and by [UpdateBannerController].
* Whether the server offers a newer app than this one, and fetching it.
*
* One way a package arrives, whether [UpdateFetchWorker] brought it down
* ahead on Wi-Fi or the listener pressed Update: [download]. The two take
* turns, and each looks first for what the other already brought, so
* nothing is downloaded twice. A package only gets its build's name once
* it checked out against the size and digest the server gave, so a file at
* that name is that build, whole ([ready]).
*
* A debug build is never offered one ([offers] false): it is signed with the
* debug key, so the platform would refuse the release build over it anyway.
*/
@Singleton
class UpdateRepository @Inject constructor(retrofit: Retrofit) {
private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java)
class UpdateRepository(
private val api: ClientVersionApi,
private val apk: ApkSource,
private val dir: File,
private val installedCode: Long,
private val installedName: String,
private val offers: Boolean,
// A download left by older builds of the app, which nothing else removes.
private val legacyApk: File? = null,
) {
private val turns = Mutex()
suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain()
suspend fun check(): Offer {
if (!offers) return Offer.Current
return try {
val info = api.getVersion().toDomain()
if (isUpdateAvailable(info.code, info.version, installedCode, installedName)) {
Offer.Newer(info)
} else {
Offer.Current
}
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
// 404 is the server saying it bundles no app: nothing to offer.
if (e.code() == HTTP_NOT_FOUND) Offer.Current else Offer.Problem(ErrorCopy.fromThrowable(e))
} catch (e: IOException) {
Offer.Problem(ErrorCopy.fromThrowable(e))
} catch (e: SerializationException) {
Offer.Problem(ErrorCopy.fromThrowable(e))
}
}
/** The package for [info], when it is already here and whole. */
fun ready(info: UpdateInfo): File? =
waiting(info).takeIf { it.isFile && it.length() == info.sizeBytes }
/**
* The server's app, checked against the size and digest the server gave,
* so what is handed to the installer is the build that was offered, all
* of it. [progress] is called with 0 to 1 as it arrives.
*/
suspend fun download(info: UpdateInfo, progress: (Float) -> Unit): Download =
turns.withLock { ready(info)?.let { Download.Ready(it) } ?: fetch(info, progress) }
/** Drops every package kept here: none is on offer any more. */
suspend fun tidy() {
turns.withLock {
withContext(Dispatchers.IO) {
dir.listFiles()?.forEach { it.delete() }
legacyApk?.delete()
}
}
}
/**
* Writes to a part file and gives it the build's name only once it
* checks out, so a download cut off partway is never taken for one that
* finished. Any package for another build goes then.
*/
private suspend fun fetch(info: UpdateInfo, progress: (Float) -> Unit): Download {
val target = waiting(info)
val part = File(dir, target.name + PART)
return try {
val digest = withContext(Dispatchers.IO) {
save(apk.open(info.apkUrl), part, info.sizeBytes, progress)
}
withContext(Dispatchers.IO) { settle(info, part, target, digest) }
} catch (e: CancellationException) {
part.delete()
throw e
} catch (e: IOException) {
part.delete()
Download.Problem("Couldn't download the update: ${ErrorCopy.fromThrowable(e)}")
}
}
private fun settle(info: UpdateInfo, part: File, target: File, digest: String): Download {
// A server that predates the digest leaves the size as the only check.
val whole = part.length() == info.sizeBytes &&
(info.sha256.isEmpty() || digest.equals(info.sha256, ignoreCase = true))
return if (whole && part.renameTo(target)) {
dir.listFiles()?.filter { it != target }?.forEach { it.delete() }
Download.Ready(target)
} else {
part.delete()
Download.Problem("The download did not match the app the server offered. Try again.")
}
}
/** Where the package for [info] waits: named for its build. */
private fun waiting(info: UpdateInfo): File =
File(dir, "minstrel-${info.code ?: info.version.replace(UNSAFE, "_")}.apk")
private companion object {
const val PART = ".part"
val UNSAFE = Regex("[^A-Za-z0-9._-]")
}
}
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
@@ -24,20 +161,55 @@ private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
channel = channel,
apkUrl = apkUrl,
sizeBytes = sizeBytes,
sha256 = sha256,
)
/** Writes [body] to [file], reporting progress against [size], and returns the SHA-256 written, in hex. */
internal fun save(body: ResponseBody, file: File, size: Long, progress: (Float) -> Unit): String {
file.parentFile?.mkdirs()
val digest = MessageDigest.getInstance("SHA-256")
// Closing the stream closes the body.
body.byteStream().use { input ->
file.outputStream().use { output -> copy(input, output, digest) { written -> report(written, size, progress) } }
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun copy(input: InputStream, output: OutputStream, digest: MessageDigest, written: (Long) -> Unit) {
val buffer = ByteArray(BUFFER_BYTES)
var total = 0L
var read = input.read(buffer)
while (read >= 0) {
output.write(buffer, 0, read)
digest.update(buffer, 0, read)
total += read
written(total)
read = input.read(buffer)
}
}
private fun report(written: Long, size: Long, progress: (Float) -> Unit) {
if (size > 0) progress((written.toFloat() / size).coerceAtMost(1f))
}
private const val BUFFER_BYTES = 64 * 1024
/**
* True when [server] should be offered over the installed build.
* True when [serverName] / [serverCode] should be offered over the installed
* build. Two questions, each asked of the value that answers it.
*
* **Decide on the ordering key whenever the server sends one.** That is the
* same value Android's package installer compares, so an offer made this way
* implies an install the platform will actually accept. Comparing NAMES
* while the platform installs by `versionCode` keeps nothing consistent
* between the two orderings: it can offer a build Android then refuses as
* a downgrade, or stay quiet about one it would take.
* **May it be installed over this one** is the ordering key's, whenever the
* server sends one: it is the same value Android's package installer
* compares, so an offer made on it is an install the platform will take.
*
* Name comparison survives only as the fallback for a server that predates
* the field. A null code means "this server cannot tell me" — never "zero" —
* **Is it another app at all** is the name's, compared for equality only.
* Every server image builds the app again at a later minute, so the code
* rises with a CI re-run or a change outside the shipped set while the app
* itself does not; asking only the first question offered the installed app
* to itself after each one.
*
* Name ordering survives only as the fallback for a server that predates the
* code. A null code means "this server cannot tell me" — never "zero" —
* because treating absent as zero would rank every such server as infinitely
* old and offer its build to everyone, forever.
*/
@@ -48,7 +220,7 @@ fun isUpdateAvailable(
installedName: String,
): Boolean =
if (serverCode != null) {
serverCode > installedCode
serverCode > installedCode && serverName != installedName
} else {
isVersionNewer(serverName, installedName)
}
@@ -0,0 +1,58 @@
package com.fabledsword.minstrel.update.data
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* The build Home last told the listener of, and when: on disk, so an app
* Android reopened does not say the same news again.
*/
@Singleton
class UpdateTold @Inject constructor(@ApplicationContext context: Context) {
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun build(): String = prefs.getString(BUILD, "").orEmpty()
fun at(): Long = prefs.getLong(AT, 0L)
fun told(build: String, at: Long) {
prefs.edit().putString(BUILD, build).putLong(AT, at).apply()
}
private companion object {
const val PREFS_NAME = "update_told"
const val BUILD = "build"
const val AT = "at"
}
}
/**
* The key a build is told under: its code, or its name from a server that
* sends no code.
*/
internal fun buildKey(code: Long?, version: String): String = code?.toString() ?: version
/**
* Whether a build on offer is said again: a build not yet told of is news at
* once; the same one again a day later, until it is installed. A clock that
* went backwards says it rather than hiding an update for a day.
*/
internal fun dueToSay(build: String, toldBuild: String, toldAt: Long, now: Long): Boolean {
val since = now - toldAt
return build != toldBuild || since < 0 || since >= SAY_AGAIN_AFTER_MS
}
/** What Home's snackbar says of an offer; its button opens the sheet. */
internal fun toldWords(version: String, ready: Boolean): String =
if (ready) {
"Version $version of the app is downloaded and ready."
} else {
"Version $version of the app is on your server."
}
/** The snackbar's button: Install when there is nothing left to download. */
internal fun toldAction(ready: Boolean): String = if (ready) "Install" else "Update"
internal const val SAY_AGAIN_AFTER_MS = 24L * 60 * 60 * 1000
@@ -0,0 +1,110 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.settings.ui.formatBytes
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateUiState
/**
* The app's own version, the newer one when the server has it, and a check
* that is always there. Settings shows it as its About card, and the sheet
* Home's update news opens holds the very same card, so there is one place
* the app installs from. Back from allowing installs, the update the
* listener asked for carries on.
*/
@Composable
fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) {
LifecycleResumeEffect(model) {
model.resumed()
onPauseOrDispose {}
}
val state by model.state.collectAsStateWithLifecycle()
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "About",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "Installed: Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val offer = state.offer
if (offer != null) {
Offered(offer, state, model::update)
} else {
Text(
text = versionWords(state, selfUpdates = !BuildConfig.DEBUG),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
state.problem?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
}
TextButton(
onClick = model::check,
enabled = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE,
) { Text(if (state.checking) "Checking…" else "Check for updates") }
}
}
}
/** The newer build, and the one button that fetches and installs it. */
@Composable
private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) {
Text(offeredWords(offer, state.ready), style = MaterialTheme.typography.bodyMedium)
when (state.stage) {
InstallStage.IDLE -> Button(onClick = update, modifier = Modifier.fillMaxWidth()) {
Text(if (state.ready) "Install" else "Update")
}
InstallStage.DOWNLOADING ->
LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
InstallStage.INSTALLING -> Text("Installing…", style = MaterialTheme.typography.bodyMedium)
}
}
/** Whether this app is the newest, as far as the server has said. */
internal fun versionWords(state: UpdateUiState, selfUpdates: Boolean = true): String = when {
// Its own package and the debug key: the server's app would install beside it, never over it.
!selfUpdates -> "Debug build: updates install from Android Studio."
state.checking -> "Checking for a newer version…"
state.unchecked != null -> "Couldn't check for a newer version: ${state.unchecked}"
else -> "This is the newest version."
}
/** The newer build, and whether it is already downloaded. */
internal fun offeredWords(offer: UpdateInfo, ready: Boolean): String =
if (ready) {
"A new version of the app is downloaded and ready: ${describe(offer)}"
} else {
"A new version of the app is on your server: ${describe(offer)}"
}
/** The name, the channel when it is the test line, and the size. */
internal fun describe(offer: UpdateInfo): String {
val channel = if (offer.channel == "dev") " (test build)" else ""
return "${offer.version}$channel · ${formatBytes(offer.sizeBytes)}"
}
@@ -1,149 +0,0 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.composables.icons.lucide.Download
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.X
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.isBusy
/**
* Shell-level soft banner that nudges an available update. Renders
* nothing until [UpdateBannerController] surfaces a newer bundled APK
* (and the user hasn't dismissed that version): a download glyph,
* "Update Minstrel · {version} available", an Install button
* (download → system installer), and a dismiss X. Uses an understated
* surface tone, not the alarming error color reserved for
* `VersionTooOldBanner`.
*
* [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller]
* exposes no byte progress, so the downloading state is an
* indeterminate bar.
*/
@Composable
fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val info = state.info
AnimatedVisibility(
visible = info != null,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
) {
// info can flip to null during the exit animation; hold the last
// non-null so the collapsing banner keeps its content.
val shown = info ?: return@AnimatedVisibility
BannerBody(
info = shown,
stage = state.stage,
message = state.message,
onInstall = { viewModel.install(shown) },
onDismiss = { viewModel.dismiss(shown.version) },
)
}
}
@Composable
private fun BannerBody(
info: UpdateInfo,
stage: InstallStage,
message: String?,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
) {
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
if (stage.isBusy()) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = MaterialTheme.colorScheme.primary,
)
}
if (message != null) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
}
@Composable
private fun BannerRow(
info: UpdateInfo,
stage: InstallStage,
onInstall: () -> Unit,
onDismiss: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
imageVector = Lucide.Download,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = if (stage == InstallStage.ERROR) {
"Update failed"
} else {
"Update Minstrel · ${info.version} available"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
// Downloading and installing are separate words because they're now
// separate waits — the install half suspends on the platform, which
// may be sitting on a confirm dialog.
Text(
when (stage) {
InstallStage.DOWNLOADING -> "Downloading…"
InstallStage.INSTALLING -> "Installing…"
else -> "Install"
},
)
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Lucide.X,
contentDescription = "Dismiss",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -1,92 +0,0 @@
package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.models.UpdateInfo
import com.fabledsword.minstrel.update.data.ApkInstaller
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateBannerController
import com.fabledsword.minstrel.update.data.isBusy
import com.fabledsword.minstrel.update.data.message
import com.fabledsword.minstrel.update.data.stage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
data class UpdateBannerUiState(
val info: UpdateInfo? = null,
val stage: InstallStage = InstallStage.IDLE,
val message: String? = null,
)
/**
* Thin VM over [UpdateBannerController]. Surfaces the available update
* and runs the download → install handoff via [ApkInstaller], mirroring
* the About card's flow (route to "install unknown apps" settings first
* when the permission is missing).
*/
@HiltViewModel
class UpdateBannerViewModel @Inject constructor(
private val controller: UpdateBannerController,
private val installer: ApkInstaller,
) : ViewModel() {
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
val uiState: StateFlow<UpdateBannerUiState> =
combine(controller.available, installState) { info, install ->
UpdateBannerUiState(info = info, stage = install.stage, message = install.message)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
initialValue = UpdateBannerUiState(),
)
fun dismiss(version: String) = controller.dismiss(version)
fun install(info: UpdateInfo) {
if (installState.value.stage.isBusy()) return
if (!installer.canInstall()) {
installer.requestInstallPermission()
installState.value = InstallSnapshot(
InstallStage.IDLE,
"Allow installs for Minstrel, then tap Install again.",
)
return
}
viewModelScope.launch {
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
val apk = download(info.apkUrl)
if (apk != null) {
// Await the platform's verdict rather than firing an intent and
// assuming it worked. On a silent install this suspends until
// the process is replaced, so the line below is only reached
// when the install did NOT simply succeed.
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
val outcome = installer.install(apk)
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
}
}
}
private suspend fun download(apkUrl: String): File? =
runCatching { installer.downloadApk(apkUrl) }
.onFailure { e ->
installState.value = InstallSnapshot(
InstallStage.ERROR,
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
)
}
.getOrNull()
}
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
@@ -0,0 +1,88 @@
package com.fabledsword.minstrel.update.ui
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.fabledsword.minstrel.update.data.toldAction
import com.fabledsword.minstrel.update.data.toldWords
import kotlinx.coroutines.delay
/** How often a screen on show asks the server about a newer app by itself. */
private const val CHECK_EVERY_MS = 5 * 60 * 1000L
/**
* A newer app, told on Home (after Librarian's and Gallery's update tray): a
* snackbar at the foot of the screen, never a dialog, and its button opens a
* sheet holding the very card Settings shows, so there is one place the app
* installs from. Each build is told at most once a day. The sheet stays open
* through a trip to allow installs, which is where the card carries on from.
*/
@Composable
fun BoxScope.UpdateNotices(model: UpdateViewModel) {
val notices = remember { SnackbarHostState() }
var updating by rememberSaveable { mutableStateOf(false) }
CheckWhileShown(model)
LaunchedEffect(model) {
model.tellHere()
model.news.collect { news ->
val tapped = notices.showSnackbar(
message = toldWords(news.offer.version, news.ready),
actionLabel = toldAction(news.ready),
withDismissAction = true,
duration = SnackbarDuration.Long,
)
if (tapped == SnackbarResult.ActionPerformed) updating = true
}
}
SnackbarHost(notices, Modifier.align(Alignment.BottomCenter).padding(12.dp))
if (updating) UpdateSheet(model) { updating = false }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UpdateSheet(model: UpdateViewModel, dismiss: () -> Unit) {
ModalBottomSheet(onDismissRequest = dismiss) {
AppVersionCard(
model,
Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
)
}
}
/**
* Asks the server about a newer app when the screen opens, as soon as the
* app comes back to the front, and every [CHECK_EVERY_MS] while it is on
* screen. A screen in the background never asks.
*/
@Composable
fun CheckWhileShown(model: UpdateViewModel) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(model, lifecycle) {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
while (true) {
model.check()
delay(CHECK_EVERY_MS)
}
}
}
}
@@ -0,0 +1,86 @@
package com.fabledsword.minstrel.update.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.update.data.AfterAsking
import com.fabledsword.minstrel.update.data.AppUpdates
import com.fabledsword.minstrel.update.data.InstallStage
import com.fabledsword.minstrel.update.data.UpdateNews
import com.fabledsword.minstrel.update.data.UpdateUiState
import com.fabledsword.minstrel.update.data.afterAsking
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A screen's handle on [AppUpdates]. What it adds is the one thing that
* belongs to the screen: that the listener pressed Update and was sent to
* allow installs. That is kept in [saved], so it outlives the process
* Android may restart from the settings page, and the update carries on
* when they come back ([resumed]) instead of asking them to tap again.
*/
@HiltViewModel
class UpdateViewModel @Inject constructor(
private val updates: AppUpdates,
private val saved: SavedStateHandle,
) : ViewModel() {
val state: StateFlow<UpdateUiState> = updates.state
val news: Flow<UpdateNews> = updates.news
/** Asks the server again; once the answer is in, an update asked for before a restart carries on. */
fun check() {
viewModelScope.launch {
updates.check().join()
carryOn()
}
}
fun tellHere() = updates.tellHere()
/** The app is in front again, perhaps back from allowing installs. */
fun resumed() {
updates.lookForReady()
carryOn()
}
fun update() {
val now = state.value
when {
now.offer == null || now.stage != InstallStage.IDLE -> Unit
!updates.mayInstall() -> {
saved[ASKED] = true
updates.askToInstall()
}
else -> updates.fetchAndInstall()
}
}
private fun carryOn() {
val now = state.value
val next = afterAsking(
asked = saved.get<Boolean>(ASKED) == true,
mayInstall = updates.mayInstall(),
offered = now.offer != null,
busy = now.stage != InstallStage.IDLE,
)
when (next) {
AfterAsking.WAIT -> Unit
AfterAsking.REFUSED -> {
saved[ASKED] = false
updates.refused()
}
AfterAsking.FETCH -> {
saved[ASKED] = false
updates.fetchAndInstall()
}
}
}
private companion object {
/** The listener asked for the update and was sent to allow installs. */
const val ASKED = "update.asked_to_allow"
}
}
@@ -0,0 +1,155 @@
package com.fabledsword.minstrel.cache.audiocache
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The two audio-cache budgets: Liked songs and Recently played. A track
* that is liked and also played counts against Liked only, and each budget
* drops its own least-recently-used audio first.
*/
class BucketLedgerTest {
private data class Span(val key: String, val position: Long, val length: Long, val touched: Long)
private fun ledger(likedCap: Long = 0L, recentCap: Long = 0L) = BucketLedger<Span>(
keyOf = { it.key },
lengthOf = { it.length },
lruOrder = compareBy<Span> { it.touched }.thenBy { it.key }.thenBy { it.position },
).apply {
likedCapBytes = likedCap
recentCapBytes = recentCap
}
/** Drop spans the way the evictor does, until both budgets fit. */
private fun BucketLedger<Span>.drain(): List<Span> {
val dropped = mutableListOf<Span>()
while (true) {
val span = nextEviction() ?: return dropped
remove(span)
dropped += span
}
}
@Test
fun `a liked track's bytes count against Liked only`() {
val l = ledger()
l.setLiked(setOf("liked"))
l.add(Span("liked", 0, 300, touched = 1))
l.add(Span("other", 0, 200, touched = 2))
assertEquals(300, l.bytesIn(CacheBucket.LIKED))
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
}
@Test
fun `filling Recently played drops its oldest audio and never liked audio`() {
val l = ledger(likedCap = 1_000, recentCap = 250)
l.setLiked(setOf("liked"))
val liked = Span("liked", 0, 100, touched = 0)
val oldest = Span("a", 0, 100, touched = 1)
l.add(liked)
l.add(oldest)
l.add(Span("b", 0, 100, touched = 2))
l.add(Span("c", 0, 100, touched = 3))
assertEquals(listOf(oldest), l.drain())
assertEquals(200, l.bytesIn(CacheBucket.RECENT))
assertEquals(100, l.bytesIn(CacheBucket.LIKED))
}
@Test
fun `filling Liked drops its least recently played liked audio`() {
val l = ledger(likedCap = 150, recentCap = 1_000)
l.setLiked(setOf("x", "y"))
val older = Span("x", 0, 100, touched = 1)
l.add(older)
l.add(Span("y", 0, 100, touched = 2))
l.add(Span("recent", 0, 500, touched = 0))
assertEquals(listOf(older), l.drain())
assertEquals(500, l.bytesIn(CacheBucket.RECENT))
}
@Test
fun `liking a cached track moves its bytes into Liked`() {
val l = ledger(likedCap = 1_000, recentCap = 150)
l.setLiked(emptySet())
l.add(Span("t", 0, 100, touched = 1))
l.add(Span("t", 100, 50, touched = 2))
l.add(Span("u", 0, 100, touched = 3))
assertEquals(250, l.bytesIn(CacheBucket.RECENT))
l.setLiked(setOf("t"))
assertEquals(150, l.bytesIn(CacheBucket.LIKED))
assertEquals(100, l.bytesIn(CacheBucket.RECENT))
assertEquals(emptyList(), l.drain())
}
@Test
fun `unliking moves the bytes to Recently played, which then trims`() {
val l = ledger(likedCap = 1_000, recentCap = 150)
l.setLiked(setOf("t"))
val unliked = Span("t", 0, 100, touched = 1)
l.add(unliked)
l.add(Span("u", 0, 100, touched = 2))
l.setLiked(emptySet())
assertEquals(listOf(unliked), l.drain())
assertEquals(0, l.bytesIn(CacheBucket.LIKED))
}
@Test
fun `shrinking a cap trims at once`() {
val l = ledger(recentCap = 1_000)
l.setLiked(emptySet())
l.add(Span("a", 0, 100, touched = 1))
l.add(Span("b", 0, 100, touched = 2))
assertEquals(emptyList(), l.drain())
l.recentCapBytes = 100
assertEquals(listOf("a"), l.drain().map { it.key })
}
@Test
fun `a cap of 0 never evicts`() {
val l = ledger(likedCap = 0, recentCap = 0)
l.setLiked(setOf("liked"))
repeat(50) { l.add(Span("k$it", 0, 1_000_000, touched = it.toLong())) }
l.add(Span("liked", 0, 1_000_000, touched = 99))
assertNull(l.nextEviction())
}
@Test
fun `nothing is evicted before the liked set is known`() {
val l = ledger(likedCap = 100, recentCap = 100)
l.add(Span("maybe-liked", 0, 500, touched = 1))
assertNull(l.nextEviction())
}
@Test
fun `a write about to start makes room in its own bucket`() {
val l = ledger(likedCap = 1_000, recentCap = 200)
l.setLiked(setOf("liked"))
l.add(Span("a", 0, 150, touched = 1))
assertNull(l.nextEviction(pendingKey = "liked", pendingBytes = 100))
assertEquals("a", l.nextEviction(pendingKey = "new", pendingBytes = 100)?.key)
}
@Test
fun `Liked room is what the Liked cap has left`() {
val l = ledger(likedCap = 500)
l.setLiked(setOf("x"))
l.add(Span("x", 0, 200, touched = 1))
assertEquals(300, l.likedRoomBytes())
l.likedCapBytes = 0
assertEquals(Long.MAX_VALUE, l.likedRoomBytes())
}
}
@@ -0,0 +1,65 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.player.output.upnp.UpnpRoute
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The pieces that let a renderer without a queue be driven from ours: which
* renderers get which mode, and mapping the URI it reports back to a queue
* position.
*/
class SingleTrackQueueTest {
private fun t(id: String) = TrackRef(id = id, title = "", albumId = "", artistId = "")
private fun route(manufacturer: String, zgt: Boolean = false) = UpnpRoute(
id = "uuid:x",
name = "Room",
manufacturer = manufacturer,
modelName = "",
avTransportControlUrl = "http://192.168.0.2:1400/AVTransport/Control".toHttpUrl(),
renderingControlUrl = null,
zoneGroupTopologyControlUrl = if (zgt) "http://192.168.0.2:1400/ZGT/Control".toHttpUrl() else null,
)
@Test
fun `a Sonos is driven through its native queue`() {
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("Sonos, Inc.").isSonos))
assertEquals(QueueMode.NATIVE_QUEUE, QueueMode.forRoute(route("", zgt = true).isSonos))
}
@Test
fun `any other renderer is driven one track at a time`() {
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("Yamaha Corporation").isSonos))
assertEquals(QueueMode.SINGLE_TRACK, QueueMode.forRoute(route("").isSonos))
}
@Test
fun `the track id is read from a stream URL, leveled or not`() {
assertEquals("abc", streamUriTrackId("http://h:8080/api/tracks/abc/stream.flac?t=tok"))
assertEquals("abc", streamUriTrackId("http://h/api/tracks/abc/leveled.flac?t=tok"))
}
@Test
fun `a URL that is not ours has no track id`() {
assertNull(streamUriTrackId("x-rincon-queue:RINCON_ABC#0"))
assertNull(streamUriTrackId(""))
assertNull(streamUriTrackId("http://h/api/tracks/"))
}
@Test
fun `a track queued twice resolves to the copy nearest the cursor`() {
val queue = listOf(t("a"), t("b"), t("a"), t("c"), t("a"))
assertEquals(2, nearestIndexOf(queue, "a", preferNear = 3))
assertEquals(0, nearestIndexOf(queue, "a", preferNear = 0))
}
@Test
fun `a track not in the queue has no index`() {
assertNull(nearestIndexOf(listOf(t("a")), "z", preferNear = 0))
}
}
@@ -0,0 +1,68 @@
package com.fabledsword.minstrel.player.output
import com.fabledsword.minstrel.player.output.upnp.TransportState
import org.junit.jupiter.api.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* A one-track renderer says STOPPED both when a track plays out and when it
* stops partway; only the first should start the next track.
*/
class TrackEndDetectorTest {
private val d = TrackEndDetector()
private fun poll(state: TransportState, positionMs: Long, durationMs: Long = 200_000L, known: Long = 0L) =
d.onPoll(state, positionMs, durationMs, known)
@Test
fun `a stop just after the last playing reading near the end is an ending`() {
poll(TransportState.PLAYING, 198_000L)
assertTrue(poll(TransportState.STOPPED, 0L))
}
@Test
fun `a stop partway through is not an ending`() {
poll(TransportState.PLAYING, 90_000L)
assertFalse(poll(TransportState.STOPPED, 90_000L))
}
@Test
fun `the stop a renderer passes through while loading a track is not an ending`() {
// Short track, never seen playing yet: without the playing guard,
// position 0 would already be within the slack of the end.
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 3_000L))
}
@Test
fun `no media present after a track plays out is an ending`() {
poll(TransportState.PLAYING, 199_500L)
assertTrue(poll(TransportState.NO_MEDIA, 0L, durationMs = 0L))
}
@Test
fun `a pause near the end is not an ending`() {
poll(TransportState.PLAYING, 199_000L)
assertFalse(poll(TransportState.PAUSED, 199_000L))
}
@Test
fun `the library duration stands in for a renderer that reports none`() {
poll(TransportState.PLAYING, 178_000L, durationMs = 0L, known = 180_000L)
assertTrue(poll(TransportState.STOPPED, 0L, durationMs = 0L, known = 180_000L))
}
@Test
fun `with no duration from anywhere nothing is judged an ending`() {
poll(TransportState.PLAYING, 5_000L, durationMs = 0L)
assertFalse(poll(TransportState.STOPPED, 0L, durationMs = 0L))
}
@Test
fun `reset forgets the track that was playing`() {
poll(TransportState.PLAYING, 199_000L)
d.reset()
assertFalse(poll(TransportState.STOPPED, 0L))
}
}
@@ -21,7 +21,7 @@ class AVTransportClientTest {
server = MockWebServer()
server.start()
val controlUrl = server.url("/MediaRenderer/AVTransport/Control")
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl)
client = AVTransportClient(SoapClient(OkHttpClient()), controlUrl, sonos = true)
}
@AfterEach
@@ -229,6 +229,45 @@ class AVTransportClientTest {
assertEquals(0, client.getMediaInfo().nrTracks)
}
@Test
fun `setNextAVTransportURI sends NextURI with its metadata`() = runTest {
server.enqueue(emptyResponse("SetNextAVTransportURI"))
client.setNextAVTransportURI(
uri = "http://example.com/api/tracks/t2/stream.flac",
mime = "audio/flac",
title = "Next",
)
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("SetNextAVTransportURI")) { "body: $body" }
assertTrue(body.contains("<NextURI>http://example.com/api/tracks/t2/stream.flac</NextURI>")) {
"body: $body"
}
assertTrue(body.contains("<NextURIMetaData>")) { "body: $body" }
}
@Test
fun `a Sonos gets the Rincon metadata it requires`() = runTest {
server.enqueue(emptyResponse("SetAVTransportURI"))
client.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
val body = server.takeRequest().body.readUtf8()
assertTrue(body.contains("RINCON_AssociatedZPUDN")) { "body: $body" }
}
@Test
fun `a generic renderer gets plain DIDL that declares the stream seekable`() = runTest {
val generic = AVTransportClient(
SoapClient(OkHttpClient()),
server.url("/MediaRenderer/AVTransport/Control"),
sonos = false,
)
server.enqueue(emptyResponse("SetAVTransportURI"))
generic.setAVTransportURIWithMetadata("http://x/a.flac", "audio/flac", "Song")
val body = server.takeRequest().body.readUtf8()
assertFalse(body.contains("RINCON")) { "body: $body" }
assertFalse(body.contains("rinconnetworks")) { "body: $body" }
assertTrue(body.contains("audio/flac:DLNA.ORG_OP=01")) { "body: $body" }
}
private fun emptyResponse(action: String): MockResponse = MockResponse().setBody(
"""<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
@@ -0,0 +1,28 @@
package com.fabledsword.minstrel.update.data
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
/** Back from the "install unknown apps" page, an update that was asked for carries on. */
class AfterAskingTest {
@Test
fun `allowed now, the update carries on`() {
assertEquals(AfterAsking.FETCH, afterAsking(asked = true, mayInstall = true, offered = true, busy = false))
}
@Test
fun `back without allowing it, the listener is told and the offer stays`() {
assertEquals(AfterAsking.REFUSED, afterAsking(asked = true, mayInstall = false, offered = true, busy = false))
}
@Test
fun `nothing asked, nothing happens`() {
assertEquals(AfterAsking.WAIT, afterAsking(asked = false, mayInstall = true, offered = true, busy = false))
}
@Test
fun `an offer not known yet, or an install under way, waits`() {
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = false, busy = false))
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = true, busy = true))
}
}
@@ -0,0 +1,120 @@
package com.fabledsword.minstrel.update.data
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
import com.fabledsword.minstrel.update.api.ClientVersionApi
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import retrofit2.HttpException
import retrofit2.Response
import java.io.File
import java.io.IOException
import java.security.MessageDigest
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The fetch worker and the listener's Update both download through here; a
* package only counts as ready once it is the whole build that was offered.
*/
class UpdateRepositoryTest {
@TempDir
lateinit var dir: File
private val apkBytes = "the newer app".toByteArray()
private val sha = MessageDigest.getInstance("SHA-256").digest(apkBytes).joinToString("") { "%02x".format(it) }
private fun wire(code: Long? = 200L, version: String = "2026.10.26.1200", sha256: String = sha) = UpdateInfoWire(
version = version,
code = code,
sizeBytes = apkBytes.size.toLong(),
sha256 = sha256,
)
private var downloads = 0
private fun repo(
version: () -> UpdateInfoWire = { wire() },
body: () -> ByteArray = { apkBytes },
offers: Boolean = true,
) = UpdateRepository(
api = object : ClientVersionApi {
override suspend fun getVersion(): UpdateInfoWire = version()
},
apk = ApkSource {
downloads += 1
body().toResponseBody()
},
dir = dir,
installedCode = 100L,
installedName = "2026.10.20.0900",
offers = offers,
)
@Test
fun `a newer build is offered`() = runTest {
assertIs<Offer.Newer>(repo().check())
}
@Test
fun `the same app built again at a later minute is not offered`() = runTest {
assertEquals(Offer.Current, repo(version = { wire(version = "2026.10.20.0900") }).check())
}
@Test
fun `a server with no app to offer is not a problem`() = runTest {
val none = repo(version = { throw HttpException(Response.error<Any>(404, "".toResponseBody())) })
assertEquals(Offer.Current, none.check())
}
@Test
fun `a server out of reach is a problem, not an all-clear`() = runTest {
assertIs<Offer.Problem>(repo(version = { throw IOException("unreachable") }).check())
}
@Test
fun `a debug build is never offered one`() = runTest {
assertEquals(Offer.Current, repo(offers = false).check())
}
@Test
fun `a build already fetched is installed from disk without a second download`() = runTest {
val r = repo()
val info = (r.check() as Offer.Newer).info
assertIs<Download.Ready>(r.download(info) {})
assertNotNull(r.ready(info))
val again = r.download(info) {}
assertIs<Download.Ready>(again)
assertEquals(1, downloads)
}
@Test
fun `a download cut off partway is not taken for the build`() = runTest {
val r = repo(body = { apkBytes.copyOf(5) })
val info = (r.check() as Offer.Newer).info
assertIs<Download.Problem>(r.download(info) {})
assertNull(r.ready(info))
assertTrue(dir.listFiles().orEmpty().isEmpty(), "the part file is removed")
}
@Test
fun `a download that does not match the digest is not taken for the build`() = runTest {
val r = repo(body = { "the other app".toByteArray() }) // same length, other bytes
val info = (r.check() as Offer.Newer).info
assertIs<Download.Problem>(r.download(info) {})
assertNull(r.ready(info))
}
@Test
fun `tidy drops a package kept for a build no longer offered`() = runTest {
val r = repo()
val info = (r.check() as Offer.Newer).info
r.download(info) {}
r.tidy()
assertNull(r.ready(info))
}
}
@@ -0,0 +1,53 @@
package com.fabledsword.minstrel.update.data
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Home tells of each build at most once a day, and says whether it is ready. */
class UpdateToldTest {
private val day = SAY_AGAIN_AFTER_MS
private val t0 = 1_000_000_000L
@Test
fun `a build not yet told of is told at once`() {
assertTrue(dueToSay("3523900", toldBuild = "3523847", toldAt = t0, now = t0 + 1))
assertTrue(dueToSay("3523900", toldBuild = "", toldAt = 0L, now = t0))
}
@Test
fun `the same build is not told again within a day`() {
assertFalse(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day - 1))
}
@Test
fun `the same build is told again the day after`() {
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day))
}
@Test
fun `a clock set back tells rather than hiding the update`() {
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 - 1))
}
@Test
fun `a build is keyed by its code, or by its name from a server with none`() {
assertEquals("3523900", buildKey(3523900L, "2026.09.10.1432"))
assertEquals("2026.09.10.1432", buildKey(null, "2026.09.10.1432"))
}
@Test
fun `the snackbar says whether the update is already here`() {
assertEquals(
"Version 2026.10.26.1200 of the app is on your server.",
toldWords("2026.10.26.1200", ready = false),
)
assertEquals(
"Version 2026.10.26.1200 of the app is downloaded and ready.",
toldWords("2026.10.26.1200", ready = true),
)
assertEquals("Update", toldAction(ready = false))
assertEquals("Install", toldAction(ready = true))
}
}
@@ -62,6 +62,21 @@ class UpdateVersioningTest {
)
}
/**
* Every server image builds the app again at a later minute, so a CI
* re-run, or a change outside the shipped set, raises the code while the
* app is the same one. The name says so, and it is not offered.
*/
@Test
fun `the same app built again at a later minute is not offered`() {
assertFalse(
isUpdateAvailable(
serverCode = 3523900, serverName = "2026.09.10.1432",
installedCode = 3523847, installedName = "2026.09.10.1432",
),
)
}
@Test
fun `falls back to the name when the server reports no ordering key`() {
assertTrue(
+41 -11
View File
@@ -3,11 +3,17 @@
# Derives the three values a build is stamped with, and the tag that names it.
#
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED,
# or SHIPPED_APK with --apk)
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
# tag=v<name> what a release of this commit must be called
#
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
# Usage: ci/version.sh [--apk] [<commit-ish>] (default HEAD)
#
# --apk names the Android app from android/ alone. The app compares this
# name with the one it is running, and a different name is what
# offers an update — so a name that moved for a server-only commit
# would offer every phone a rebuild of the app it already has.
#
# This exists as a script rather than inline workflow YAML for one reason:
# release.yml only runs on `main` and on tags, so anything living inside it is
@@ -30,6 +36,12 @@
set -euo pipefail
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
apk=false
if [ "${1:-}" = "--apk" ]; then
apk=true
shift
fi
readonly REF="${1:-HEAD}"
# Both clocks are overridable so a test can pin them. Nothing but tests should
@@ -48,14 +60,17 @@ readonly REF="${1:-HEAD}"
# wrongly included → version moves when nothing shipped. Cosmetic noise in
# a string nobody sorts.
#
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
# is shorter than it looks like it should be. The server image ships cmd/,
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
# android/. Neither ships the other's sources — but excluding android/ here
# would stop an Android-only commit from moving the APK's OWN version, which
# is the dangerous direction. So this is the union: exclude only what ships in
# NEITHER, and accept that an Android commit also nudges the server's reported
# version. Over-inclusion across the two, which is the harmless direction.
# THIS LIST NAMES THE SERVER IMAGE, and it is shorter than it looks like it
# should be. The image ships cmd/, internal/, shared/, web/,
# config.example.yaml and client/ — and client/ holds the APK, so android/
# stays in: an Android-only commit changes what the image carries. Exclude
# only what ships in neither artifact; over-inclusion is the harmless
# direction here.
#
# The APK itself is named from SHIPPED_APK (below), where over-inclusion is
# NOT harmless: the app offers an update whenever the name differs from its
# own, so a name moved by a server-only commit offers every phone a rebuild
# of the app it already has.
#
# The family's other repos (roundtable / roundtable-android) each keep a
# tighter list because they are separate repos with one artifact apiece. Do
@@ -100,9 +115,24 @@ readonly SHIPPED=(
':!web/vitest.setup.ts'
)
# What the APK is built from: android/ and nothing else (the Gradle build
# reads no path outside it). Still a denylist within it, for the same reason
# as above — a new directory under android/ counts until named here.
readonly SHIPPED_APK=(
android
':!android/app/src/test' # JVM unit tests
':!android/*.md'
)
if [ "${apk}" = true ]; then
shipped=("${SHIPPED_APK[@]}")
else
shipped=("${SHIPPED[@]}")
fi
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
if [ -z "${commit_epoch}" ]; then
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${shipped[@]}")"
# Loudly, on purpose. A silent fallback here is the landmine this whole
# script exists to avoid: a plausible-looking version that is quietly wrong,
# on a green run. Realistically this means a shallow clone (no commit in
+50
View File
@@ -17,8 +17,11 @@ package api
// client treats 404 as "no update channel available."
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"path/filepath"
@@ -62,6 +65,41 @@ var (
clientAPKLastDownloadMu sync.Mutex
)
// clientAPKSum is the bundled APK's sha256, kept until the file changes
// under it (another size or modification time), so a version check every
// few minutes from every phone does not re-read tens of megabytes each time.
type clientAPKSum struct {
size int64
mod time.Time
hex string
}
var (
clientAPKSumCache clientAPKSum
clientAPKSumCacheMu sync.Mutex
)
// clientAPKDigest returns the sha256 of the APK at path, in hex.
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
clientAPKSumCacheMu.Lock()
defer clientAPKSumCacheMu.Unlock()
c := clientAPKSumCache
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
return c.hex, nil
}
f, err := os.Open(path)
if err != nil {
return "", err
}
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
return clientAPKSumCache.hex, nil
}
func testResetClientAPKRateLimit() {
clientAPKLastDownloadMu.Lock()
defer clientAPKLastDownloadMu.Unlock()
@@ -115,6 +153,11 @@ type clientVersionResponse struct {
Channel string `json:"channel,omitempty"`
APKURL string `json:"apk_url"`
SizeBytes int64 `json:"size_bytes"`
// SHA256 is worked out from the file being served, not copied from the
// sidecar, so it cannot disagree with the bytes the phone receives. The
// app downloads ahead on Wi-Fi and installs later; this is how it knows
// the file it kept is the whole build that was offered.
SHA256 string `json:"sha256"`
}
// handleClientVersion returns the bundled Android client version + a
@@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
return
}
sum, err := clientAPKDigest(apkPath, stat)
if err != nil {
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
return
}
writeJSON(w, http.StatusOK, clientVersionResponse{
Version: strings.TrimSpace(sidecar.Name),
Code: sidecar.Code,
Channel: strings.TrimSpace(sidecar.Channel),
APKURL: "/api/client/apk",
SizeBytes: stat.Size(),
SHA256: sum,
})
}
+35
View File
@@ -2,6 +2,8 @@ package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log/slog"
@@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
if resp.SizeBytes != size {
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
}
want := sha256.Sum256([]byte("fake apk content"))
if resp.SHA256 != hex.EncodeToString(want[:]) {
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
}
}
// The digest is cached by size and modification time; a new APK dropped in
// place must be answered with its own digest, not the old one's.
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
writeClientAssets(t, `{"name":"a","code":1}`)
first := decodeClientVersion(t, getClientVersion(t))
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
apk := filepath.Join(dir, clientAPKFilename)
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
t.Fatal(err)
}
second := decodeClientVersion(t, getClientVersion(t))
want := sha256.Sum256([]byte("another, longer build"))
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
}
}
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
t.Helper()
if rr.Code != http.StatusOK {
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
}
var resp clientVersionResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
return resp
}
// A release published before ordering keys were recorded has a name and
+12 -9
View File
@@ -20,14 +20,13 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Action is the discriminator stored in audit_log.action. New values
// added in U2/U3 + future tasks; declare them here so callers don't
// stringly-type and so a future audit-search UI has a single source
// of truth for the vocabulary.
// Action is the discriminator stored in audit_log.action. Every value is
// declared here, so callers never stringly-type an action and the vocabulary
// has one source.
type Action string
const (
// U1
// Accounts and invites.
ActionRegister Action = "register"
ActionPromoteAdmin Action = "promote_admin"
ActionDemoteAdmin Action = "demote_admin"
@@ -35,15 +34,13 @@ const (
ActionInviteRedeem Action = "invite_redeem"
ActionInviteRevoke Action = "invite_revoke"
// U2 (declared early; callers in U1 don't use them yet, but
// having them here means U2's diff is purely additive on the
// caller side, not also touching this file).
// Admin user management.
ActionCreateUserAdmin Action = "create_user_admin"
ActionDeleteUser Action = "delete_user"
ActionPasswordResetAdmin Action = "password_reset_admin"
ActionAutoApproveToggle Action = "auto_approve_toggle"
// U3
// Credentials.
ActionPasswordChangeSelf Action = "password_change_self"
ActionTokenRegenerate Action = "token_regenerate"
ActionForgotPasswordInit Action = "forgot_password_initiated"
@@ -65,6 +62,12 @@ const (
ActionDuplicateDismiss Action = "duplicate_dismiss"
ActionDuplicateWrongFile Action = "duplicate_wrong_file"
// An admin removing a track: its file and row are deleted, with every
// like, play and playlist entry that hung off the row. The metadata names
// the track and its file, so the log can say what went after the row is
// gone.
ActionTrackRemove Action = "track_remove"
// Subsonic password: generated in Settings for t/s-only clients.
ActionSubsonicPasswordSet Action = "subsonic_password_set"
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
+130
View File
@@ -585,6 +585,69 @@ func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenrePa
return items, nil
}
const listAlbumsFrequentlyPlayedByUser = `-- name: ListAlbumsFrequentlyPlayedByUser :many
WITH plays AS (
SELECT track_id, count(*) AS cnt
FROM play_events
WHERE user_id = $3 AND was_skipped = false
GROUP BY track_id
), per_album AS (
SELECT t.album_id, sum(p.cnt) AS cnt
FROM plays p
JOIN tracks t ON t.id = p.track_id
WHERE t.missing_since IS NULL
GROUP BY t.album_id
)
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid
FROM per_album pa
JOIN albums ON albums.id = pa.album_id
ORDER BY pa.cnt DESC, albums.id
LIMIT $2 OFFSET $1
`
type ListAlbumsFrequentlyPlayedByUserParams struct {
Off int32
Lim int32
UserID pgtype.UUID
}
// The user's albums, most played first: an album's count is the user's
// completed plays across its tracks. Skips do not count, so skipping
// through an album does not make it a favourite. Plays of tracks whose file
// is gone do not count. Serves Subsonic getAlbumList2?type=frequent.
func (q *Queries) ListAlbumsFrequentlyPlayedByUser(ctx context.Context, arg ListAlbumsFrequentlyPlayedByUserParams) ([]Album, error) {
rows, err := q.db.Query(ctx, listAlbumsFrequentlyPlayedByUser, arg.Off, arg.Lim, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
&i.CoverArtSource,
&i.CoverArtSourcesVersion,
&i.ReleaseGroupMbid,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listAlbumsMissingMbidWithTrack = `-- name: ListAlbumsMissingMbidWithTrack :many
SELECT a.id AS album_id,
a.artist_id AS artist_id,
@@ -720,6 +783,73 @@ func (q *Queries) ListAlbumsRandom(ctx context.Context, limit int32) ([]Album, e
return items, nil
}
const listAlbumsRecentlyPlayedByUser = `-- name: ListAlbumsRecentlyPlayedByUser :many
WITH plays AS (
SELECT track_id, max(started_at) AS last_played
FROM play_events
WHERE user_id = $3
GROUP BY track_id
), per_album AS (
SELECT t.album_id, max(p.last_played) AS last_played
FROM plays p
JOIN tracks t ON t.id = p.track_id
WHERE t.missing_since IS NULL
GROUP BY t.album_id
)
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid
FROM per_album pa
JOIN albums ON albums.id = pa.album_id
ORDER BY pa.last_played DESC, albums.id
LIMIT $2 OFFSET $1
`
type ListAlbumsRecentlyPlayedByUserParams struct {
Off int32
Lim int32
UserID pgtype.UUID
}
// The user's albums, most recently played first: an album's time is its
// latest play of any of its tracks, skips included, since a skip is still
// the user reaching for that album. Plays of tracks whose file is gone do
// not count. Serves Subsonic getAlbumList2?type=recent.
//
// Aggregates play_events per track first (play_events_user_track_idx), then
// folds tracks into albums, so the join runs over distinct tracks rather
// than every play.
func (q *Queries) ListAlbumsRecentlyPlayedByUser(ctx context.Context, arg ListAlbumsRecentlyPlayedByUserParams) ([]Album, error) {
rows, err := q.db.Query(ctx, listAlbumsRecentlyPlayedByUser, arg.Off, arg.Lim, arg.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Album
for rows.Next() {
var i Album
if err := rows.Scan(
&i.ID,
&i.Title,
&i.SortTitle,
&i.ArtistID,
&i.ReleaseDate,
&i.Mbid,
&i.CoverArtPath,
&i.CreatedAt,
&i.UpdatedAt,
&i.CoverArtSource,
&i.CoverArtSourcesVersion,
&i.ReleaseGroupMbid,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentlyAddedAlbumsWithArtist = `-- name: ListRecentlyAddedAlbumsWithArtist :many
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, albums.release_group_mbid, artists.name AS artist_name
FROM albums
+50
View File
@@ -99,6 +99,56 @@ WHERE EXISTS (
ORDER BY albums.sort_title, albums.id
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
-- name: ListAlbumsRecentlyPlayedByUser :many
-- The user's albums, most recently played first: an album's time is its
-- latest play of any of its tracks, skips included, since a skip is still
-- the user reaching for that album. Plays of tracks whose file is gone do
-- not count. Serves Subsonic getAlbumList2?type=recent.
--
-- Aggregates play_events per track first (play_events_user_track_idx), then
-- folds tracks into albums, so the join runs over distinct tracks rather
-- than every play.
WITH plays AS (
SELECT track_id, max(started_at) AS last_played
FROM play_events
WHERE user_id = sqlc.arg(user_id)
GROUP BY track_id
), per_album AS (
SELECT t.album_id, max(p.last_played) AS last_played
FROM plays p
JOIN tracks t ON t.id = p.track_id
WHERE t.missing_since IS NULL
GROUP BY t.album_id
)
SELECT albums.*
FROM per_album pa
JOIN albums ON albums.id = pa.album_id
ORDER BY pa.last_played DESC, albums.id
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
-- name: ListAlbumsFrequentlyPlayedByUser :many
-- The user's albums, most played first: an album's count is the user's
-- completed plays across its tracks. Skips do not count, so skipping
-- through an album does not make it a favourite. Plays of tracks whose file
-- is gone do not count. Serves Subsonic getAlbumList2?type=frequent.
WITH plays AS (
SELECT track_id, count(*) AS cnt
FROM play_events
WHERE user_id = sqlc.arg(user_id) AND was_skipped = false
GROUP BY track_id
), per_album AS (
SELECT t.album_id, sum(p.cnt) AS cnt
FROM plays p
JOIN tracks t ON t.id = p.track_id
WHERE t.missing_since IS NULL
GROUP BY t.album_id
)
SELECT albums.*
FROM per_album pa
JOIN albums ON albums.id = pa.album_id
ORDER BY pa.cnt DESC, albums.id
LIMIT sqlc.arg(lim) OFFSET sqlc.arg(off);
-- name: SearchAlbums :many
SELECT * FROM albums
WHERE title ILIKE '%' || $1 || '%'
+107 -76
View File
@@ -272,73 +272,41 @@ func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
}
}
// The two bundling paths must stay mutually exclusive. If the rebundle step's
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
// its freshly-built APK, then overwrite it with the previous release's. The
// image would still build and the sidecar would still parse — it would just
// quietly serve stale art to the channel whose whole job is being current.
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
yaml := string(body)
// A merge to main is enough to put a new app in front of stable phones: main
// builds the signed APK from its own commit and bakes it into :latest, as dev
// does for :dev. The regression to guard is main falling back to a tag-only
// APK build, which leaves every merged app change waiting on a release while
// each build stays green.
func TestEveryBranchShipsTheAppBuiltFromItsOwnCommit(t *testing.T) {
jobs := releaseJobs(t)
i := strings.Index(yaml, "- name: Bundle latest release APK")
if i < 0 {
t.Fatal("no 'Bundle latest release APK' step")
}
step := yaml[i:]
if j := strings.Index(step, "\n - name:"); j >= 0 {
step = step[:j]
}
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
}
}
// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any
// command substitution whose grep matches NOTHING fatal at the assignment —
// pipefail reports the grep's non-zero status even though `head` succeeded —
// so the step dies before reaching the `if` written to handle the empty case.
//
// This is not hypothetical. It took down the first `main` build after the
// version rework: the newest release predated sidecar assets, its
// `.apk.version` grep matched nothing, and the step aborted instead of
// falling through to the name-only branch that exists precisely for it. The
// same latent hazard sat on the other two assignments and had simply never
// fired, because their greps always matched.
//
// Pins the property that makes every "degrades gracefully" branch in that
// step reachable at all.
func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
if err != nil {
t.Fatal(err)
}
yaml := string(body)
i := strings.Index(yaml, "- name: Bundle latest release APK")
if i < 0 {
t.Fatal("no 'Bundle latest release APK' step")
}
step := yaml[i:]
if j := strings.Index(step, "\n - name:"); j >= 0 {
step = step[:j]
}
for _, line := range strings.Split(step, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") {
continue
build := strings.Join(strings.Fields(jobs["android-release"].If), " ")
for _, ref := range []string{"refs/heads/main", "refs/heads/dev", "refs/tags/v"} {
if !strings.Contains(build, ref) {
t.Errorf("android-release does not build on %s, so that ref's image carries no app of its own\n if: %s", ref, build)
}
// Only assignments from a command substitution can abort the step.
if !strings.Contains(trimmed, `="$(`) {
continue
}
if !strings.HasSuffix(trimmed, "|| true") {
t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+
"skipping the empty-case branch below it:\n %s", trimmed)
}
image := strings.Join(strings.Fields(jobs["image-release"].If), " ")
if !strings.Contains(image, "needs.android-release.result == 'success'") || strings.Contains(image, "'skipped'") {
t.Errorf("image-release can publish without a freshly-built APK\n if: %s", image)
}
yaml := releaseYAML(t)
if strings.Contains(yaml, "releases/latest") {
t.Error("release.yml fetches the latest release; an image bundling a release's APK ships an app older than its own commit")
}
// Both bundling steps run on every ref. Sliced from image-release on,
// because release-assets has a step of the same name.
i := strings.Index(yaml, "\n image-release:")
if i < 0 {
t.Fatal("no image-release job")
}
for _, name := range []string{"Download signed APK artifact", "Stage bundled APK + version sidecar"} {
step := stepBody(t, yaml[i:], name)
if strings.Contains(step, "github.ref") {
t.Errorf("%q is gated by ref, so some ref's image ships without its APK\n%s", name, step)
}
}
}
@@ -376,7 +344,7 @@ func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
arm, _, _ := imageTagArms(t, releaseYAML(t))
if !strings.Contains(arm, "${IMAGE}:latest") {
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
t.Errorf("tag arm does not refresh :latest — the server would hand out a different APK build from the one attached to the Release\n%s", arm)
}
if strings.Contains(arm, "GITHUB_SHA") {
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
@@ -455,7 +423,7 @@ func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
}
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart")
t.Error("the image job no longer derives its version from ci/version.sh — the image's version has drifted out of test coverage")
}
}
@@ -508,10 +476,11 @@ func commitFile(t *testing.T, dir, path, epoch string) {
}
// versionIn runs ci/version.sh inside dir, reading real git rather than the
// pinned-clock override, so the PATHSPEC is what is under test.
func versionIn(t *testing.T, dir string) (string, error) {
// pinned-clock override, so the PATHSPEC is what is under test. flags go
// before the ref, as in `--apk`.
func versionIn(t *testing.T, dir string, flags ...string) (string, error) {
t.Helper()
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "HEAD")
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), append(flags, "HEAD")...)
cmd.Dir = dir
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
@@ -558,11 +527,9 @@ func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
}
}
// android/ is deliberately NOT excluded, and that is the subtle half of the
// list. It ships in no server image — but it is the APK's entire source, and
// ONE script derives the version for both artifacts. Excluding it would stop
// an Android-only commit from moving the APK's own version, which is exactly
// the silent downgrade the versioning rework exists to prevent.
// android/ is deliberately NOT excluded from the image's name: the image
// bundles the APK under client/, so an Android-only commit changes what the
// image carries, and an image that changed must not keep its old name.
func TestVersionName_AndroidSourcesCount(t *testing.T) {
dir := gitRepo(t)
commitFile(t, dir, "internal/server/thing.go", "1757443736")
@@ -573,7 +540,71 @@ func TestVersionName_AndroidSourcesCount(t *testing.T) {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("an Android commit did not move the version; the APK would ship new code under its old version name\n%s", out)
t.Errorf("an Android commit did not move the image's version; it carries a new APK under its old name\n%s", out)
}
}
// The app offers an update whenever the server's APK has a different name
// from its own, so the APK's name must move for Android commits and ONLY for
// them. A name taken from the whole repo would offer every phone a rebuild of
// the app it already has on each server-only deploy.
func TestApkName_MovesOnlyForAndroidCommits(t *testing.T) {
const (
app = "1757443736" // 2025.09.09.1848
server = "1789000920" // 2026.09.10.0042, later
again = "1789600000" // 2026.09.16.2306, later still
)
dir := gitRepo(t)
commitFile(t, dir, "android/app/src/main/Thing.kt", app)
commitFile(t, dir, "internal/server/thing.go", server)
commitFile(t, dir, "web/src/routes/page.svelte", server)
out, err := versionIn(t, dir, "--apk")
if err != nil {
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2025.09.09.1848") {
t.Errorf("a server-only commit moved the APK's name; every phone would be offered the app it already has\n%s", out)
}
// The image's name still follows the server commit, so the test above
// is not passing because the flag is ignored and everything is stale.
out, err = versionIn(t, dir)
if err != nil {
t.Fatalf("version.sh failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.10.0042") {
t.Errorf("without --apk the name should follow the server commit\n%s", out)
}
// A test-only Android commit ships nothing either.
commitFile(t, dir, "android/app/src/test/java/A.kt", again)
out, err = versionIn(t, dir, "--apk")
if err != nil {
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2025.09.09.1848") {
t.Errorf("an Android test-only commit moved the APK's name\n%s", out)
}
// ...and a real Android commit must move it, or new app code would ship
// under the name of the old and never be offered.
commitFile(t, dir, "android/app/src/main/Thing.kt", again)
out, err = versionIn(t, dir, "--apk")
if err != nil {
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
}
if !strings.Contains(out, "name=2026.09.16.2306") {
t.Errorf("an Android commit did not move the APK's name; the new app would never be offered\n%s", out)
}
}
// The APK lane must ask for the APK's name. Without the flag it would still
// build, sign and publish, and quietly go back to offering an update on every
// server deploy.
func TestReleaseWorkflow_NamesTheApkFromAndroidAlone(t *testing.T) {
step := stepBody(t, releaseYAML(t), "Compute release version")
if !strings.Contains(step, "ci/version.sh --apk HEAD") {
t.Errorf("the APK lane does not derive its version with --apk\n%s", step)
}
}
+178
View File
@@ -0,0 +1,178 @@
package subsonic
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"slices"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
// playHistoryFixture is a library of single-track albums and a listener
// whose plays rank them differently by recency and by count.
type playHistoryFixture struct {
pool *pgxpool.Pool
user dbq.User
albums map[string]dbq.Album // keyed by title
}
// seedPlayHistory builds the fixture:
//
// A: one completed play 1h ago
// B: three completed plays 5h ago
// D: five skipped plays 30m ago (recent counts it, frequent does not)
// C: played 10m ago, but its file is gone (in neither list)
// E: played many times by another user (in neither list)
func seedPlayHistory(t *testing.T) playHistoryFixture {
t.Helper()
if testing.Short() {
t.Skip("skipping in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
ctx := context.Background()
q := dbq.New(pool)
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "listener", PasswordHash: "x", ApiTokenHash: "x",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
other, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "other", PasswordHash: "x", ApiTokenHash: "y",
})
if err != nil {
t.Fatalf("create other user: %v", err)
}
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Artist", SortName: "Artist"})
if err != nil {
t.Fatalf("artist: %v", err)
}
f := playHistoryFixture{pool: pool, user: user, albums: map[string]dbq.Album{}}
tracks := map[string]dbq.Track{}
for _, title := range []string{"A", "B", "C", "D", "E"} {
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID})
if err != nil {
t.Fatalf("album %s: %v", title, err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: title, AlbumID: al.ID, ArtistID: artist.ID,
FilePath: "/tmp/albumlist-" + title + ".flac", DurationMs: 100_000,
})
if err != nil {
t.Fatalf("track %s: %v", title, err)
}
f.albums[title] = al
tracks[title] = tr
}
if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() WHERE id = $1`, tracks["C"].ID); err != nil {
t.Fatalf("mark missing: %v", err)
}
now := time.Now()
play := func(u dbq.User, title string, ago time.Duration, skipped bool, times int) {
t.Helper()
at := now.Add(-ago)
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'albumlist-test') RETURNING id`,
u.ID, at).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
for i := 0; i < times; i++ {
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, $3, $4, $5)`,
u.ID, tracks[title].ID, sessionID, at, skipped); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
}
play(user, "A", time.Hour, false, 1)
play(user, "B", 5*time.Hour, false, 3)
play(user, "D", 30*time.Minute, true, 5)
play(user, "C", 10*time.Minute, false, 4)
play(other, "E", time.Minute, false, 9)
return f
}
// albumListTitles calls getAlbumList2 as the fixture's user and returns the
// album titles in the order served.
func (f playHistoryFixture) albumListTitles(t *testing.T, query string) []string {
t.Helper()
b := &browseHandlers{pool: f.pool}
req := httptest.NewRequest(http.MethodGet, "/rest/getAlbumList2?f=json&"+query, nil)
ctx := context.WithValue(req.Context(), userCtxKey, f.user)
w := httptest.NewRecorder()
b.getAlbumList2(w, req.WithContext(ctx))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var raw struct {
Response struct {
Status string `json:"status"`
AlbumList2 AlbumList2Container `json:"albumList2"`
} `json:"subsonic-response"`
}
if err := json.Unmarshal(w.Body.Bytes(), &raw); err != nil {
t.Fatalf("decode: %v body=%s", err, w.Body.String())
}
if raw.Response.Status != "ok" {
t.Fatalf("status %q body=%s", raw.Response.Status, w.Body.String())
}
titles := make([]string, len(raw.Response.AlbumList2.Albums))
for i, a := range raw.Response.AlbumList2.Albums {
titles[i] = a.Name
}
return titles
}
func TestGetAlbumList2_RecentOrdersByLatestPlay(t *testing.T) {
f := seedPlayHistory(t)
got := f.albumListTitles(t, "type=recent")
if want := []string{"D", "A", "B"}; !slices.Equal(got, want) {
t.Errorf("recent = %v, want %v", got, want)
}
}
func TestGetAlbumList2_FrequentOrdersByCompletedPlays(t *testing.T) {
f := seedPlayHistory(t)
got := f.albumListTitles(t, "type=frequent")
if want := []string{"B", "A"}; !slices.Equal(got, want) {
t.Errorf("frequent = %v, want %v", got, want)
}
}
func TestGetAlbumList2_RecentPages(t *testing.T) {
f := seedPlayHistory(t)
got := f.albumListTitles(t, "type=recent&size=1&offset=1")
if want := []string{"A"}; !slices.Equal(got, want) {
t.Errorf("recent page = %v, want %v", got, want)
}
}
+17 -4
View File
@@ -236,8 +236,9 @@ func (b *browseHandlers) getSong(w http.ResponseWriter, r *http.Request) {
}
// getAlbumList2 returns a paged album list filtered by type. Supported types
// match the Subsonic spec § getAlbumList2. "recent" and "frequent" return an
// empty list rather than an error, so clients that poll them don't break.
// match the Subsonic spec § getAlbumList2. "recent" and "frequent" read the
// caller's own play history: most recently played first, and most played
// first.
func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query()
listType := params.Get("type")
@@ -291,8 +292,20 @@ func (b *browseHandlers) getAlbumList2(w http.ResponseWriter, r *http.Request) {
Genre: genre, Lim: int32(size), Off: int32(offset),
})
case "recent", "frequent":
// Not served from play history: empty keeps clients working.
albums = nil
user, ok := UserFromContext(r.Context())
if !ok {
WriteFail(w, r, ErrNotAuthorized, "Not authenticated")
return
}
if listType == "recent" {
albums, err = q.ListAlbumsRecentlyPlayedByUser(r.Context(), dbq.ListAlbumsRecentlyPlayedByUserParams{
UserID: user.ID, Lim: int32(size), Off: int32(offset),
})
} else {
albums, err = q.ListAlbumsFrequentlyPlayedByUser(r.Context(), dbq.ListAlbumsFrequentlyPlayedByUserParams{
UserID: user.ID, Lim: int32(size), Off: int32(offset),
})
}
default:
WriteFail(w, r, ErrGeneric, "Invalid list type: "+listType)
return
+3 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
)
@@ -26,6 +27,7 @@ import (
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, events *playevents.Writer, hops func() int) {
b := &browseHandlers{pool: pool}
m := newMediaHandlers(pool, events, logger)
u := &userHandlers{lookup: dbq.New(pool).GetUserByUsername}
r.Route("/rest", func(sub chi.Router) {
sub.Use(Middleware(pool, cfg, auth.NewLoginGuard(), hops))
register(sub, "/ping", handlePing)
@@ -46,7 +48,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, cfg Config, ev
register(sub, "/unstar", m.handleUnstar)
register(sub, "/getStarred", m.handleGetStarred)
register(sub, "/getStarred2", m.handleGetStarred2)
register(sub, "/getUser", handleGetUser)
register(sub, "/getUser", u.getUser)
// Subsonic clients expect every /rest/* miss to come back as a
// Subsonic-shaped error envelope, not chi's plain-text 404. Without
+30 -11
View File
@@ -1,9 +1,13 @@
package subsonic
import (
"context"
"encoding/xml"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -38,26 +42,41 @@ type User struct {
VideoConversionRole bool `json:"videoConversionRole" xml:"videoConversionRole,attr"`
}
// handleGetUser returns the authenticated user when no username is provided
// or when the requested username matches; admins may look up any user. Per
// spec a non-admin asking about another user gets ErrNotAuthorized.
func handleGetUser(w http.ResponseWriter, r *http.Request) {
// userHandlers serves getUser. lookup finds a user by exact username; it is
// a field so tests can answer without a database.
type userHandlers struct {
lookup func(ctx context.Context, username string) (dbq.User, error)
}
// getUser returns the authenticated user when no username is provided or
// when the requested username matches; admins may look up any user, and an
// unknown username is ErrDataNotFound. Per spec a non-admin asking about
// another user gets ErrNotAuthorized.
func (h *userHandlers) getUser(w http.ResponseWriter, r *http.Request) {
caller, ok := UserFromContext(r.Context())
if !ok {
WriteFail(w, r, ErrNotAuthorized, "Not authenticated")
return
}
requested := r.URL.Query().Get("username")
if requested != "" && requested != caller.Username && !caller.IsAdmin {
if requested == "" || requested == caller.Username {
Write(w, r, UserResponse{Envelope: NewEnvelope("ok"), User: userPayload(caller)})
return
}
if !caller.IsAdmin {
WriteFail(w, r, ErrNotAuthorized, "Not authorized to view other users")
return
}
// No per-user lookup: an admin asking for another username is answered
// with the caller's own roles.
Write(w, r, UserResponse{
Envelope: NewEnvelope("ok"),
User: userPayload(caller),
})
target, err := h.lookup(r.Context(), requested)
if errors.Is(err, pgx.ErrNoRows) {
WriteFail(w, r, ErrDataNotFound, "User not found")
return
}
if err != nil {
WriteFail(w, r, ErrGeneric, "Failed to look up user")
return
}
Write(w, r, UserResponse{Envelope: NewEnvelope("ok"), User: userPayload(target)})
}
func userPayload(u dbq.User) User {
+49 -2
View File
@@ -8,6 +8,8 @@ import (
"strings"
"testing"
"github.com/jackc/pgx/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
@@ -51,6 +53,41 @@ func TestHandleGetUser_NonAdminCannotLookupOthers(t *testing.T) {
}
}
func TestHandleGetUser_AdminLooksUpAnotherUser(t *testing.T) {
admin := dbq.User{Username: "admin", IsAdmin: true}
bob := dbq.User{Username: "bob", IsAdmin: false}
_, body := callGetUser(t, admin, "bob", bob)
if body.Resp.Status != "ok" {
t.Fatalf("status = %q, want ok (err=%+v)", body.Resp.Status, body.Resp.Error)
}
if body.Resp.User.Username != "bob" {
t.Errorf("username = %q, want bob: the answer must describe the user asked about, not the caller", body.Resp.User.Username)
}
if body.Resp.User.AdminRole || body.Resp.User.SettingsRole {
t.Errorf("bob is not an admin, but the answer carries admin/settings roles %v/%v",
body.Resp.User.AdminRole, body.Resp.User.SettingsRole)
}
}
func TestHandleGetUser_AdminLooksUpUnknownUser(t *testing.T) {
admin := dbq.User{Username: "admin", IsAdmin: true}
_, body := callGetUser(t, admin, "nobody")
if body.Resp.Status != "failed" || body.Resp.Error == nil ||
body.Resp.Error.Code != ErrDataNotFound {
t.Errorf("expected ErrDataNotFound, got status=%q err=%+v",
body.Resp.Status, body.Resp.Error)
}
}
func TestHandleGetUser_OwnUsernameNeedsNoLookup(t *testing.T) {
// No known users: a lookup would fail, so success proves none was made.
user := dbq.User{Username: "alice", IsAdmin: false}
_, body := callGetUser(t, user, "alice")
if body.Resp.Status != "ok" || body.Resp.User.Username != "alice" {
t.Errorf("status=%q username=%q, want ok/alice", body.Resp.Status, body.Resp.User.Username)
}
}
type getUserPayload struct {
Resp struct {
Status string `json:"status"`
@@ -59,8 +96,18 @@ type getUserPayload struct {
} `json:"subsonic-response"`
}
func callGetUser(t *testing.T, user dbq.User, requestedUsername string) (*httptest.ResponseRecorder, getUserPayload) {
// callGetUser runs getUser as caller, with the users in known as the only
// accounts a lookup can find.
func callGetUser(t *testing.T, user dbq.User, requestedUsername string, known ...dbq.User) (*httptest.ResponseRecorder, getUserPayload) {
t.Helper()
h := &userHandlers{lookup: func(_ context.Context, username string) (dbq.User, error) {
for _, u := range known {
if u.Username == username {
return u, nil
}
}
return dbq.User{}, pgx.ErrNoRows
}}
url := "/rest/getUser.view?f=json"
if requestedUsername != "" {
url += "&username=" + requestedUsername
@@ -68,7 +115,7 @@ func callGetUser(t *testing.T, user dbq.User, requestedUsername string) (*httpte
req := httptest.NewRequest("GET", url, nil)
req = req.WithContext(context.WithValue(req.Context(), userCtxKey, user))
w := httptest.NewRecorder()
handleGetUser(w, req)
h.getUser(w, req)
var body getUserPayload
if err := json.NewDecoder(strings.NewReader(w.Body.String())).Decode(&body); err != nil {
+29 -3
View File
@@ -13,11 +13,13 @@ import (
"fmt"
"log/slog"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
)
@@ -57,11 +59,32 @@ func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Servic
// file cannot be removed it is a *library.FileRemoveError and NOTHING was
// deleted — see library.DeleteTrackFile for why that order is the contract.
//
// adminID is accepted for the audit log but not yet written to it.
// A removal is written to the audit log with adminID as the actor. The
// track's names are read before the delete, since the row, and perhaps its
// album and artist, are gone after it.
func (s *Service) RemoveTrack(
ctx context.Context,
trackID, adminID pgtype.UUID, //nolint:revive // adminID reserved for audit-log wiring in a follow-up
ctx context.Context, trackID, adminID pgtype.UUID,
) (deletedAlbumID *pgtype.UUID, deletedArtistID *pgtype.UUID, err error) {
q := dbq.New(s.pool)
track, err := q.GetTrackByID(ctx, trackID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil, ErrNotFound
}
return nil, nil, fmt.Errorf("get track: %w", err)
}
meta := map[string]any{
"track_id": syncpkg.FormatUUID(track.ID),
"title": track.Title,
"file_path": track.FilePath,
}
if artist, err := q.GetArtistByID(ctx, track.ArtistID); err == nil {
meta["artist"] = artist.Name
}
if album, err := q.GetAlbumByID(ctx, track.AlbumID); err == nil {
meta["album"] = album.Title
}
deleted, err := library.DeleteTrackFile(ctx, s.pool, s.logger, s.dataDir, trackID)
if err != nil {
if errors.Is(err, library.ErrTrackNotFound) {
@@ -69,6 +92,9 @@ func (s *Service) RemoveTrack(
}
return nil, nil, fmt.Errorf("delete track: %w", err)
}
meta["album_deleted"] = deleted.AlbumID != nil
meta["artist_deleted"] = deleted.ArtistID != nil
audit.WriteOrLog(ctx, s.pool, s.logger, adminID, pgtype.UUID{}, audit.ActionTrackRemove, meta)
return deleted.AlbumID, deleted.ArtistID, nil
}
+41
View File
@@ -137,6 +137,47 @@ func TestRemoveTrack_LocalFile_HappyPath(t *testing.T) {
}
}
func TestRemoveTrack_WritesAuditRow(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")
if _, err := pool.Exec(context.Background(), `DELETE FROM audit_log`); err != nil {
t.Fatalf("reset audit_log: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "track.mp3")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
_, _, track := seedArtistAlbumTrack(t, pool, "Audited", "", path)
svc := NewService(pool, nil, "")
if _, _, err := svc.RemoveTrack(context.Background(), track.ID, admin.ID); err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
var actor pgtype.UUID
var title, artist, album, filePath string
var albumDeleted bool
err := pool.QueryRow(context.Background(), `
SELECT actor_id, metadata->>'title', metadata->>'artist', metadata->>'album',
metadata->>'file_path', (metadata->>'album_deleted')::boolean
FROM audit_log WHERE action = 'track_remove'`).
Scan(&actor, &title, &artist, &album, &filePath, &albumDeleted)
if err != nil {
t.Fatalf("read audit row: %v", err)
}
if actor != admin.ID {
t.Errorf("actor = %v, want the admin %v", actor, admin.ID)
}
if title != "Audited Track" || artist != "Audited Artist" || album != "Audited Album" || filePath != path {
t.Errorf("metadata = %q / %q / %q / %q", title, artist, album, filePath)
}
if !albumDeleted {
t.Errorf("album_deleted = false, want true: the track was its album's only one")
}
}
func TestRemoveTrack_FileAlreadyMissing(t *testing.T) {
pool := newPool(t)
admin := seedAdmin(t, pool, "alice")