Files
jizhi/frontend/lib/shared/update/update_downloader.dart
T
2026-07-24 23:11:20 +08:00

191 lines
6.0 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'package:path_provider/path_provider.dart';
import 'package:miaoji_zhang/shared/update/update_installer.dart';
import 'package:miaoji_zhang/shared/update/update_models.dart';
class UpdateDownloadProgress {
final int received;
final int total;
final double bytesPerSecond;
const UpdateDownloadProgress({
required this.received,
required this.total,
required this.bytesPerSecond,
});
double? get fraction => total > 0 ? (received / total).clamp(0, 1) : null;
}
class UpdateDownloadException implements Exception {
final String message;
const UpdateDownloadException(this.message);
@override
String toString() => message;
}
class UpdateDownloader {
UpdateDownloader({Dio? dio})
: _dio =
dio ??
Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(minutes: 10),
followRedirects: true,
maxRedirects: 5,
),
);
final Dio _dio;
CancelToken? _cancelToken;
bool get isDownloading => _cancelToken != null;
void cancel() => _cancelToken?.cancel('用户取消下载');
Future<void> cleanupStale() async {
if (!Platform.isAndroid) return;
final directory = await _updateDirectory();
if (!await directory.exists()) return;
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
await for (final entity in directory.list()) {
if (entity is! File) continue;
final stat = await entity.stat();
if (entity.path.endsWith('.part') || stat.modified.isBefore(cutoff)) {
await _delete(entity);
}
}
}
Future<String> downloadAndVerify(
AppRelease release, {
required void Function(UpdateDownloadProgress progress) onProgress,
}) async {
if (_cancelToken != null) {
throw const UpdateDownloadException('更新包正在下载中');
}
final uri = Uri.tryParse(release.downloadUrl);
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
throw const UpdateDownloadException('更新包地址无效,必须使用 HTTPS');
}
final expectedSha = release.sha256?.trim().toLowerCase();
if (expectedSha == null ||
!RegExp(r'^[0-9a-f]{64}$').hasMatch(expectedSha)) {
throw const UpdateDownloadException('发布记录缺少有效的 SHA-256,已禁止安装');
}
final directory = await _updateDirectory();
await directory.create(recursive: true);
final partial = File(
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk.part',
);
final target = File(
'${directory.path}${Platform.pathSeparator}jizhi-${release.buildNumber}.apk',
);
await _delete(partial);
await _delete(target);
final token = CancelToken();
_cancelToken = token;
final stopwatch = Stopwatch()..start();
var lastBytes = 0;
var lastElapsed = Duration.zero;
var keepTarget = false;
try {
await _dio.download(
release.downloadUrl,
partial.path,
cancelToken: token,
deleteOnError: true,
onReceiveProgress: (received, total) {
final elapsed = stopwatch.elapsed;
final deltaMicros = (elapsed - lastElapsed).inMicroseconds;
final speed = deltaMicros <= 0
? 0.0
: (received - lastBytes) *
Duration.microsecondsPerSecond /
deltaMicros;
if (elapsed - lastElapsed >= const Duration(milliseconds: 250) ||
received == total) {
lastBytes = received;
lastElapsed = elapsed;
onProgress(
UpdateDownloadProgress(
received: received,
total: total > 0 ? total : release.fileSize ?? 0,
bytesPerSecond: speed,
),
);
}
},
);
final actualSize = await partial.length();
if (release.fileSize case final expectedSize?
when expectedSize > 0 && actualSize != expectedSize) {
throw UpdateDownloadException(
'更新包大小不一致(预期 $expectedSize 字节,实际 $actualSize 字节)',
);
}
final actualSha = (await sha256.bind(partial.openRead()).first)
.toString();
if (actualSha.toLowerCase() != expectedSha) {
throw const UpdateDownloadException('更新包完整性校验失败,文件已删除');
}
await partial.rename(target.path);
final inspection = await UpdateInstaller.inspect(
path: target.path,
expectedBuild: release.buildNumber,
);
if (!inspection.valid) {
throw UpdateDownloadException(inspection.message ?? '更新包身份校验失败,文件已删除');
}
keepTarget = true;
return target.path;
} on DioException catch (error) {
if (CancelToken.isCancel(error)) {
throw const UpdateDownloadException('下载已取消');
}
throw const UpdateDownloadException('更新包下载失败,请检查网络后重试');
} on UpdateDownloadException {
rethrow;
} catch (_) {
throw const UpdateDownloadException('更新包处理失败,请重新下载');
} finally {
_cancelToken = null;
stopwatch.stop();
if (!keepTarget) {
await _delete(partial);
await _delete(target);
}
}
}
Future<void> deleteFile(String path) => _delete(File(path));
Future<void> scheduleCleanup(String path) async {
unawaited(
Future<void>.delayed(const Duration(minutes: 10), () async {
await _delete(File(path));
}),
);
}
Future<Directory> _updateDirectory() async {
final temporary = await getTemporaryDirectory();
return Directory('${temporary.path}${Platform.pathSeparator}updates');
}
Future<void> _delete(File file) async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
}