fix(android/resilience): auto-recover failed loads on reconnect #106

Merged
bvandeusen merged 2 commits from dev into main 2026-07-02 16:45:13 -04:00
Owner

Closes the "fails once, stays broken forever" behavior (issue #1245): when a screen failed to load (dead zone, server restart), nothing ever retried — and the cache-first screens masked the failure as an empty account.

What changed

Screen-level recovery (the missing wire)

  • New connectivity/Recovery.kt: NetworkStatusController.recoveries() — per-collector flow of down→Healthy transitions (StateFlow replay dropped, so an already-healthy subscribe never double-loads). Screen-level half of the idiom SyncController / MutationReplayer / DiagnosticsUploader already use.
  • Every screen ViewModel now re-runs its load on recovery: Home, Playlists, Liked, History, Hidden, Requests, Search, Discover, Album/Artist/Playlist detail, Admin Requests/Users/Quarantine/Invites. Cache-first VMs refresh unconditionally (silent Room write); direct-load VMs only from Error (no spinner flash over good content).

Failure no longer masquerades as emptiness

  • Home / Playlists / Liked track refresh failure in a refreshError StateFlow folded into their state combine; Library reads new SyncController.lastSyncError. Empty cache + failed load → "Couldn't load…" with Retry instead of "Welcome to Minstrel" / "No playlists yet". Failed refresh over a populated cache stays silent (stale content beats a full-screen error; the connection banner tells the story).

Stuck corners unstuck

  • Requests: 12s poll also retries from UiState.Error (was structurally unable to escape it — predicate required a Success state). Also covers non-transport failures (e.g. a 500) that health monitoring never sees.
  • Search: retry() bypasses the distinctUntilChanged query pipeline so re-running the same query after a transient failure works; error state now has a Retry button.
  • ArtistDetail: secondary sections (similar artists / top tracks) re-fetch on recovery instead of staying silently absent.

Retry affordance normalized (rule 24)

  • ErrorRetry gains optional title + LazyColumn wrapper so pull-to-refresh works on error states (same rationale as EmptyState); adopted on every error branch. PlaylistDetail's private one-shot ErrorBlock deleted in its favor.

Verification

  • CI green on head 7628330f (ktlint, detekt, 99 unit tests, assemble).
  • LibraryViewModelTest updated: lastSyncError stubbed with a real MutableStateFlow (a relaxed-mock flow never emits, which kept the state combine from firing).

🤖 Generated with Claude Code

https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6

Closes the "fails once, stays broken forever" behavior (issue #1245): when a screen failed to load (dead zone, server restart), nothing ever retried — and the cache-first screens masked the failure as an empty account. ## What changed **Screen-level recovery (the missing wire)** - New `connectivity/Recovery.kt`: `NetworkStatusController.recoveries()` — per-collector flow of down→Healthy transitions (StateFlow replay dropped, so an already-healthy subscribe never double-loads). Screen-level half of the idiom SyncController / MutationReplayer / DiagnosticsUploader already use. - Every screen ViewModel now re-runs its load on recovery: Home, Playlists, Liked, History, Hidden, Requests, Search, Discover, Album/Artist/Playlist detail, Admin Requests/Users/Quarantine/Invites. Cache-first VMs refresh unconditionally (silent Room write); direct-load VMs only from Error (no spinner flash over good content). **Failure no longer masquerades as emptiness** - Home / Playlists / Liked track refresh failure in a `refreshError` StateFlow folded into their state combine; Library reads new `SyncController.lastSyncError`. Empty cache + failed load → "Couldn't load…" with Retry instead of "Welcome to Minstrel" / "No playlists yet". Failed refresh over a populated cache stays silent (stale content beats a full-screen error; the connection banner tells the story). **Stuck corners unstuck** - Requests: 12s poll also retries from `UiState.Error` (was structurally unable to escape it — predicate required a Success state). Also covers non-transport failures (e.g. a 500) that health monitoring never sees. - Search: `retry()` bypasses the `distinctUntilChanged` query pipeline so re-running the same query after a transient failure works; error state now has a Retry button. - ArtistDetail: secondary sections (similar artists / top tracks) re-fetch on recovery instead of staying silently absent. **Retry affordance normalized (rule 24)** - `ErrorRetry` gains optional title + LazyColumn wrapper so pull-to-refresh works on error states (same rationale as EmptyState); adopted on every error branch. PlaylistDetail's private one-shot `ErrorBlock` deleted in its favor. ## Verification - CI green on head `7628330f` (ktlint, detekt, 99 unit tests, assemble). - `LibraryViewModelTest` updated: `lastSyncError` stubbed with a real `MutableStateFlow` (a relaxed-mock flow never emits, which kept the state combine from firing). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
bvandeusen added 2 commits 2026-07-02 16:45:07 -04:00
fix(android/resilience): auto-recover failed loads on reconnect (#1245)
android / Build + lint + test (push) Failing after 3m3s
09102dc615
Failed loads used to stay failed forever: no screen ViewModel listened
to server-health recovery, and the cache-first screens swallowed refresh
errors so a cold load against a down server looked like an empty account.

- connectivity/Recovery.kt: recoveries() — per-collector flow of
  down→Healthy transitions; the screen-level half of the idiom
  SyncController/MutationReplayer/DiagnosticsUploader already use.
- Every screen VM now re-runs its load on recovery (cache-first screens
  unconditionally; direct-load screens when sitting in Error).
- Cache-first error surfacing: Home / Playlists / Liked track refresh
  failure; Library reads SyncController.lastSyncError (new) — empty
  cache + failed refresh now renders Error-with-Retry, not welcome copy.
- Requests: 12s poll also retries from Error (was structurally unable
  to escape it — the in-flight predicate required a Success state).
- Search: retry() bypasses the distinctUntilChanged query pipeline so a
  same-text resubmit after a transient failure actually re-runs.
- ArtistDetail: secondary sections (similar artists / top tracks)
  re-fetch on recovery instead of staying silently absent.
- ErrorRetry: LazyColumn wrapper (pull-to-refresh works on error states,
  same rationale as EmptyState) + optional title; adopted on every error
  branch; PlaylistDetail's one-shot ErrorBlock removed in its favor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
test(android/library): stub SyncController.lastSyncError with a real StateFlow
android / Build + lint + test (push) Successful in 3m45s
7628330f72
The VM now combines lastSyncError into uiState; a relaxed-mock flow
never emits, so the combine never fired and the state sat at Loading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
bvandeusen merged commit d145fee35d into main 2026-07-02 16:45:13 -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#106