Unify offline detection + offline playlist UX (NetworkStatusController) #86

Merged
bvandeusen merged 8 commits from dev into main 2026-06-05 13:27:36 -04:00
Owner

Collapses three half-systems that each answered a different connectivity question into one signal-driven authority, fixes the sticky offline banner, and reworks the offline playlist surface.

What changed

One authority. New NetworkStatusController absorbs the former VersionCheckController (/healthz poll + version parse) and ServerHealthController (tri-state derive). Every consumer — banner, Home, Playlists list, search, OfflineGatedDataSource, TrackRow, LocalServerHealth — now reads its single state. ConnectivityObserver remains only as the thin device-link input it feeds on. A pure, unit-tested ReachabilityMachine holds the state logic.

Signal-driven, corroborated. Inputs: device-link transitions (link-return → immediate probe), an adaptive /healthz poll (calm when healthy, fast when degraded), reportSuccess()/reportFailure() from an OkHttp interceptor + the audio data source + the playback-error reporter, and recheck() from pull-to-refresh.

  • Success is self-proving — a good stream/2xx snaps straight to Healthy.
  • Failure is arbitrated — a track fast-fail triggers a /healthz probe; corroborated → ServerDown, probe-ok → stay Healthy (one bad track ≠ false offline).

Sticky banner fixed. Recovery was bound to the 5-min poll; now link-return and any successful op flip to Healthy in seconds. The banner reads the controller directly (no WhileSubscribed staleness).

New non-gating Unstable state → a mild "Reconnecting…" banner during the arbitration window; never gates playback or flips a screen offline. Plus a transient "Back online" recovery flash.

Offline playlist UX. PlaylistRef.fullyCached (DAO LEFT-JOIN over cached_playlist_tracks × audio_cache_index); tiles that can't be played offline (refreshable || !fullyCached) grey out but stay tappable; the offline Playlists row leads with the Recently-played / Liked pools, orders available-before-greyed, and drops not-yet-generated placeholders. Home + Playlists-list now derive offline from the unified controller (so they react to ServerDown, not just the device link).

Tests

New JVM unit suites run in CI: ReachabilityMachineTest (state machine), PlaylistsRepositoryCacheMergeTest (fullyCached), BuildPlaylistsRowTest (offline ordering).

Test plan (on device)

  • Toggle airplane mode mid-playback → banner shows, then clears within seconds on reconnect (not minutes).
  • A single unplayable track does not flip the app to offline.
  • Offline: Playlists row leads with Recently-played/Liked; un-cached/refreshable tiles are greyed but still open.
  • "Back online" flash appears on recovery.

🤖 Generated with Claude Code

Collapses three half-systems that each answered a different connectivity question into one signal-driven authority, fixes the sticky offline banner, and reworks the offline playlist surface. ## What changed **One authority.** New `NetworkStatusController` absorbs the former `VersionCheckController` (`/healthz` poll + version parse) and `ServerHealthController` (tri-state derive). Every consumer — banner, Home, Playlists list, search, `OfflineGatedDataSource`, `TrackRow`, `LocalServerHealth` — now reads its single `state`. `ConnectivityObserver` remains only as the thin device-link input it feeds on. A pure, unit-tested `ReachabilityMachine` holds the state logic. **Signal-driven, corroborated.** Inputs: device-link transitions (link-return → immediate probe), an adaptive `/healthz` poll (calm when healthy, fast when degraded), `reportSuccess()`/`reportFailure()` from an OkHttp interceptor + the audio data source + the playback-error reporter, and `recheck()` from pull-to-refresh. - **Success is self-proving** — a good stream/2xx snaps straight to `Healthy`. - **Failure is arbitrated** — a track fast-fail triggers a `/healthz` probe; corroborated → `ServerDown`, probe-ok → stay `Healthy` (one bad track ≠ false offline). **Sticky banner fixed.** Recovery was bound to the 5-min poll; now link-return and any successful op flip to `Healthy` in seconds. The banner reads the controller directly (no `WhileSubscribed` staleness). **New non-gating `Unstable` state** → a mild "Reconnecting…" banner during the arbitration window; never gates playback or flips a screen offline. Plus a transient "Back online" recovery flash. **Offline playlist UX.** `PlaylistRef.fullyCached` (DAO LEFT-JOIN over `cached_playlist_tracks` × `audio_cache_index`); tiles that can't be played offline (`refreshable || !fullyCached`) grey out but stay tappable; the offline Playlists row leads with the Recently-played / Liked pools, orders available-before-greyed, and drops not-yet-generated placeholders. Home + Playlists-list now derive `offline` from the unified controller (so they react to `ServerDown`, not just the device link). ## Tests New JVM unit suites run in CI: `ReachabilityMachineTest` (state machine), `PlaylistsRepositoryCacheMergeTest` (`fullyCached`), `BuildPlaylistsRowTest` (offline ordering). ## Test plan (on device) - [ ] Toggle airplane mode mid-playback → banner shows, then clears within seconds on reconnect (not minutes). - [ ] A single unplayable track does not flip the app to offline. - [ ] Offline: Playlists row leads with Recently-played/Liked; un-cached/refreshable tiles are greyed but still open. - [ ] "Back online" flash appears on recovery. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bvandeusen added 8 commits 2026-06-05 13:27:28 -04:00
feat(android): pure ReachabilityMachine + 4-state ServerHealth enum
android / Build + lint + test (push) Failing after 1m16s
c78bbb7ba5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refactor(android): unify offline detection into NetworkStatusController
android / Build + lint + test (push) Successful in 3m25s
b467cb7532
Absorbs VersionCheckController (/healthz poll + version parse) and
ServerHealthController (tri-state derive) into one signal-driven authority.
Adds the non-gating Unstable state across all ServerHealth branch sites
(OfflineGatedDataSource, SearchRepository, TrackRow, banner). Repoints
MinstrelApplication, MainActivity, PlayerFactory, VersionTooOldViewModel.
Drops the now-unused nowMs params the detekt UnusedParameter rule flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OkHttp ReachabilityReportingInterceptor (Lazy to break the Hilt cycle) runs
first in the chain and reports only PLACEHOLDER_HOST (Minstrel-bound) 2xx/IO
outcomes so external artwork fetches don't read as server reachability.
OfflineGatedDataSource reports stream open success/failure; PlaybackErrorReporter
arbitrates on track failures; PullToRefreshScaffold re-probes on every pull.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The added IOException rethrow pushed open() to 3 throws (max 2). Move the
two gating throws into a private gateOnHealth() helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(android): 4-state connection banner with unstable + back-online flash
android / Build + lint + test (push) Successful in 3m27s
8b77c6be97
Banner VM collects NetworkStatusController.state directly (drops the redundant
WhileSubscribed re-wrap). Adds a mild 'Reconnecting…' treatment for the
non-gating Unstable state and a transient 'Back online' confirmation on
down→Healthy recovery (try/finally guards against a stuck flash).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(android): per-playlist fullyCached via cache-index join
android / Build + lint + test (push) Successful in 3m27s
69ccd7b25d
CachedPlaylistDao.observeCachedCounts LEFT-JOINs cached_playlist_tracks ×
audio_cache_index per playlist; PlaylistsRepository.observeAll combines it in
and stamps PlaylistRef.fullyCached (trackCount>0 && cached>=trackCount). Merge
extracted to a pure mergePlaylistsWithCache for Android-free unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat(android): grey offline-unavailable playlists, lead with cached pools
android / Build + lint + test (push) Successful in 3m28s
31d8c30dfe
Home + Playlists list derive offline from NetworkStatusController (Offline or
ServerDown, not the raw device link), fixing the consistency gap. PlaylistRef
gains unavailableOffline (refreshable || !fullyCached); PlaylistCard dims the
whole tile when greyed but stays tappable. buildPlaylistsRow offline: pools
lead, real playlists partitioned available-first, placeholders dropped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fix(android): arbitrate op-failure probe off the reducer thread
android / Build + lint + test (push) Successful in 3m33s
e185b36138
Awaiting probeOnce() inline in the OpFailure branch blocked the single-consumer
reducer for the /healthz timeout, delaying a concurrent self-proving success
from snapping back to Healthy. Launch the probe instead so recovery stays fast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bvandeusen merged commit 11466e1525 into main 2026-06-05 13:27:36 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#86