a0aea00667
offlineProvider: a Notifier<bool> driven by a periodic /healthz probe. Offline after N=3 consecutive failed probes; recovers on the first success. Slow heartbeat when online (30s), faster when offline (10s) so recovery is noticed quickly. 5s initial delay for provider warmup; optimistic (false) until proven otherwise. Deliberately NOT coupled to connectivityProvider — subscribing to that StreamProvider eagerly mounts its 2s timeout and leaks a pending Timer through widget tests (the MutationReplayer bug). /healthz failing already covers interface-down. No .timeout() wrapper either (dio's own timeouts bound the probe) so the only Timers are the tracked initial+periodic, both cancelled via ref.onDispose — the proven smoke-safe shape. Wired in app.dart postFrame to start the poller. No UI yet; S4 gates system-playlist play + Shuffle-all on it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.4 KiB
Dart
106 lines
3.4 KiB
Dart
// Reachability-based offline marker (#427 S1).
|
|
//
|
|
// `connectivityProvider` only knows whether a network *interface* is
|
|
// up — not whether the Minstrel server is actually reachable (captive
|
|
// portals, server down, DNS, VPN-only routes all read "online"). This
|
|
// is the single source of truth other features gate on: offline =
|
|
// the server failed its /healthz probe N times in a row; recovery on
|
|
// the first success.
|
|
//
|
|
// Deliberately NOT coupled to connectivityProvider: subscribing to
|
|
// that StreamProvider eagerly mounts its 2s checkConnectivity timeout
|
|
// and leaks a pending Timer through widget tests that never reach the
|
|
// auth state (the bug fixed for MutationReplayer). /healthz failing
|
|
// already covers interface-down — it just fails the probe.
|
|
//
|
|
// Optimistic: assume online until proven offline, so a cold launch
|
|
// behaves normally and only flips after sustained unreachability.
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../api/endpoints/health.dart';
|
|
import '../library/library_providers.dart' show dioProvider;
|
|
|
|
class OfflineMonitor extends Notifier<bool> {
|
|
Timer? _initial;
|
|
Timer? _periodic;
|
|
int _consecutiveFails = 0;
|
|
|
|
/// Consecutive failed probes before we declare offline. Small
|
|
/// enough to react within ~half a minute, large enough that a
|
|
/// single dropped request doesn't flip the whole UI.
|
|
static const _threshold = 3;
|
|
|
|
/// Probe cadence. Slower when believed-online (cheap heartbeat);
|
|
/// faster when offline so recovery is noticed quickly.
|
|
static const _onlineInterval = Duration(seconds: 30);
|
|
static const _offlineInterval = Duration(seconds: 10);
|
|
|
|
/// Let auth + server-url + dio providers finish async warmup
|
|
/// before the first probe so a cold start doesn't false-positive.
|
|
static const _initialDelay = Duration(seconds: 5);
|
|
|
|
@override
|
|
bool build() {
|
|
ref.onDispose(() {
|
|
_initial?.cancel();
|
|
_periodic?.cancel();
|
|
});
|
|
_initial = Timer(_initialDelay, () {
|
|
_check();
|
|
_arm();
|
|
});
|
|
return false; // optimistic until a probe says otherwise
|
|
}
|
|
|
|
void _arm() {
|
|
_periodic?.cancel();
|
|
_periodic = Timer.periodic(
|
|
state ? _offlineInterval : _onlineInterval,
|
|
(_) => _check(),
|
|
);
|
|
}
|
|
|
|
Future<void> _check() async {
|
|
final Dio dio;
|
|
try {
|
|
// Not configured yet (no server URL) → don't flip; the app is
|
|
// still on the connect screen and "offline" is meaningless.
|
|
dio = await ref.read(dioProvider.future);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
try {
|
|
// dio's own connect/receive timeouts bound this — no extra
|
|
// Timer (which would leak in widget tests).
|
|
await HealthApi(dio).check();
|
|
_consecutiveFails = 0;
|
|
_set(false);
|
|
} catch (_) {
|
|
_consecutiveFails++;
|
|
if (_consecutiveFails >= _threshold) {
|
|
_set(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _set(bool offline) {
|
|
if (state == offline) return;
|
|
state = offline;
|
|
_arm(); // cadence follows the new state
|
|
}
|
|
|
|
/// Force an immediate probe — e.g. a user-initiated retry. Public
|
|
/// so S4's offline surfaces can offer a "try again" affordance.
|
|
Future<void> recheck() => _check();
|
|
}
|
|
|
|
/// `true` when the server is unreachable. Watch this to gate
|
|
/// online-only affordances; read it once at app start to activate
|
|
/// the poller.
|
|
final offlineProvider =
|
|
NotifierProvider<OfflineMonitor, bool>(OfflineMonitor.new);
|