From 2c9881944d423e8b9f81a3c96149efde83d0d3bf Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 30 Aug 2026 23:19:00 +0700 Subject: [PATCH] feat(updater): resume and verify APK downloads --- lib/services/apk_downloader.dart | 188 ++++++++++++++++++++++++++----- lib/services/update_checker.dart | 21 +++- lib/widgets/update_dialog.dart | 1 + test/apk_downloader_test.dart | 71 ++++++++++++ 4 files changed, 254 insertions(+), 27 deletions(-) create mode 100644 test/apk_downloader_test.dart diff --git a/lib/services/apk_downloader.dart b/lib/services/apk_downloader.dart index ffc1ff7f..47905b7b 100644 --- a/lib/services/apk_downloader.dart +++ b/lib/services/apk_downloader.dart @@ -1,7 +1,10 @@ +import 'dart:convert'; import 'dart:io'; + +import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; -import 'package:path_provider/path_provider.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('ApkDownloader'); @@ -9,10 +12,15 @@ final _log = AppLogger('ApkDownloader'); typedef ProgressCallback = void Function(int received, int total); class ApkDownloader { + static const _streamIdleTimeout = Duration(seconds: 60); + static Future downloadApk({ required String url, required String version, + String? expectedSha256, ProgressCallback? onProgress, + http.Client? client, + Directory? downloadDirectory, }) async { final uri = Uri.tryParse(url); if (uri == null || uri.scheme != 'https') { @@ -20,54 +28,182 @@ class ApkDownloader { return null; } - final client = http.Client(); + final ownedClient = client == null; + final effectiveClient = client ?? http.Client(); IOSink? sink; try { - final request = http.Request('GET', uri); - final response = await client.send(request); - - if (response.statusCode != 200) { - _log.e('Failed to download: ${response.statusCode}'); - return null; - } - - final contentLength = response.contentLength ?? 0; - - final dir = await getExternalStorageDirectory(); + final dir = downloadDirectory ?? await getExternalStorageDirectory(); if (dir == null) { _log.e('Could not get storage directory'); return null; } + await dir.create(recursive: true); - final filePath = '${dir.path}/SpotiFLAC-$version.apk'; - final file = File(filePath); + final safeVersion = version.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + final finalFile = File( + '${dir.path}${Platform.pathSeparator}SpotiFLAC-Mobile-$safeVersion.apk', + ); + final partFile = File('${finalFile.path}.part'); + final metadataFile = File('${partFile.path}.json'); + final metadata = await _readResumeMetadata(metadataFile); - if (await file.exists()) { - await file.delete(); + var resumeOffset = 0; + if (await partFile.exists() && metadata?['url'] == url) { + resumeOffset = await partFile.length(); + } else { + if (await partFile.exists()) await partFile.delete(); + if (await metadataFile.exists()) await metadataFile.delete(); } - sink = file.openWrite(); - int received = 0; + final request = http.Request('GET', uri); + if (resumeOffset > 0) { + request.headers['Range'] = 'bytes=$resumeOffset-'; + final etag = metadata?['etag']?.toString() ?? ''; + if (etag.isNotEmpty) request.headers['If-Range'] = etag; + } - await for (final chunk in response.stream) { + final response = await effectiveClient + .send(request) + .timeout(const Duration(seconds: 30)); + final isResume = resumeOffset > 0 && response.statusCode == 206; + if (response.statusCode == 206 && + !_contentRangeStartsAt(response, resumeOffset)) { + _log.w('Server returned an invalid Content-Range; clearing partial'); + await _discardPartial(partFile, metadataFile); + return null; + } + if (response.statusCode == 416) { + _log.w('Server rejected the saved download range; clearing partial'); + await _discardPartial(partFile, metadataFile); + return null; + } + if (response.statusCode != 200 && response.statusCode != 206) { + _log.e('Failed to download: ${response.statusCode}'); + return null; + } + + if (!isResume) resumeOffset = 0; + final total = _responseTotalBytes(response, resumeOffset); + await metadataFile.writeAsString( + jsonEncode({ + 'url': url, + 'etag': response.headers['etag'] ?? '', + 'total': total, + }), + flush: true, + ); + + sink = partFile.openWrite( + mode: isResume ? FileMode.append : FileMode.writeOnly, + ); + var received = resumeOffset; + onProgress?.call(received, total); + await for (final chunk in response.stream.timeout(_streamIdleTimeout)) { sink.add(chunk); received += chunk.length; - onProgress?.call(received, contentLength); + onProgress?.call(received, total); + } + await sink.flush(); + await sink.close(); + sink = null; + + if (total > 0 && received != total) { + _log.w('Incomplete APK download: $received/$total bytes'); + return null; + } + if (!await _looksLikeApk(partFile)) { + _log.e('Downloaded update is not a valid APK/ZIP payload'); + await _discardPartial(partFile, metadataFile); + return null; } - await sink.flush(); - _log.i('Downloaded to: $filePath'); - return filePath; + final expected = expectedSha256?.trim().toLowerCase() ?? ''; + if (expected.isNotEmpty) { + if (!RegExp(r'^[a-f0-9]{64}$').hasMatch(expected)) { + _log.e('Release supplied an invalid SHA-256 digest'); + await _discardPartial(partFile, metadataFile); + return null; + } + final actual = (await sha256.bind(partFile.openRead()).first) + .toString(); + if (actual != expected) { + _log.e('APK SHA-256 verification failed'); + await _discardPartial(partFile, metadataFile); + return null; + } + } + + if (await finalFile.exists()) await finalFile.delete(); + await partFile.rename(finalFile.path); + if (await metadataFile.exists()) await metadataFile.delete(); + _log.i( + 'Downloaded verified update to ${finalFile.path}' + '${expected.isEmpty ? ' (release digest unavailable)' : ''}', + ); + return finalFile.path; } catch (e) { - _log.e('Error: $e'); + _log.e('Update download paused after error: $e'); return null; } finally { await sink?.close(); - client.close(); + if (ownedClient) effectiveClient.close(); } } + static Future?> _readResumeMetadata(File file) async { + try { + if (!await file.exists()) return null; + final decoded = jsonDecode(await file.readAsString()); + return decoded is Map ? Map.from(decoded) : null; + } catch (_) { + return null; + } + } + + static bool _contentRangeStartsAt( + http.StreamedResponse response, + int expected, + ) { + final value = response.headers['content-range'] ?? ''; + final match = RegExp(r'^bytes (\d+)-(\d+)/(\d+|\*)$').firstMatch(value); + return match != null && int.tryParse(match.group(1)!) == expected; + } + + static int _responseTotalBytes( + http.StreamedResponse response, + int resumeOffset, + ) { + final range = response.headers['content-range'] ?? ''; + final match = RegExp(r'/([0-9]+)$').firstMatch(range); + final rangeTotal = match == null ? null : int.tryParse(match.group(1)!); + return rangeTotal ?? + ((response.contentLength ?? 0) > 0 + ? resumeOffset + response.contentLength! + : 0); + } + + static Future _looksLikeApk(File file) async { + try { + if (await file.length() < 4) return false; + final header = await file + .openRead(0, 4) + .fold>([], (bytes, chunk) => bytes..addAll(chunk)); + return header.length == 4 && + header[0] == 0x50 && + header[1] == 0x4b && + header[2] == 0x03 && + header[3] == 0x04; + } catch (_) { + return false; + } + } + + static Future _discardPartial(File part, File metadata) async { + if (await part.exists()) await part.delete(); + if (await metadata.exists()) await metadata.delete(); + } + static Future installApk(String filePath) async { try { final result = await OpenFilex.open(filePath); diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index ad4df834..4df090d5 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -14,11 +14,13 @@ class _ApkAsset { final String name; final String url; final _ApkVariant variant; + final String? sha256; const _ApkAsset({ required this.name, required this.url, required this.variant, + this.sha256, }); } @@ -27,6 +29,7 @@ class UpdateInfo { final String changelog; final String downloadUrl; final String? apkDownloadUrl; + final String? apkSha256; final DateTime publishedAt; final bool isPrerelease; @@ -39,6 +42,7 @@ class UpdateInfo { required this.changelog, required this.downloadUrl, this.apkDownloadUrl, + this.apkSha256, required this.publishedAt, this.isPrerelease = false, this.releasesBehind = 0, @@ -182,6 +186,7 @@ class UpdateChecker { changelog: body, downloadUrl: htmlUrl, apkDownloadUrl: apkUrl, + apkSha256: selectedAsset?.sha256, publishedAt: publishedAt, isPrerelease: isPrerelease, releasesBehind: releasesBehind, @@ -251,13 +256,27 @@ class UpdateChecker { } apkAssets.add( - _ApkAsset(name: name, url: uri.toString(), variant: variant), + _ApkAsset( + name: name, + url: uri.toString(), + variant: variant, + sha256: _normalizeAssetDigest(assetMap['digest']?.toString()), + ), ); } return apkAssets; } + static String? _normalizeAssetDigest(String? digest) { + if (digest == null) return null; + final normalized = digest.trim().toLowerCase().replaceFirst( + RegExp(r'^sha256:'), + '', + ); + return RegExp(r'^[a-f0-9]{64}$').hasMatch(normalized) ? normalized : null; + } + static _ApkVariant? _apkVariantFromName(String name) { if (name.contains('universal')) { return _ApkVariant.universal; diff --git a/lib/widgets/update_dialog.dart b/lib/widgets/update_dialog.dart index 8317a147..49af193e 100644 --- a/lib/widgets/update_dialog.dart +++ b/lib/widgets/update_dialog.dart @@ -69,6 +69,7 @@ class _UpdateDialogState extends State { final filePath = await ApkDownloader.downloadApk( url: apkUrl, version: widget.updateInfo.version, + expectedSha256: widget.updateInfo.apkSha256, onProgress: (received, total) { if (mounted) { setState(() { diff --git a/test/apk_downloader_test.dart b/test/apk_downloader_test.dart new file mode 100644 index 00000000..63af8d01 --- /dev/null +++ b/test/apk_downloader_test.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; +import 'package:spotiflac_android/services/apk_downloader.dart'; + +void main() { + test('APK updater resumes a matching partial and verifies SHA-256', () async { + final directory = await Directory.systemTemp.createTemp( + 'spotiflac-updater-test-', + ); + addTearDown(() async { + if (await directory.exists()) await directory.delete(recursive: true); + }); + final payload = [ + 0x50, + 0x4b, + 0x03, + 0x04, + ...List.generate(64, (i) => i), + ]; + const version = '5.0.0'; + const url = 'https://updates.example.test/SpotiFLAC-Mobile.apk'; + final finalPath = p.join(directory.path, 'SpotiFLAC-Mobile-$version.apk'); + final part = File('$finalPath.part'); + await part.writeAsBytes(payload.sublist(0, 12)); + await File( + '$finalPath.part.json', + ).writeAsString(jsonEncode({'url': url, 'etag': '"release-1"'})); + + final client = MockClient((request) async { + expect(request.headers['range'], 'bytes=12-'); + expect(request.headers['if-range'], '"release-1"'); + return http.Response.bytes( + payload.sublist(12), + 206, + headers: { + 'content-range': 'bytes 12-${payload.length - 1}/${payload.length}', + 'content-length': '${payload.length - 12}', + 'etag': '"release-1"', + }, + request: request, + ); + }); + var lastReceived = 0; + var lastTotal = 0; + + final downloaded = await ApkDownloader.downloadApk( + url: url, + version: version, + expectedSha256: sha256.convert(payload).toString(), + client: client, + downloadDirectory: directory, + onProgress: (received, total) { + lastReceived = received; + lastTotal = total; + }, + ); + + expect(downloaded, finalPath); + expect(await File(finalPath).readAsBytes(), payload); + expect(lastReceived, payload.length); + expect(lastTotal, payload.length); + expect(await part.exists(), isFalse); + expect(await File('$finalPath.part.json').exists(), isFalse); + }); +}