fix(updater): revalidate releases instead of hiding updates in cache

This commit is contained in:
zarzet
2026-09-26 15:02:46 +07:00
parent e03b4f5d8d
commit 6b17f06a5b
3 changed files with 403 additions and 51 deletions
+1 -1
View File
@@ -376,7 +376,7 @@ class _MainShellState extends ConsumerState<MainShell>
// 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;
+89 -50
View File
@@ -50,6 +50,21 @@ class UpdateInfo {
}
class UpdateChecker {
final http.Client? _client;
final String _installedVersion;
final bool _isAndroid;
final List<String>? _supportedAbis;
UpdateChecker({
http.Client? client,
String installedVersion = AppInfo.version,
bool? isAndroid,
List<String>? 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<String?> _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<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
if (!Platform.isAndroid) {
static List<Map<String, dynamic>> _parseReleases(String body) {
final decoded = jsonDecode(body);
if (decoded is! List<dynamic> ||
decoded.any((release) => release is! Map<String, dynamic>)) {
throw const FormatException('Invalid GitHub release list');
}
return decoded.cast<Map<String, dynamic>>();
}
Future<UpdateInfo?> 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<dynamic>)
.whereType<Map<String, dynamic>>()
.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);
+313
View File
@@ -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<String, Object?> 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);
},
);
}
}