mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
feat(updater): resume and verify APK downloads
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user