mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
fix(updater): revalidate releases instead of hiding updates in cache
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user