|
|
@@ -0,0 +1,248 @@
|
|
|
+
|
|
|
+import 'dart:io';
|
|
|
+import 'dart:math';
|
|
|
+import 'package:dio/dio.dart';
|
|
|
+import 'package:mime/mime.dart';
|
|
|
+import 'package:path/path.dart' as path;
|
|
|
+import 'package:path_provider/path_provider.dart';
|
|
|
+import 'package:permission_handler/permission_handler.dart';
|
|
|
+
|
|
|
+
|
|
|
+const _show_debug_info = true;
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+/// 下载状态枚举
|
|
|
+enum DownloadStatus {
|
|
|
+ downloading,
|
|
|
+ completed,
|
|
|
+ failed,
|
|
|
+}
|
|
|
+
|
|
|
+/// 下载结果
|
|
|
+class DownloadResult {
|
|
|
+ final DownloadStatus status;
|
|
|
+ final String? filePath;
|
|
|
+ final double? progress;
|
|
|
+ final int? errorCode;
|
|
|
+ final String? errorReason;
|
|
|
+
|
|
|
+ bool get isCompleted => status == DownloadStatus.completed;
|
|
|
+ bool get isDownloading => status == DownloadStatus.downloading;
|
|
|
+ bool get isFailed => status == DownloadStatus.failed;
|
|
|
+
|
|
|
+
|
|
|
+ /// ----- error code -----
|
|
|
+ static const int errorCodeError = -1;
|
|
|
+ static const int errorCodePermissionDenied = -2;
|
|
|
+
|
|
|
+
|
|
|
+ /// ----- constructor -----
|
|
|
+ DownloadResult({
|
|
|
+ required this.status,
|
|
|
+ this.filePath,
|
|
|
+ this.progress,
|
|
|
+ this.errorCode,
|
|
|
+ this.errorReason,
|
|
|
+ });
|
|
|
+
|
|
|
+ factory DownloadResult.downloading(double progress) {
|
|
|
+ if (progress < 0) { progress = 0; }
|
|
|
+ else if (progress > 1) { progress = 1; }
|
|
|
+ return DownloadResult(
|
|
|
+ status: DownloadStatus.downloading,
|
|
|
+ progress: progress,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ factory DownloadResult.failed({
|
|
|
+ int errorCode = errorCodeError,
|
|
|
+ String? errorReason,
|
|
|
+ }) {
|
|
|
+ return DownloadResult(
|
|
|
+ status: DownloadStatus.failed,
|
|
|
+ errorCode: errorCode,
|
|
|
+ errorReason: errorReason,
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ factory DownloadResult.completed({
|
|
|
+ required String filePath,
|
|
|
+ }) {
|
|
|
+ return DownloadResult(
|
|
|
+ status: DownloadStatus.completed,
|
|
|
+ filePath: filePath,
|
|
|
+ progress: 1.0,
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+class HttpDownloader {
|
|
|
+
|
|
|
+ //下载
|
|
|
+ static Future<DownloadResult> download({
|
|
|
+ required String fileUrl,
|
|
|
+ String? fileName,
|
|
|
+ void Function(DownloadResult)? onProgress,
|
|
|
+ }) async {
|
|
|
+
|
|
|
+ // 请求存储权限
|
|
|
+ PermissionStatus status = await Permission.storage.request();
|
|
|
+ if (!status.isGranted) {
|
|
|
+ final result = DownloadResult.failed(
|
|
|
+ errorCode: DownloadResult.errorCodePermissionDenied,
|
|
|
+ errorReason: 'no storage permission',
|
|
|
+ );
|
|
|
+ if (onProgress != null) {
|
|
|
+ onProgress(result);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ // 获取下载目录
|
|
|
+ final saveDir = await getDownloadDirectory();
|
|
|
+ // 处理保存文件名
|
|
|
+ fileName ??= await processFileName(fileUrl, fileName);
|
|
|
+ final savePath = '$saveDir/$fileName';
|
|
|
+
|
|
|
+ try {
|
|
|
+ final dio = Dio();
|
|
|
+
|
|
|
+ // 发送开始下载状态
|
|
|
+ if (_show_debug_info) {
|
|
|
+ print('┌────────────────────────────────────────────────────────────────────────────────────────');
|
|
|
+ print('│ ⬇️ start download: $fileUrl');
|
|
|
+ print('│ save path: $fileName');
|
|
|
+ print('└────────────────────────────────────────────────────────────────────────────────────────');
|
|
|
+ }
|
|
|
+ if (onProgress != null) {
|
|
|
+ onProgress(DownloadResult.downloading(0.0));
|
|
|
+ }
|
|
|
+
|
|
|
+ await dio.download(
|
|
|
+ fileUrl,
|
|
|
+ savePath,
|
|
|
+ onReceiveProgress: (received, total) {
|
|
|
+ if (_show_debug_info) {
|
|
|
+ print('$fileName receive: $received/$total');
|
|
|
+ }
|
|
|
+ if (total != -1) {
|
|
|
+ if (onProgress != null) {
|
|
|
+ onProgress(DownloadResult.downloading(received / total));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ // 发送完成状态
|
|
|
+ if (_show_debug_info) {
|
|
|
+ print('文件下载完成: $savePath');
|
|
|
+ }
|
|
|
+ final result = DownloadResult.completed(filePath: savePath);
|
|
|
+ if (onProgress != null) {
|
|
|
+ onProgress(result);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+
|
|
|
+ } catch (e) {
|
|
|
+ print('下载失败: $e');
|
|
|
+ // 发送失败状态
|
|
|
+ final result = DownloadResult.failed(
|
|
|
+ errorReason: e.toString(),
|
|
|
+ );
|
|
|
+ if (onProgress != null) {
|
|
|
+ onProgress(result);
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 文件存储路径
|
|
|
+ static Future<String> getDownloadDirectory() async {
|
|
|
+
|
|
|
+ Directory directory;
|
|
|
+ if (Platform.isAndroid) {
|
|
|
+ // Android: 外部存储目录(对应 RNFS.ExternalDirectoryPath)
|
|
|
+ directory = await getExternalStorageDirectory() ??
|
|
|
+ await getApplicationDocumentsDirectory();
|
|
|
+ } else if (Platform.isIOS) {
|
|
|
+ // iOS: Library 目录
|
|
|
+ directory = await getLibraryDirectory();
|
|
|
+ } else {
|
|
|
+ // 其他平台使用文档目录
|
|
|
+ directory = await getApplicationDocumentsDirectory();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建 dl_cache 子目录
|
|
|
+ final cacheDir = Directory(path.join(directory.path, 'cache'));
|
|
|
+ if (!await cacheDir.exists()) {
|
|
|
+ await cacheDir.create(recursive: true);
|
|
|
+ }
|
|
|
+ return cacheDir.path;
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ //验证文件名
|
|
|
+ static String getValidFileName(String name) {
|
|
|
+ // 替换无效字符
|
|
|
+ return name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取文件名
|
|
|
+ static Future<String> processFileName(String url, String? customName) async {
|
|
|
+ String fileName;
|
|
|
+
|
|
|
+ if (customName != null) {
|
|
|
+ fileName = getValidFileName(customName);
|
|
|
+ } else {
|
|
|
+ // 尝试从URL获取
|
|
|
+ final uri = Uri.parse(url);
|
|
|
+ fileName = uri.pathSegments.lastOrNull ?? _randomFileName();
|
|
|
+
|
|
|
+ // 如果没有扩展名,尝试添加
|
|
|
+ if (!fileName.contains('.')) {
|
|
|
+ final mimeType = await getMimeTypeAccurate(url); // 需要网络请求获取Content-Type
|
|
|
+ if (mimeType != null) {
|
|
|
+ final ext = mimeType.split('/').last;
|
|
|
+ fileName = '$fileName.$ext';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return fileName;
|
|
|
+ }
|
|
|
+
|
|
|
+ static String _randomFileName() {
|
|
|
+ const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
|
+ final random = Random.secure();
|
|
|
+ final result = StringBuffer();
|
|
|
+
|
|
|
+ for (int i = 0; i < 16; i++) {
|
|
|
+ final index = random.nextInt(charset.length);
|
|
|
+ result.write(charset[index]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return '${result.toString()}${DateTime.now().millisecondsSinceEpoch}';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 或者结合网络请求获取更准确的结果
|
|
|
+ static Future<String?> getMimeTypeAccurate(String url) async {
|
|
|
+ // 先尝试从扩展名获取
|
|
|
+ final fromExtension = lookupMimeType(url);
|
|
|
+
|
|
|
+ // 如果无法确定或想验证,可以发送HEAD请求
|
|
|
+ try {
|
|
|
+ final response = await Dio().head(url);
|
|
|
+ final fromHeader = response.headers
|
|
|
+ .value('content-type')
|
|
|
+ ?.split(';')
|
|
|
+ .first;
|
|
|
+ return fromHeader ?? fromExtension;
|
|
|
+ } catch (e) {
|
|
|
+ return fromExtension; // 回退到扩展名推断
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+}
|
|
|
+
|