feat(updater): resume and verify APK downloads

This commit is contained in:
zarzet
2026-08-30 23:19:00 +07:00
parent b18def64d3
commit 2c9881944d
4 changed files with 254 additions and 27 deletions
+162 -26
View File
@@ -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<String?> 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<Map<String, dynamic>?> _readResumeMetadata(File file) async {
try {
if (!await file.exists()) return null;
final decoded = jsonDecode(await file.readAsString());
return decoded is Map ? Map<String, dynamic>.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<bool> _looksLikeApk(File file) async {
try {
if (await file.length() < 4) return false;
final header = await file
.openRead(0, 4)
.fold<List<int>>(<int>[], (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<void> _discardPartial(File part, File metadata) async {
if (await part.exists()) await part.delete();
if (await metadata.exists()) await metadata.delete();
}
static Future<void> installApk(String filePath) async {
try {
final result = await OpenFilex.open(filePath);
+20 -1
View File
@@ -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;
+1
View File
@@ -69,6 +69,7 @@ class _UpdateDialogState extends State<UpdateDialog> {
final filePath = await ApkDownloader.downloadApk(
url: apkUrl,
version: widget.updateInfo.version,
expectedSha256: widget.updateInfo.apkSha256,
onProgress: (received, total) {
if (mounted) {
setState(() {
+71
View File
@@ -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 = <int>[
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);
});
}