191 lines
6.0 KiB
Dart
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 (_) {}
|
|
}
|
|
}
|