From 369ed800b9f59ae25df9a91d1355724bc0eb9e20 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 16:14:42 -0400 Subject: [PATCH] fix(flutter): non-blocking version check + 1h staleness throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-start spinner was up to ~38s on slow / remote connections because VersionGate blocked the entire ShellRoute on /healthz, with the default dio's 8s connect + 30s receive timeouts. The /healthz server handler itself is fine (microsecond JSON encode); the blocker was client-side. Three issues fixed in one pass: 1. Optimistic render. VersionGate becomes a ConsumerStatefulWidget that always renders its child and just activates the version check controller on mount. The "you're too old" experience moves from a full-screen hard-block (_TooOldScreen, deleted) to a soft banner above the AppBar that lets the user keep playing cached content while they update. 2. 1h-throttled background check. New VersionCheckController (AsyncNotifier) hydrates from a secure-storage cache on boot, returning the cached result instantly. If the cache is missing or >1h old, fires a background recheck. AppLifecycleState.resumed triggers recheckIfStale so foregrounding after >1h re-checks without per-frame hammering. "Check now" button on the banner bypasses the staleness gate so dev iteration (push new APK, want to see banner clear) doesn't wait an hour. 3. Bounded health-check dio. The /healthz request uses a dedicated dio with connectTimeout: 3s + receiveTimeout: 2s rather than the default 8s / 30s. Health probes should fail fast — if the server can't ack in 5s, the user has bigger problems than a stale min_client_version and the cached value remains in effect. Cache keys live alongside the existing tz cadence cache in flutter_secure_storage (kResult + kAtMs). On any network error or parse failure, _runCheck soft-fails without bumping the timestamp, so the next staleness check will retry. VersionTooOldBanner renders in _ShellWithPlayerBar's Column above the existing UpdateBanner — the two coexist when both apply (server rejects you AND an APK is queued). Co-Authored-By: Claude Opus 4.7 (1M context) --- flutter_client/lib/shared/routing.dart | 5 + .../lib/shared/widgets/version_gate.dart | 243 ++++++++++++++---- 2 files changed, 205 insertions(+), 43 deletions(-) diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index cc29e387..ef14e7a2 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -151,6 +151,11 @@ class _ShellWithPlayerBar extends ConsumerWidget { final hasBanner = ref.watch(shouldShowUpdateBannerProvider) != null; return Column( children: [ + // VersionTooOldBanner above UpdateBanner: a server-rejects-you + // soft warning carries more user-relevant urgency than the APK + // download prompt below it. The two banners can coexist + // (server says you're too old AND a local APK is queued). + const VersionTooOldBanner(), const UpdateBanner(), Expanded( child: hasBanner diff --git a/flutter_client/lib/shared/widgets/version_gate.dart b/flutter_client/lib/shared/widgets/version_gate.dart index 6c78e5dc..e2ac1119 100644 --- a/flutter_client/lib/shared/widgets/version_gate.dart +++ b/flutter_client/lib/shared/widgets/version_gate.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -6,61 +7,217 @@ import 'package:pub_semver/pub_semver.dart'; import '../../api/client.dart'; import '../../api/endpoints/health.dart'; import '../../auth/auth_provider.dart'; +import '../../theme/theme_extension.dart'; -final _versionCheckProvider = FutureProvider<_VersionResult>((ref) async { - final url = await ref.watch(serverUrlProvider.future); - if (url == null) return _VersionResult.skipped; - final dio = ApiClient.buildDio(baseUrl: url, tokenResolver: () async => null); - final body = await HealthApi(dio).check(); - final min = body['min_client_version']; - if (min == null || min.isEmpty) return _VersionResult.skipped; - final info = await PackageInfo.fromPlatform(); - final mine = Version.parse(info.version); - final required = Version.parse(min); - return mine < required ? _VersionResult.tooOld : _VersionResult.ok; -}); +/// Result of the most recent /healthz version compatibility check. +/// +/// `skipped` means the server didn't emit a `min_client_version` field +/// (older servers, partial deploys). Treat as compatible. +enum VersionResult { ok, tooOld, skipped } -enum _VersionResult { ok, tooOld, skipped } +VersionResult _resultFromString(String? s) { + switch (s) { + case 'ok': + return VersionResult.ok; + case 'tooOld': + return VersionResult.tooOld; + default: + return VersionResult.skipped; + } +} -class VersionGate extends ConsumerWidget { +String _stringFromResult(VersionResult r) { + switch (r) { + case VersionResult.ok: + return 'ok'; + case VersionResult.tooOld: + return 'tooOld'; + case VersionResult.skipped: + return 'skipped'; + } +} + +/// Drives the version-compatibility check against /healthz. Non-blocking: +/// the controller hydrates from a 1h-throttled cache on boot, exposes +/// the current `VersionResult`, and refreshes itself in the background. +/// UI (VersionGate + _VersionTooOldBanner) reads this state to decide +/// whether to surface the "too old" banner — never blocks rendering. +/// +/// Cache keys live in flutter_secure_storage so the cadence survives +/// app restarts. 1h throttle bounds /healthz traffic; explicit +/// `recheck()` (from the banner's "Check now" button or app resume) +/// bypasses the throttle. +class VersionCheckController extends AsyncNotifier { + static const _kResult = 'version_check_result'; + static const _kAtMs = 'version_check_at_ms'; + static const _staleMs = 60 * 60 * 1000; // 1h + + @override + Future build() async { + final storage = ref.read(secureStorageProvider); + final resultStr = await storage.read(key: _kResult); + final atStr = await storage.read(key: _kAtMs); + final cached = _resultFromString(resultStr); + final at = int.tryParse(atStr ?? '') ?? 0; + final nowMs = DateTime.now().millisecondsSinceEpoch; + + // Fire background recheck on cold-start when cache is missing or stale. + // Doesn't block: build returns the cached result immediately; the + // recheck updates state asynchronously when it lands. + if (nowMs - at > _staleMs) { + Future.microtask(_runCheck); + } + return cached; + } + + /// Force a fresh check, bypassing the 1h staleness gate. Used by the + /// "Check now" banner button and (indirectly) by the AppLifecycleState + /// resume observer in VersionGate. + Future recheck() async => _runCheck(); + + /// Recheck only if the cache is older than the 1h throttle. No-op + /// when fresh. Used on app resume to avoid hammering /healthz when + /// the user is briefly switching between apps. + Future recheckIfStale() async { + final storage = ref.read(secureStorageProvider); + final atStr = await storage.read(key: _kAtMs); + final at = int.tryParse(atStr ?? '') ?? 0; + if (DateTime.now().millisecondsSinceEpoch - at > _staleMs) { + await _runCheck(); + } + } + + Future _runCheck() async { + try { + final url = await ref.read(serverUrlProvider.future); + if (url == null || url.isEmpty) return; + // Bounded timeouts for a health probe — the default + // ApiClient.buildDio dio is tuned for actual data fetches + // (8s connect + 30s receive). /healthz should resolve in + // tens of milliseconds; failing fast unblocks slow networks. + final dio = ApiClient.buildDio( + baseUrl: url, + tokenResolver: () async => null, + ); + dio.options.connectTimeout = const Duration(seconds: 3); + dio.options.receiveTimeout = const Duration(seconds: 2); + final body = await HealthApi(dio).check(); + final min = body['min_client_version']; + VersionResult result; + if (min == null || min.isEmpty) { + result = VersionResult.skipped; + } else { + final info = await PackageInfo.fromPlatform(); + final mine = Version.parse(info.version); + final required = Version.parse(min); + result = mine < required ? VersionResult.tooOld : VersionResult.ok; + } + // Persist + flip state. Storage write before state assignment so + // a hot reload immediately after won't see a fresh state with a + // stale cache. + final storage = ref.read(secureStorageProvider); + await storage.write(key: _kResult, value: _stringFromResult(result)); + await storage.write( + key: _kAtMs, + value: DateTime.now().millisecondsSinceEpoch.toString(), + ); + state = AsyncData(result); + } on DioException catch (_) { + // Network error / timeout. Keep the cached state; don't bump the + // timestamp so the next staleness check still triggers a retry. + } catch (_) { + // Other errors (PackageInfo, parsing, etc.). Same handling — + // soft-fail so the UI never sees an error state from a defensive + // background check. + } + } +} + +final versionCheckProvider = + AsyncNotifierProvider( + VersionCheckController.new, +); + +/// Wraps the shell. Non-blocking: always renders the child. Activates +/// the version check controller on mount and reruns it on app resume +/// (gated by the controller's 1h staleness throttle). +class VersionGate extends ConsumerStatefulWidget { const VersionGate({required this.child, super.key}); final Widget child; @override - Widget build(BuildContext context, WidgetRef ref) { - return ref.watch(_versionCheckProvider).when( - data: (r) => r == _VersionResult.tooOld ? const _TooOldScreen() : child, - error: (_, __) => child, // soft-fail if /healthz is unreachable; let the rest of the flow surface a real error - loading: () => const Scaffold(body: Center(child: CircularProgressIndicator())), - ); - } + ConsumerState createState() => _VersionGateState(); } -class _TooOldScreen extends StatelessWidget { - const _TooOldScreen(); +class _VersionGateState extends ConsumerState + with WidgetsBindingObserver { @override - Widget build(BuildContext context) { - return const Scaffold( - body: SafeArea( - child: Padding( - padding: EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Update required', - style: TextStyle(fontSize: 24, fontWeight: FontWeight.w500), - ), - SizedBox(height: 12), - Text( - 'This client is too old for that Minstrel server. Install the latest build to continue.', - textAlign: TextAlign.center, - ), - ], + void initState() { + super.initState(); + // Read once so the provider's build() runs; the controller + // hydrates from cache + fires a background check when stale. + ref.read(versionCheckProvider); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + // Staleness-gated: cheap when the user just briefly left the app. + // ignore: unawaited_futures + ref.read(versionCheckProvider.notifier).recheckIfStale(); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} + +/// Banner that surfaces when the server reports our client is too old. +/// Non-blocking: the rest of the app keeps working (offline-mode-style +/// — locally cached metadata + audio still play). Tap "Check now" to +/// force a re-check after installing a new build. +class VersionTooOldBanner extends ConsumerWidget { + const VersionTooOldBanner({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncResult = ref.watch(versionCheckProvider); + final result = asyncResult.asData?.value; + if (result != VersionResult.tooOld) return const SizedBox.shrink(); + final fs = Theme.of(context).extension()!; + return SafeArea( + bottom: false, + child: Container( + color: fs.error.withValues(alpha: 0.15), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + Icon(Icons.warning_amber_rounded, color: fs.error, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + "This app is older than the server requires. " + "You can keep using cached content; install an update when ready.", + style: TextStyle(color: fs.parchment, fontSize: 13), ), ), - ), + TextButton( + onPressed: () => + ref.read(versionCheckProvider.notifier).recheck(), + style: TextButton.styleFrom( + foregroundColor: fs.parchment, + minimumSize: const Size(0, 36), + padding: const EdgeInsets.symmetric(horizontal: 12), + ), + child: const Text('Check now'), + ), + ]), ), ); }