Initial project import
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user