• v2026.06.05
    android / Build + lint + test (push) Successful in 3m56s
    release / Build signed APK (tag releases only) (push) Successful in 3m50s
    release / Build + push container image (push) Successful in 13s
    Stable

    bvandeusen released this 2026-06-05 13:31:28 -04:00 | 103 commits to main since this release

    First cut of v2026.06.05 (per-day mutable CalVer). Unifies the Android client's fragmented offline-detection code into one signal-driven authority, fixes the sticky offline banner, and reworks the offline playlist surface. (PR #86.)

    The problem

    Connectivity was answered by three half-systems that didn't talk to each other: ConnectivityObserver (device link), VersionCheckController (which had reachability bolted onto a version poll), and ServerHealthController (a passive derive with no triggers). Real failures — a track fast-failing, a stream read dying — were dead-ended; they never fed back. Home read the raw device link while the banner read the tri-state, so the two halves of "offline" disagreed. And recovery was bound to the 5-minute /healthz poll, so the banner stuck around long after the connection returned.

    The fix

    One authority — NetworkStatusController

    Absorbs VersionCheckController + ServerHealthController. Every consumer (banner, Home, Playlists, search, OfflineGatedDataSource, TrackRow, LocalServerHealth) now reads its single state; ConnectivityObserver is just its device-link input. A pure, unit-tested ReachabilityMachine holds the state logic.

    Signal-driven, corroborated reachability

    • Success is self-proving — a successful stream read / API 2xx snaps straight to Healthy (no waiting for the next poll).
    • Failure is ambiguous — a track fast-fail triggers a /healthz probe to arbitrate: corroborated → ServerDown; probe-ok → stay Healthy. One bad transcode no longer reads as "server down."
    • Inputs feeding the controller: device-link transitions (link-return → immediate probe), an adaptive poll (5 min healthy / ~20 s degraded), an OkHttp interceptor + the audio data source + the playback-error reporter (reportSuccess/reportFailure), and pull-to-refresh (recheck).

    Sticky banner — fixed

    Link-return and any successful op now flip to Healthy in seconds. The banner observes the controller directly (the old WhileSubscribed re-wrap is gone).

    New non-gating Unstable state

    A mild "Reconnecting…" banner during the arbitration window — it never gates playback or flips a screen into offline mode (preserves the anti-flicker intent of the /healthz hysteresis). Plus a transient "Back online" confirmation on recovery.

    Offline playlist UX

    • PlaylistRef.fullyCached via a DAO LEFT-JOIN (cached_playlist_tracks × audio_cache_index).
    • Tiles that can't be played offline (refreshable || !fullyCached) grey out but stay tappable (the detail screen shuffles whatever subset is cached).
    • The offline Playlists row leads with the Recently-played / Liked pools, orders fully-cached before greyed, and drops not-yet-generated placeholders.
    • Home and the Playlists list now derive offline from the unified controller, so they react to ServerDown (server down on Wi-Fi), not just the device link.

    New JVM unit suites run in CI: ReachabilityMachineTest, PlaylistsRepositoryCacheMergeTest, BuildPlaylistsRowTest.

    Verify on device

    • Toggle airplane mode mid-playback → the banner appears, then clears within seconds on reconnect (not minutes).
    • A single unplayable/404 track does not flip the app to offline.
    • adb logcat -s Minstrel shows NetworkStatus -> X transitions; a brief blip reads Unstable ("Reconnecting…"), not an instant ServerDown.
    • Go offline → the Playlists row leads with Recently-played / Liked; un-cached or refreshable tiles are greyed but still open into their detail.
    • On recovery, a brief "Back online" flash appears before the banner collapses.
    Downloads