Compare commits

..
Author SHA1 Message Date
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
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
33 changed files with 1398 additions and 712 deletions
+1 -1
View File
@@ -452,7 +452,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
@@ -24,7 +24,6 @@ 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
@@ -155,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
@@ -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)
}
}
}
@@ -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 = "",
)
@@ -216,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,
@@ -332,11 +364,15 @@ class MinstrelForwardingPlayer(
super.seekTo(mediaItemIndex, positionMs)
when (active.queueMode) {
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) {
active.avTransport.seekToTrack(mediaItemIndex + 1)
if (positionMs > 0L) {
active.avTransport.seek(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)
}
}
}
@@ -747,7 +783,7 @@ class MinstrelForwardingPlayer(
* 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) singleTrack.knownDurationMs() else info.trackDurationMs
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.
@@ -383,23 +383,27 @@ class OutputPickerController @Inject constructor(
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
return@withLock
}
val handledIncrementally = runCatching {
// 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)
@@ -679,11 +683,11 @@ class OutputPickerController @Inject constructor(
localQueue: List<TrackRef>,
localIndex: Int,
): Boolean {
val (transport, rendering, route, queueMode) = target
val route = target.route
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
if (queueMode == QueueMode.SINGLE_TRACK) {
if (target.queueMode == QueueMode.SINGLE_TRACK) {
// The renderer holds only this track; arm the one after it.
singleTrack.adopt(transport, localQueue, plan.localIndex, plan.trackUri)
singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri)
}
Timber.w(
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
@@ -704,9 +708,9 @@ class OutputPickerController @Inject constructor(
ActiveUpnp(
routeId = route.id,
routeName = route.name,
avTransport = transport,
rendering = rendering,
queueMode = queueMode,
avTransport = target.transport,
rendering = target.rendering,
queueMode = target.queueMode,
),
)
return true
@@ -70,8 +70,10 @@ class SingleTrackLoader @Inject constructor(
* 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)
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) =
@@ -137,9 +139,8 @@ class SingleTrackLoader @Inject constructor(
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)
val known = queue[currentIndex].durationSec * MILLIS_PER_SECOND
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, known)
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) {
@@ -147,7 +148,7 @@ class SingleTrackLoader @Inject constructor(
runCatching { start(transport, next) }
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
}
PollOutcome(currentIndex + 1, advanced = advance)
PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex))
}
/**
@@ -162,10 +163,6 @@ class SingleTrackLoader @Inject constructor(
RemoteStallWatchdog.QueueState.UNKNOWN
}
/** The library's duration for the current track, for a renderer that reports none. */
fun knownDurationMs(): Long =
queue.getOrNull(currentIndex)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
if (index !in queue.indices) return false
val token = mint(index)
@@ -240,11 +237,14 @@ class SingleTrackLoader @Inject constructor(
)
private companion object {
const val MILLIS_PER_SECOND = 1_000L
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
@@ -263,3 +263,4 @@ internal fun streamUriTrackId(uri: String): String? {
}
private const val TRACKS_PATH_MARKER = "/api/tracks/"
private const val MILLIS_PER_SECOND = 1_000L
@@ -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
@@ -246,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"
@@ -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
@@ -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,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
+73 -10
View File
@@ -455,7 +455,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 +508,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 +559,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 +572,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)
}
}