diff --git a/flutter_client/android/app/src/main/AndroidManifest.xml b/flutter_client/android/app/src/main/AndroidManifest.xml index 294073a0..cfa07960 100644 --- a/flutter_client/android/app/src/main/AndroidManifest.xml +++ b/flutter_client/android/app/src/main/AndroidManifest.xml @@ -9,6 +9,10 @@ for the media notification (now-playing tile) to appear. Audio playback works without it; only the system-tray surface breaks. --> + + + + + + + + + diff --git a/flutter_client/lib/shared/routing.dart b/flutter_client/lib/shared/routing.dart index 189884f0..9d99b5df 100644 --- a/flutter_client/lib/shared/routing.dart +++ b/flutter_client/lib/shared/routing.dart @@ -18,6 +18,7 @@ import '../playlists/playlists_list_screen.dart'; import '../requests/requests_screen.dart'; import '../search/search_screen.dart'; import '../settings/settings_screen.dart'; +import '../update/update_banner.dart'; import '../admin/admin_landing_screen.dart'; import '../admin/admin_requests_screen.dart'; import '../admin/admin_quarantine_screen.dart'; @@ -94,6 +95,7 @@ class _ShellWithPlayerBar extends StatelessWidget { Widget build(BuildContext context) { return Column( children: [ + const UpdateBanner(), Expanded(child: child), const PlayerBar(), ], diff --git a/flutter_client/lib/update/client_update_provider.dart b/flutter_client/lib/update/client_update_provider.dart new file mode 100644 index 00000000..f249922f --- /dev/null +++ b/flutter_client/lib/update/client_update_provider.dart @@ -0,0 +1,102 @@ +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 'package:pub_semver/pub_semver.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`. +/// Both strings are normalized (leading 'v' stripped) and parsed as +/// semver. On parse failure, falls back to string inequality (treats +/// any difference as "newer" — operator can dismiss if wrong). +/// +/// Exposed for testing; the polling logic in ClientUpdateController +/// is the only production caller. +bool isVersionNewer(String serverVersion, String installedVersion) { + final svr = serverVersion.replaceFirst(RegExp(r'^v'), ''); + final ins = installedVersion.replaceFirst(RegExp(r'^v'), ''); + try { + return Version.parse(svr) > Version.parse(ins); + } catch (_) { + return svr != ins; + } +} + +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. +final _dismissedVersionsProvider = StateProvider>((_) => {}); + +/// 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).update((s) => {...s, 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)); +}); diff --git a/flutter_client/lib/update/installer.dart b/flutter_client/lib/update/installer.dart new file mode 100644 index 00000000..94cca2ee --- /dev/null +++ b/flutter_client/lib/update/installer.dart @@ -0,0 +1,46 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Bridges to MainActivity.kt's MethodChannel for the Android +/// PackageInstaller intent (#397). Web / iOS don't support self- +/// install; this class is Android-only at v1. +class UpdateInstaller { + UpdateInstaller(this._dio); + final Dio _dio; + + static const _channel = MethodChannel('com.fabledsword.minstrel/installer'); + static const _filename = 'minstrel-update.apk'; + + /// Streams the APK from `apkUrl` into the cache directory. `onProgress` + /// receives 0..1 fractions; emits 1.0 once when the download completes. + /// Returns the local path the install intent will read from. + Future download( + String apkUrl, { + void Function(double progress)? onProgress, + }) async { + final cacheDir = await getApplicationCacheDirectory(); + final path = '${cacheDir.path}/$_filename'; + await _dio.download( + apkUrl, + path, + onReceiveProgress: (received, total) { + if (total > 0 && onProgress != null) { + onProgress(received / total); + } + }, + ); + return path; + } + + /// Hands the downloaded APK to Android's PackageInstaller via a + /// FileProvider content:// URI. The system shows the install confirm + /// dialog; user must tap Install. App restarts on the new version. + /// + /// First-ever install attempt prompts the user to flip "Install + /// unknown apps" for Minstrel in Settings → Apps → Special access. + /// One-time grant; persists across updates. + Future install(String apkPath) async { + await _channel.invokeMethod('install', {'path': apkPath}); + } +} diff --git a/flutter_client/lib/update/update_banner.dart b/flutter_client/lib/update/update_banner.dart new file mode 100644 index 00000000..3f2b80df --- /dev/null +++ b/flutter_client/lib/update/update_banner.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../theme/theme_extension.dart'; +import 'client_update_provider.dart'; +import 'update_info.dart'; + +/// Soft banner mounted at the top of the shell. Renders nothing when +/// no update is available or the user has dismissed this version's +/// banner. Tapping Install downloads the APK and fires the system +/// install intent (Android only). +class UpdateBanner extends ConsumerStatefulWidget { + const UpdateBanner({super.key}); + + @override + ConsumerState createState() => _UpdateBannerState(); +} + +enum _Stage { idle, downloading, error } + +class _UpdateBannerState extends ConsumerState { + _Stage _stage = _Stage.idle; + double _progress = 0; + String? _error; + + Future _onInstall(UpdateInfo info) async { + setState(() { + _stage = _Stage.downloading; + _progress = 0; + _error = null; + }); + try { + final installer = await ref.read(updateInstallerProvider.future); + final path = await installer.download( + info.apkUrl, + onProgress: (p) => setState(() => _progress = p), + ); + await installer.install(path); + // Stage stays 'downloading' — Android system install dialog has + // taken over. If user cancels, the banner is still here. + } catch (e) { + if (!mounted) return; + setState(() { + _stage = _Stage.error; + _error = '$e'; + }); + } + } + + void _onDismiss(UpdateInfo info) { + ref.read(dismissUpdateProvider)(info.version); + } + + @override + Widget build(BuildContext context) { + final info = ref.watch(shouldShowUpdateBannerProvider); + if (info == null) return const SizedBox.shrink(); + final fs = Theme.of(context).extension()!; + + return Material( + color: fs.iron, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + child: Row( + children: [ + Icon(Icons.system_update, color: fs.parchment, size: 18), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _stage == _Stage.error + ? 'Update failed' + : 'Update Minstrel · ${info.version} available', + style: TextStyle(color: fs.parchment, fontSize: 13), + overflow: TextOverflow.ellipsis, + ), + if (_stage == _Stage.downloading) ...[ + const SizedBox(height: 4), + LinearProgressIndicator( + value: _progress > 0 ? _progress : null, + minHeight: 2, + backgroundColor: fs.obsidian, + valueColor: AlwaysStoppedAnimation(fs.accent), + ), + ], + if (_stage == _Stage.error && _error != null) ...[ + const SizedBox(height: 2), + Text( + _error!, + style: TextStyle(color: fs.ash, fontSize: 11), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (_stage == _Stage.idle) + TextButton( + onPressed: () => _onInstall(info), + style: TextButton.styleFrom( + foregroundColor: fs.accent, + minimumSize: const Size(64, 36), + ), + child: const Text('Install'), + ), + if (_stage == _Stage.error) + TextButton( + onPressed: () => _onInstall(info), + style: TextButton.styleFrom(foregroundColor: fs.accent), + child: const Text('Retry'), + ), + IconButton( + tooltip: 'Dismiss', + icon: Icon(Icons.close, size: 18, color: fs.ash), + onPressed: () => _onDismiss(info), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter_client/lib/update/update_info.dart b/flutter_client/lib/update/update_info.dart new file mode 100644 index 00000000..fdd8335a --- /dev/null +++ b/flutter_client/lib/update/update_info.dart @@ -0,0 +1,21 @@ +// Wire shape returned by GET /api/client/version. `version` is the +// server-bundled APK version (raw — may have a "v" prefix from the +// git tag); `apkUrl` is server-relative (e.g. "/api/client/apk"). + +class UpdateInfo { + const UpdateInfo({ + required this.version, + required this.apkUrl, + required this.sizeBytes, + }); + + final String version; + final String apkUrl; + final int sizeBytes; + + factory UpdateInfo.fromJson(Map j) => UpdateInfo( + version: (j['version'] as String?) ?? '', + apkUrl: (j['apk_url'] as String?) ?? '/api/client/apk', + sizeBytes: (j['size_bytes'] as num?)?.toInt() ?? 0, + ); +} diff --git a/flutter_client/test/update/client_update_provider_test.dart b/flutter_client/test/update/client_update_provider_test.dart new file mode 100644 index 00000000..1ed1f679 --- /dev/null +++ b/flutter_client/test/update/client_update_provider_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:minstrel/update/client_update_provider.dart'; + +void main() { + group('isVersionNewer (semver path)', () { + test('strictly newer', () { + expect(isVersionNewer('0.1.1', '0.1.0'), isTrue); + expect(isVersionNewer('0.2.0', '0.1.99'), isTrue); + expect(isVersionNewer('1.0.0', '0.99.99'), isTrue); + }); + + test('equal returns false', () { + expect(isVersionNewer('0.1.0', '0.1.0'), isFalse); + }); + + test('older returns false', () { + expect(isVersionNewer('0.1.0', '0.1.1'), isFalse); + expect(isVersionNewer('0.1.0', '0.2.0'), isFalse); + }); + + test('strips leading v on either side', () { + expect(isVersionNewer('v0.1.1', '0.1.0'), isTrue); + expect(isVersionNewer('0.1.1', 'v0.1.0'), isTrue); + expect(isVersionNewer('v0.1.0', 'v0.1.0'), isFalse); + }); + + test('honors prerelease ordering', () { + // 0.1.0 > 0.1.0-rc.1 per semver + expect(isVersionNewer('0.1.0', '0.1.0-rc.1'), isTrue); + expect(isVersionNewer('0.1.0-rc.1', '0.1.0'), isFalse); + }); + }); + + group('isVersionNewer (non-semver fallback)', () { + test('different strings → newer', () { + // Date-tag-style versions don't parse as semver; falls back to + // string inequality. Operator can dismiss if the comparison is + // wrong (the alternative — silent skip — would mean operators + // never see updates). + expect(isVersionNewer('2026.05.10', '2026.05.09'), isTrue); + expect(isVersionNewer('2026.05.09', '2026.05.10'), isTrue); + }); + + test('equal strings → not newer', () { + expect(isVersionNewer('2026.05.10', '2026.05.10'), isFalse); + }); + }); +}