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); }, ); } }