diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 43f30980..66275447 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -376,7 +376,7 @@ class _MainShellState extends ConsumerState // The check runs even when the user disabled update prompts: versions // that fall forceUpdateThreshold stable releases behind must update, and // that enforcement cannot be opted out of. - final updateInfo = await UpdateChecker.checkForUpdate( + final updateInfo = await UpdateChecker().checkForUpdate( channel: settings.updateChannel, ); if (updateInfo == null || !mounted) return false; diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 4df090d5..b0594010 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -50,6 +50,21 @@ class UpdateInfo { } class UpdateChecker { + final http.Client? _client; + final String _installedVersion; + final bool _isAndroid; + final List? _supportedAbis; + + UpdateChecker({ + http.Client? client, + String installedVersion = AppInfo.version, + bool? isAndroid, + List? supportedAbis, + }) : _client = client, + _installedVersion = installedVersion, + _isAndroid = isAndroid ?? Platform.isAndroid, + _supportedAbis = supportedAbis; + static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; @@ -57,67 +72,86 @@ class UpdateChecker { /// update before continuing to use the app. static const int forceUpdateThreshold = 3; - // The releases payload is tens of KB and update cadence is days, not - // minutes: serve from cache within the TTL and revalidate with ETag after, - // instead of re-downloading the full list on every cold start. - static const Duration _cacheTtl = Duration(hours: 6); + // Revalidate on every check. A local TTL can hide a newly published release; + // ETag still avoids downloading the release list when it has not changed. static const String _cachedBodyKey = 'update_checker_releases_json'; static const String _cachedAtKey = 'update_checker_releases_fetched_at'; static const String _cachedEtagKey = 'update_checker_releases_etag'; - static Future _fetchReleasesBody() async { + Future<({String body, bool verified})?> _fetchReleasesBody() async { final prefs = await SharedPreferences.getInstance(); - final cachedBody = prefs.getString(_cachedBodyKey); - final cachedAt = prefs.getInt(_cachedAtKey) ?? 0; - final ageMs = DateTime.now().millisecondsSinceEpoch - cachedAt; - if (cachedBody != null && ageMs < _cacheTtl.inMilliseconds) { - return cachedBody; + var cachedBody = prefs.getString(_cachedBodyKey); + if (cachedBody != null) { + try { + _parseReleases(cachedBody); + } on FormatException { + cachedBody = null; + _log.w('Ignoring invalid cached release list'); + } } final cachedEtag = prefs.getString(_cachedEtagKey); - final response = await http - .get( - Uri.parse('$_allReleasesApiUrl?per_page=30'), - headers: { - 'Accept': 'application/vnd.github.v3+json', - if (cachedBody != null && cachedEtag != null) - 'If-None-Match': cachedEtag, - }, - ) - .timeout(const Duration(seconds: 10)); + try { + final response = await (_client?.get ?? http.get)( + Uri.parse('$_allReleasesApiUrl?per_page=30'), + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'Cache-Control': 'no-cache', + if (cachedBody != null && cachedEtag != null) + 'If-None-Match': cachedEtag, + }, + ).timeout(const Duration(seconds: 10)); - if (response.statusCode == 304 && cachedBody != null) { + if (response.statusCode == 304 && cachedBody != null) { + _log.i('Release list revalidated by GitHub (304)'); + await prefs.setInt(_cachedAtKey, DateTime.now().millisecondsSinceEpoch); + return (body: cachedBody, verified: true); + } + if (response.statusCode != 200) { + throw http.ClientException( + 'GitHub API returned ${response.statusCode}', + ); + } + + // Invalid responses must not overwrite a usable offline snapshot. + _parseReleases(response.body); + await prefs.setString(_cachedBodyKey, response.body); + final etag = response.headers['etag']; + if (etag != null) { + await prefs.setString(_cachedEtagKey, etag); + } else { + await prefs.remove(_cachedEtagKey); + } await prefs.setInt(_cachedAtKey, DateTime.now().millisecondsSinceEpoch); - return cachedBody; + _log.i('Fetched current release list from GitHub (200)'); + return (body: response.body, verified: true); + } catch (error) { + _log.w('Could not refresh releases: $error'); + return cachedBody == null ? null : (body: cachedBody, verified: false); } - if (response.statusCode != 200) { - _log.w('GitHub API returned ${response.statusCode}'); - return cachedBody; // stale is better than none for the update prompt - } - - await prefs.setString(_cachedBodyKey, response.body); - final etag = response.headers['etag']; - if (etag != null) { - await prefs.setString(_cachedEtagKey, etag); - } - await prefs.setInt(_cachedAtKey, DateTime.now().millisecondsSinceEpoch); - return response.body; } - static Future checkForUpdate({String channel = 'stable'}) async { - if (!Platform.isAndroid) { + static List> _parseReleases(String body) { + final decoded = jsonDecode(body); + if (decoded is! List || + decoded.any((release) => release is! Map)) { + throw const FormatException('Invalid GitHub release list'); + } + return decoded.cast>(); + } + + Future checkForUpdate({String channel = 'stable'}) async { + if (!_isAndroid) { return null; } try { - final releasesJson = await _fetchReleasesBody(); - if (releasesJson == null) { + final snapshot = await _fetchReleasesBody(); + if (snapshot == null) { return null; } - final releases = (jsonDecode(releasesJson) as List) - .whereType>() - .toList(); + final releases = _parseReleases(snapshot.body); if (releases.isEmpty) { _log.i('No releases found'); return null; @@ -150,15 +184,22 @@ class UpdateChecker { 'v', '', ); - if (version.isNotEmpty && _isNewerVersion(version, AppInfo.version)) { + if (version.isNotEmpty && _isNewerVersion(version, _installedVersion)) { releasesBehind++; } } - if (!_isNewerVersion(latestVersion, AppInfo.version)) { - _log.i( - 'No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)', - ); + if (!_isNewerVersion(latestVersion, _installedVersion)) { + if (snapshot.verified) { + _log.i( + 'No update available (current: $_installedVersion, latest: $latestVersion, channel: $channel)', + ); + } else { + _log.w( + 'Update availability could not be verified ' + '(current: $_installedVersion, cached latest: $latestVersion, channel: $channel)', + ); + } return null; } @@ -293,9 +334,7 @@ class UpdateChecker { return null; } - static Future<_ApkAsset?> _selectApkForCurrentDevice( - List<_ApkAsset> assets, - ) async { + Future<_ApkAsset?> _selectApkForCurrentDevice(List<_ApkAsset> assets) async { if (assets.isEmpty) { return null; } @@ -317,7 +356,7 @@ class UpdateChecker { } } - final supportedAbis = await _getSupportedAndroidAbis(); + final supportedAbis = _supportedAbis ?? await _getSupportedAndroidAbis(); final hasArm64 = supportedAbis.any(_isArm64Abi); final hasArm32 = supportedAbis.any(_isArm32Abi); diff --git a/test/update_checker_test.dart b/test/update_checker_test.dart new file mode 100644 index 00000000..dec291fb --- /dev/null +++ b/test/update_checker_test.dart @@ -0,0 +1,313 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/services/update_checker.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/runtime_profile_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/logger.dart'; +import 'package:spotiflac_android/widgets/update_dialog.dart'; + +Map release(String version) => { + 'tag_name': 'v$version', + 'prerelease': false, + 'draft': false, + 'body': '- Release notes', + 'published_at': '2026-09-26T06:32:33Z', + 'html_url': + 'https://github.com/spotiflacapp/SpotiFLAC-Mobile/releases/tag/v$version', + 'assets': [ + for (final abi in ['arm32', 'arm64']) + { + 'name': 'SpotiFLAC-v$version-$abi.apk', + 'browser_download_url': + 'https://example.test/SpotiFLAC-v$version-$abi.apk', + 'digest': 'sha256:${List.filled(64, 'a').join()}', + }, + ], +}; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + LogBuffer().clear(); + LogBuffer.loggingEnabled = true; + }); + tearDown(() => LogBuffer.loggingEnabled = false); + + for (final (abi, asset) in [ + ('arm64-v8a', 'arm64'), + ('armeabi-v7a', 'arm32'), + ]) { + test('5.0.0 discovers 5.0.5 despite a recent cache ($abi)', () async { + SharedPreferences.setMockInitialValues({ + 'update_checker_releases_json': jsonEncode([release('5.0.0')]), + 'update_checker_releases_fetched_at': + DateTime.now().millisecondsSinceEpoch, + 'update_checker_releases_etag': '"old-release-list"', + }); + var requests = 0; + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: true, + supportedAbis: [abi], + client: MockClient((request) async { + requests++; + expect(request.headers['If-None-Match'], '"old-release-list"'); + return http.Response( + jsonEncode([release('5.0.5'), release('5.0.0')]), + 200, + headers: {'etag': '"new-release-list"'}, + ); + }), + ); + + final update = await checker.checkForUpdate(); + expect(requests, 1); + expect(update?.version, '5.0.5'); + expect(update?.apkDownloadUrl, endsWith('v5.0.5-$asset.apk')); + expect(update?.releasesBehind, 1); + expect(update?.apkSha256, List.filled(64, 'a').join()); + final prefs = await SharedPreferences.getInstance(); + expect( + prefs.getString('update_checker_releases_etag'), + '"new-release-list"', + ); + expect( + prefs.getString('update_checker_releases_json'), + contains('v5.0.5'), + ); + }); + } + + test( + 'checks again immediately and reuses the body only after GitHub 304', + () async { + var requests = 0; + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: true, + supportedAbis: ['arm64-v8a'], + client: MockClient((request) async { + requests++; + if (requests == 1) { + expect(request.headers['If-None-Match'], isNull); + return http.Response( + jsonEncode([release('5.0.5')]), + 200, + headers: {'etag': '"fresh"'}, + ); + } + expect(request.headers['If-None-Match'], '"fresh"'); + return http.Response('', 304); + }), + ); + expect((await checker.checkForUpdate())?.version, '5.0.5'); + expect((await checker.checkForUpdate())?.version, '5.0.5'); + expect(requests, 2); + }, + ); + + test( + 'replacing a body without an ETag removes the previous validator', + () async { + SharedPreferences.setMockInitialValues({ + 'update_checker_releases_json': jsonEncode([release('5.0.0')]), + 'update_checker_releases_etag': '"old"', + }); + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: true, + supportedAbis: ['arm64-v8a'], + client: MockClient( + (_) async => http.Response(jsonEncode([release('5.0.5')]), 200), + ), + ); + expect((await checker.checkForUpdate())?.version, '5.0.5'); + expect( + (await SharedPreferences.getInstance()).getString( + 'update_checker_releases_etag', + ), + isNull, + ); + }, + ); + + for (final failure in ['offline', 'rate limit', 'invalid JSON']) { + test('$failure does not claim cached 5.0.0 is the latest online', () async { + final cachedBody = jsonEncode([release('5.0.0')]); + SharedPreferences.setMockInitialValues({ + 'update_checker_releases_json': cachedBody, + 'update_checker_releases_etag': '"cached"', + }); + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: true, + supportedAbis: ['arm64-v8a'], + client: MockClient((_) async { + if (failure == 'offline') throw http.ClientException('Offline'); + if (failure == 'rate limit') return http.Response('Limited', 403); + return http.Response('{"message":"Invalid response"}', 200); + }), + ); + expect(await checker.checkForUpdate(), isNull); + final messages = LogBuffer().entries + .map((entry) => entry.message) + .join('\n'); + expect(messages, contains('could not be verified')); + expect(messages, isNot(contains('No update available'))); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('update_checker_releases_json'), cachedBody); + expect(prefs.getString('update_checker_releases_etag'), '"cached"'); + }); + } + + test( + 'an offline check can still offer a previously discovered update', + () async { + SharedPreferences.setMockInitialValues({ + 'update_checker_releases_json': jsonEncode([release('5.0.5')]), + }); + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: true, + supportedAbis: ['armeabi-v7a'], + client: MockClient((_) async => throw http.ClientException('Offline')), + ); + expect((await checker.checkForUpdate())?.version, '5.0.5'); + }, + ); + + test( + 'invalid stored JSON does not send its ETag or block fresh releases', + () async { + SharedPreferences.setMockInitialValues({ + 'update_checker_releases_json': '{broken', + 'update_checker_releases_etag': '"broken"', + }); + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: true, + supportedAbis: ['arm64-v8a'], + client: MockClient((request) async { + expect(request.headers['If-None-Match'], isNull); + return http.Response(jsonEncode([release('5.0.5')]), 200); + }), + ); + expect((await checker.checkForUpdate())?.version, '5.0.5'); + }, + ); + + test('stable skips previews and counts only newer stable releases', () async { + final checker = UpdateChecker( + installedVersion: '4.9.5', + isAndroid: true, + supportedAbis: ['arm64-v8a'], + client: MockClient( + (_) async => http.Response( + jsonEncode([ + {...release('5.0.6-preview'), 'prerelease': true}, + release('5.0.5'), + release('5.0.0'), + release('4.9.6'), + release('4.9.5'), + ]), + 200, + ), + ), + ); + final stable = await checker.checkForUpdate(); + expect(stable?.version, '5.0.5'); + expect(stable?.releasesBehind, UpdateChecker.forceUpdateThreshold); + expect( + (await checker.checkForUpdate(channel: 'preview'))?.version, + '5.0.6-preview', + ); + }); + + test( + 'an installed 5.0.5 does not prompt to reinstall the same version', + () async { + final checker = UpdateChecker( + installedVersion: '5.0.5', + isAndroid: true, + client: MockClient( + (_) async => http.Response(jsonEncode([release('5.0.5')]), 200), + ), + ); + expect(await checker.checkForUpdate(), isNull); + }, + ); + + test('non-Android platforms do not request or offer an APK', () async { + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: false, + client: MockClient( + (_) async => throw StateError('Unexpected network request'), + ), + ); + expect(await checker.checkForUpdate(), isNull); + }); + + for (final mornye in [false, true]) { + testWidgets( + 'freshly discovered release opens the update dialog ($mornye)', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final checker = UpdateChecker( + installedVersion: '5.0.0', + isAndroid: true, + supportedAbis: ['arm64-v8a'], + client: MockClient( + (_) async => http.Response(jsonEncode([release('5.0.5')]), 200), + ), + ); + await tester.pumpWidget( + ProviderScope( + overrides: [lowEndDeviceProvider.overrideWithValue(false)], + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : null, + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () async { + final update = await checker.checkForUpdate(); + if (context.mounted && update != null) { + await showUpdateDialog( + context, + updateInfo: update, + onDisableUpdates: () {}, + ); + } + }, + child: const Text('Check now'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('Check now')); + await tester.pumpAndSettle(); + expect(find.byType(UpdateDialog), findsOneWidget); + expect(find.text('v5.0.5'), findsOneWidget); + expect(find.textContaining('Release notes'), findsOneWidget); + expect(find.text('Download & Install').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }, + ); + } +}