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 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 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 deleteFile(String path) => _delete(File(path)); Future scheduleCleanup(String path) async { unawaited( Future.delayed(const Duration(minutes: 10), () async { await _delete(File(path)); }), ); } Future _updateDirectory() async { final temporary = await getTemporaryDirectory(); return Directory('${temporary.path}${Platform.pathSeparator}updates'); } Future _delete(File file) async { try { if (await file.exists()) await file.delete(); } catch (_) {} } }