import 'dart:io'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../app/data/models/launch/smart_geo.dart'; import 'log/logger.dart'; /// 地理数据下载工具类 class GeoDownloader { static const String TAG = 'GeoDownloader'; static final GeoDownloader _instance = GeoDownloader._internal(); factory GeoDownloader() => _instance; GeoDownloader._internal(); final Dio _dio = Dio( BaseOptions( connectTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(minutes: 5), sendTimeout: const Duration(seconds: 30), ), ); /// 获取 geo 文件目录(包名/files/geo) Future _getGeoDirectory() async { final appDir = await getFilesDir(); // 使用 files/geo 路径 final geoDir = Directory(path.join(appDir.path, 'geo')); if (!await geoDir.exists()) { await geoDir.create(recursive: true); } return geoDir; } /// 获取文件目录 Future getFilesDir() async { if (Platform.isAndroid || Platform.isWindows) { return await getApplicationSupportDirectory(); } else { // iOS fallback return await getApplicationDocumentsDirectory(); } } /// 计算文件的 MD5 Future _calculateFileMd5(String filePath) async { try { final file = File(filePath); if (!await file.exists()) { return null; } final bytes = await file.readAsBytes(); final digest = md5.convert(bytes); return digest.toString(); } catch (e) { log(TAG, 'Error calculating MD5: $e'); return null; } } /// 下载并解压 zip 文件 /// [url] 下载地址 /// [expectedMd5] 期望的 MD5 值(zip 文件的 MD5) /// [targetFileName] 解压后的目标文件名(不含扩展名) /// [onProgress] 下载进度回调 (0.0 - 1.0) /// 返回解压后的文件路径,如果不需要下载则返回现有文件路径 Future downloadAndExtract({ required String url, required String expectedMd5, required String targetFileName, Function(double)? onProgress, }) async { try { final geoDir = await _getGeoDirectory(); final targetFilePath = path.join(geoDir.path, targetFileName); final zipFilePath = path.join(geoDir.path, '$targetFileName.zip'); final zipFile = File(zipFilePath); // 检查 zip 文件是否存在且 MD5 匹配 bool needDownload = true; if (await zipFile.exists()) { final zipMd5 = await _calculateFileMd5(zipFilePath); log( TAG, '$targetFileName.zip local MD5: $zipMd5, expected: $expectedMd5', ); if (zipMd5 != null && zipMd5.toLowerCase() == expectedMd5.toLowerCase()) { log( TAG, '$targetFileName.zip already exists with correct MD5, skip download', ); needDownload = false; onProgress?.call(0.7); // 跳过下载,直接到解压阶段 } else { log(TAG, '$targetFileName.zip MD5 mismatch, will download again'); // MD5 不匹配,删除旧的 zip 文件 await zipFile.delete(); } } // 下载 zip 文件(如果需要) if (needDownload) { log(TAG, 'Downloading $targetFileName from $url'); try { final response = await _dio.download( url, zipFilePath, onReceiveProgress: (received, total) { if (total != -1 && onProgress != null) { // 下载进度占 70% onProgress(received / total * 0.7); } }, ); log(TAG, 'Download response status: ${response.statusCode}'); if (response.statusCode != 200) { log( TAG, 'Error: Download failed with status code ${response.statusCode}', ); return null; } } catch (e) { log(TAG, 'Error during download: $e'); // 清理可能产生的不完整文件 if (await zipFile.exists()) { await zipFile.delete(); } return null; } log(TAG, 'Downloaded to $zipFilePath'); // 验证下载的文件是否存在 if (!await zipFile.exists()) { log(TAG, 'Error: Downloaded zip file does not exist'); return null; } // 检查文件大小 final fileSize = await zipFile.length(); log(TAG, 'Downloaded zip file size: $fileSize bytes'); if (fileSize == 0) { log(TAG, 'Error: Downloaded zip file is empty'); await zipFile.delete(); return null; } onProgress?.call(0.7); } // 读取 zip 文件 final zipBytes = await zipFile.readAsBytes(); // 解压 log(TAG, 'Extracting $targetFileName...'); final archive = ZipDecoder().decodeBytes(zipBytes); bool extracted = false; // 查找目标文件(去掉 .zip 后缀的文件名) for (final file in archive) { if (file.isFile) { final fileName = file.name; log(TAG, 'Found file in archive: $fileName'); // 忽略 macOS 元数据文件和隐藏文件 if (fileName.startsWith('__MACOSX/') || fileName.startsWith('.') || path.basename(fileName).startsWith('._')) { log(TAG, 'Skipping system file: $fileName'); continue; } // 只提取 .dat 文件到根目录 if (fileName.endsWith('.dat')) { // 提取文件内容 final fileData = file.content as List; // 使用 basename 确保文件保存在根目录 final extractedFile = File( path.join(geoDir.path, path.basename(fileName)), ); await extractedFile.writeAsBytes(fileData); log(TAG, 'Extracted to ${extractedFile.path}'); extracted = true; } } } if (!extracted) { log(TAG, 'Warning: No .dat file found in archive'); } onProgress?.call(0.9); // 保留 zip 文件(不删除,下次启动可以通过 MD5 判断是否需要重新下载) log(TAG, 'Keeping zip file for future MD5 validation'); // 验证解压后的文件是否存在 final targetFile = File(targetFilePath); if (!await targetFile.exists()) { log(TAG, 'Error: Target file does not exist after extraction'); onProgress?.call(1.0); return null; } // 记录成功信息 final fileSize = await targetFile.length(); log(TAG, '$targetFileName extracted successfully, size: $fileSize bytes'); log(TAG, 'Zip file MD5: $expectedMd5 (validated)'); onProgress?.call(1.0); return targetFilePath; } catch (e) { log(TAG, 'Error downloading/extracting $targetFileName: $e'); // 注意:不删除 zip 文件,因为: // 1. 如果 zip 已经 MD5 验证通过,不应该删除 // 2. 如果下载失败,在下载环节已经清理过了 // 3. 保留 zip 文件便于调试和下次快速恢复 return null; } } /// 下载 SmartGeo 数据(geosite 和 geoip) /// [smartGeo] SmartGeo 配置数据 /// [onGeoSiteProgress] geosite 下载进度回调 /// [onGeoIpProgress] geoip 下载进度回调 /// 返回 Map,包含 geosite 和 geoip 的文件路径 Future> downloadSmartGeo({ required SmartGeo smartGeo, Function(double)? onGeoSiteProgress, Function(double)? onGeoIpProgress, }) async { final result = {}; // 下载 geosite.dat if (smartGeo.geoSiteUrl != null && smartGeo.geoSiteMd5 != null) { log(TAG, 'Downloading geosite.dat...'); final geoSitePath = await downloadAndExtract( url: smartGeo.geoSiteUrl!, expectedMd5: smartGeo.geoSiteMd5!, targetFileName: 'geosite.dat', onProgress: onGeoSiteProgress, ); result['geosite'] = geoSitePath; } // 下载 geoip.dat if (smartGeo.geoIpUrl != null && smartGeo.geoIpMd5 != null) { log(TAG, 'Downloading geoip.dat...'); final geoIpPath = await downloadAndExtract( url: smartGeo.geoIpUrl!, expectedMd5: smartGeo.geoIpMd5!, targetFileName: 'geoip.dat', onProgress: onGeoIpProgress, ); result['geoip'] = geoIpPath; } return result; } /// 检查 SmartGeo 文件是否需要更新 /// [smartGeo] SmartGeo 配置数据 /// 返回 Map,key 为文件类型(geosite/geoip),value 为是否需要更新 /// 注意:通过检查 zip 文件的 MD5 来判断是否需要更新 Future> checkNeedUpdate(SmartGeo smartGeo) async { final result = {}; try { final geoDir = await _getGeoDirectory(); // 检查 geosite.dat.zip if (smartGeo.geoSiteUrl != null && smartGeo.geoSiteMd5 != null) { final geoSiteZipFile = File(path.join(geoDir.path, 'geosite.dat.zip')); if (await geoSiteZipFile.exists()) { final localMd5 = await _calculateFileMd5(geoSiteZipFile.path); // 如果 MD5 不同,则需要更新 result['geosite'] = localMd5?.toLowerCase() != smartGeo.geoSiteMd5?.toLowerCase(); } else { // zip 文件不存在,需要下载 result['geosite'] = true; } } // 检查 geoip.dat.zip if (smartGeo.geoIpUrl != null && smartGeo.geoIpMd5 != null) { final geoIpZipFile = File(path.join(geoDir.path, 'geoip.dat.zip')); if (await geoIpZipFile.exists()) { final localMd5 = await _calculateFileMd5(geoIpZipFile.path); // 如果 MD5 不同,则需要更新 result['geoip'] = localMd5?.toLowerCase() != smartGeo.geoIpMd5?.toLowerCase(); } else { // zip 文件不存在,需要下载 result['geoip'] = true; } } } catch (e) { log(TAG, 'Error checking update: $e'); } return result; } /// 获取 geo 文件路径 /// [fileName] 文件名(如 'geosite.dat' 或 'geoip.dat') Future getGeoFilePath(String fileName) async { final geoDir = await _getGeoDirectory(); return path.join(geoDir.path, fileName); } /// 清除所有 geo 文件 Future clearGeoFiles() async { try { final geoDir = await _getGeoDirectory(); if (await geoDir.exists()) { await geoDir.delete(recursive: true); await geoDir.create(recursive: true); log(TAG, 'Cleared all geo files'); } } catch (e) { log(TAG, 'Error clearing geo files: $e'); } } /// 诊断工具:列出 geo 目录下的所有文件 Future> diagnose() async { try { final geoDir = await _getGeoDirectory(); final result = { 'geoDirectory': geoDir.path, 'directoryExists': await geoDir.exists(), 'files': >[], }; if (await geoDir.exists()) { await for (final entity in geoDir.list()) { if (entity is File) { final file = entity; final fileSize = await file.length(); final fileMd5 = await _calculateFileMd5(file.path); result['files'].add({ 'name': path.basename(file.path), 'path': file.path, 'size': fileSize, 'md5': fileMd5, }); } } } log(TAG, 'Diagnosis result: $result'); return result; } catch (e) { log(TAG, 'Error during diagnosis: $e'); return {'error': e.toString()}; } } }