import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../library/library_providers.dart' show dioProvider; import 'installer.dart'; import 'update_info.dart'; /// Tracks the bundled-server APK version vs. the installed app version. /// Polls /api/client/version on startup + every 24h. Returns null when /// no update is available (or the channel is unreachable / disabled). /// /// Server returns 404 when no APK is bundled (dev environments, pre-CI /// images) — we treat that as "no update channel" and stay silent. class ClientUpdateController extends AsyncNotifier { static const Duration _pollInterval = Duration(hours: 24); Timer? _pollTimer; @override Future build() async { ref.onDispose(() { _pollTimer?.cancel(); _pollTimer = null; }); _pollTimer ??= Timer.periodic(_pollInterval, (_) => ref.invalidateSelf()); return _check(); } Future _check() async { final dio = await ref.read(dioProvider.future); final Response> r; try { r = await dio.get>('/api/client/version'); } on DioException catch (e) { // 404 = no APK bundled; any other error = treat as silent. if (e.response?.statusCode == 404) return null; return null; } if (r.data == null) return null; final info = UpdateInfo.fromJson(r.data!); final installed = (await PackageInfo.fromPlatform()).version; if (!isVersionNewer(info.version, installed)) return null; return info; } } /// True when `serverVersion` is strictly newer than `installedVersion`. /// /// Comparison strategy: split both strings on `.`, parse each component /// as an int (non-numeric = 0), pad the shorter list with zeros, then /// compare component-wise. This handles our date-style versions /// (2026.05.10.1) which exceed the 3-part semver shape that /// `pub_semver.Version.parse` accepts, and treats "2026.05.10" as /// equal to "2026.05.10.0" rather than "different = newer". /// /// Falls back to pub_semver as a secondary attempt when the components /// look semver-like (handles pre-release suffixes, build metadata, etc). /// /// Exposed for testing; the polling logic in ClientUpdateController /// is the only production caller. bool isVersionNewer(String serverVersion, String installedVersion) { List parts(String v) => v .replaceFirst(RegExp(r'^v'), '') .split('.') .map((p) => int.tryParse(p) ?? 0) .toList(); final svr = parts(serverVersion); final ins = parts(installedVersion); // Safety net for non-numeric versions (e.g. branch-name builds like // "main" vs "dev"): if neither side parsed any non-zero component, // fall back to string inequality so an operator on a dev build // still gets the banner instead of silently matching everything. final svrAllZero = svr.every((c) => c == 0); final insAllZero = ins.every((c) => c == 0); if (svrAllZero && insAllZero) { return serverVersion.replaceFirst(RegExp(r'^v'), '') != installedVersion.replaceFirst(RegExp(r'^v'), ''); } final n = svr.length > ins.length ? svr.length : ins.length; while (svr.length < n) { svr.add(0); } while (ins.length < n) { ins.add(0); } for (var i = 0; i < n; i++) { if (svr[i] > ins[i]) return true; if (svr[i] < ins[i]) return false; } return false; } final clientUpdateProvider = AsyncNotifierProvider( ClientUpdateController.new); /// In-memory dismissed-versions set. Keyed by version string so a /// later release's banner re-appears even if the operator dismissed /// the previous one. Not persisted — restart re-shows the banner, /// which is acceptable nudging for v1. class _DismissedVersionsNotifier extends Notifier> { @override Set build() => {}; void add(String version) { state = {...state, version}; } } final _dismissedVersionsProvider = NotifierProvider<_DismissedVersionsNotifier, Set>( _DismissedVersionsNotifier.new); /// True when the update banner should render: an UpdateInfo is /// available AND the operator hasn't dismissed this specific version. final shouldShowUpdateBannerProvider = Provider((ref) { final info = ref.watch(clientUpdateProvider).value; if (info == null) return null; final dismissed = ref.watch(_dismissedVersionsProvider); if (dismissed.contains(info.version)) return null; return info; }); /// Dismiss controller: marks the given version as dismissed so the /// banner stops showing for this session. final dismissUpdateProvider = Provider((ref) { return (version) => ref.read(_dismissedVersionsProvider.notifier).add(version); }); /// Installer provider — depends on dio so the install download uses /// the same authenticated client (though /api/client/apk is unauthed, /// reusing dio keeps configuration consistent). final updateInstallerProvider = FutureProvider((ref) async { return UpdateInstaller(await ref.watch(dioProvider.future)); });