b02daf1448
- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、Ctrl/Cmd+F搜索、Esc 关闭搜索、鼠标右滑返回上级。 - 移动端同步状态页、同步统计卡片、同步任务/累计统计事件、同步页布局更新。 - Android 相册同步默认路径改为 DCIM/Camera,下载路径改用 external_path。 - Rust sync-core 更新:Android 日志依赖、AlbumUpload/AlbumDownload、MirrorWcf 统计修复、Linux FUSE镜像挂载与读写同步支持。 - 依赖更新:external_path、fl_chart、pdfrx 升级,并修了 pdfrx 新版本的deprecated API。 - 新增文件包括:sort_options.dart、sync_page_android.dart、sync_stats_card.dart、platform/fuse.rs、sync_engine/fuse.rs。
474 lines
14 KiB
Dart
474 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'package:background_downloader/background_downloader.dart' as bd;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:external_path/external_path.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import '../core/constants/storage_keys.dart';
|
|
import '../data/models/download_task_model.dart';
|
|
import 'file_service.dart';
|
|
import 'storage_service.dart';
|
|
import '../core/utils/app_logger.dart';
|
|
|
|
/// 下载服务 - 单例模式
|
|
/// 所有平台统一使用 background_downloader
|
|
class DownloadService {
|
|
static final DownloadService _instance = DownloadService._internal();
|
|
factory DownloadService() => _instance;
|
|
|
|
DownloadService._internal();
|
|
|
|
// 统一映射:外部下载器 task ID → 内部 task ID
|
|
final Map<String, String> _externalTaskIdToInternalId = {};
|
|
final Map<String, String> _internalIdToExternalTaskId = {};
|
|
|
|
// 存储 background_downloader 的 DownloadTask 对象,用于暂停/恢复/取消
|
|
final Map<String, bd.DownloadTask> _bdTasks = {};
|
|
|
|
// 回调处理器
|
|
static Function(String taskId, DownloadStatus status, int progress)?
|
|
_callbackHandler;
|
|
|
|
final FileService _fileService = FileService();
|
|
final Map<String, StreamController<DownloadTaskModel>> _progressControllers =
|
|
{};
|
|
bool _isInitialized = false;
|
|
|
|
/// 设置回调处理器
|
|
static void setCallbackHandler(
|
|
Function(String taskId, DownloadStatus status, int progress) handler,
|
|
) {
|
|
_callbackHandler = handler;
|
|
}
|
|
|
|
/// 获取下载任务进度流
|
|
Stream<DownloadTaskModel> getProgressStream(String taskId) {
|
|
if (!_progressControllers.containsKey(taskId)) {
|
|
_progressControllers[taskId] =
|
|
StreamController<DownloadTaskModel>.broadcast();
|
|
}
|
|
return _progressControllers[taskId]!.stream;
|
|
}
|
|
|
|
/// 获取下载目录
|
|
Future<Directory> getDownloadDirectory() async {
|
|
if (defaultTargetPlatform == TargetPlatform.android) {
|
|
if (await Permission.notification.isDenied) {
|
|
await Permission.notification.request();
|
|
}
|
|
final status = await Permission.manageExternalStorage.request();
|
|
|
|
if (status.isPermanentlyDenied) {
|
|
throw Exception('存储权限被永久拒绝,请在设置中开启');
|
|
}
|
|
|
|
if (!status.isGranted) {
|
|
throw Exception('存储权限被拒绝');
|
|
}
|
|
|
|
final downloadPath = await ExternalPath.getExternalStoragePublicDirectory(
|
|
ExternalPath.DIRECTORY_DOWNLOAD,
|
|
);
|
|
final directory = Directory(downloadPath);
|
|
if (!await directory.exists()) {
|
|
await directory.create(recursive: true);
|
|
}
|
|
return directory;
|
|
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
|
|
final appDocDir = await getApplicationDocumentsDirectory();
|
|
final directory = Directory('${appDocDir.path}/Downloads');
|
|
if (!await directory.exists()) {
|
|
await directory.create(recursive: true);
|
|
}
|
|
return directory;
|
|
} else {
|
|
// Windows/Linux/macOS - 使用系统下载目录
|
|
final downloadsDir = await getDownloadsDirectory();
|
|
if (downloadsDir != null) {
|
|
return downloadsDir;
|
|
}
|
|
// 回退方案
|
|
if (Platform.isWindows) {
|
|
final userProfile = Platform.environment['USERPROFILE'] ?? '';
|
|
final dir = Directory('$userProfile\\Downloads');
|
|
if (!await dir.exists()) {
|
|
await dir.create(recursive: true);
|
|
}
|
|
return dir;
|
|
}
|
|
final home = Platform.environment['HOME'] ?? '';
|
|
final dir = Directory('$home/Downloads');
|
|
if (!await dir.exists()) {
|
|
await dir.create(recursive: true);
|
|
}
|
|
return dir;
|
|
}
|
|
}
|
|
|
|
/// 读取 WiFi-only 下载设置
|
|
Future<bool> isWifiOnlyEnabled() async {
|
|
return await StorageService.instance.getBool(
|
|
StorageKeys.downloadWifiOnly,
|
|
) ??
|
|
false;
|
|
}
|
|
|
|
/// 读取重试次数设置
|
|
Future<int> getRetries() async {
|
|
return await StorageService.instance.getInt(StorageKeys.downloadRetries) ??
|
|
3;
|
|
}
|
|
|
|
/// 初始化下载器
|
|
Future<void> initialize({
|
|
Function(String taskId, DownloadStatus status, int progress)?
|
|
callbackHandler,
|
|
}) async {
|
|
if (callbackHandler != null) {
|
|
setCallbackHandler(callbackHandler);
|
|
AppLogger.d('回调处理器已更新');
|
|
}
|
|
|
|
if (_isInitialized) {
|
|
AppLogger.d('DownloadService 已经初始化');
|
|
return;
|
|
}
|
|
|
|
// 配置通知(Android 前台服务需要通知栏显示)
|
|
if (Platform.isAndroid) {
|
|
bd.FileDownloader().configureNotification(
|
|
running: const bd.TaskNotification(
|
|
'正在下载',
|
|
'文件: {filename} - {progress}',
|
|
),
|
|
complete: const bd.TaskNotification('下载完成', '文件: {filename} 已保存'),
|
|
error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'),
|
|
paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'),
|
|
progressBar: true,
|
|
tapOpensFile: true,
|
|
);
|
|
AppLogger.d('background_downloader 通知已配置');
|
|
}
|
|
|
|
bd.FileDownloader().registerCallbacks(
|
|
taskStatusCallback: _handleBdStatusUpdate,
|
|
taskProgressCallback: _handleBdProgressUpdate,
|
|
);
|
|
|
|
// 启动任务追踪和恢复
|
|
await bd.FileDownloader().start(
|
|
doTrackTasks: true,
|
|
markDownloadedComplete: true,
|
|
doRescheduleKilledTasks: true,
|
|
);
|
|
|
|
_isInitialized = true;
|
|
AppLogger.d('DownloadService 初始化完成 (background_downloader)');
|
|
}
|
|
|
|
/// background_downloader 状态回调
|
|
void _handleBdStatusUpdate(bd.TaskStatusUpdate update) {
|
|
final internalId = _externalTaskIdToInternalId[update.task.taskId];
|
|
// 如果映射不存在,尝试从 metaData 恢复
|
|
if (internalId == null && update.task.metaData.isNotEmpty) {
|
|
final metaInternalId = update.task.metaData;
|
|
_externalTaskIdToInternalId[update.task.taskId] = metaInternalId;
|
|
_internalIdToExternalTaskId[metaInternalId] = update.task.taskId;
|
|
_bdTasks[metaInternalId] = update.task as bd.DownloadTask;
|
|
AppLogger.d(
|
|
'从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId',
|
|
);
|
|
}
|
|
|
|
final resolvedInternalId = _externalTaskIdToInternalId[update.task.taskId];
|
|
if (resolvedInternalId == null) {
|
|
AppLogger.d(
|
|
'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}',
|
|
);
|
|
return;
|
|
}
|
|
|
|
DownloadStatus status;
|
|
switch (update.status) {
|
|
case bd.TaskStatus.enqueued:
|
|
status = DownloadStatus.waiting;
|
|
case bd.TaskStatus.running:
|
|
status = DownloadStatus.downloading;
|
|
case bd.TaskStatus.complete:
|
|
status = DownloadStatus.completed;
|
|
case bd.TaskStatus.notFound:
|
|
case bd.TaskStatus.failed:
|
|
status = DownloadStatus.failed;
|
|
case bd.TaskStatus.canceled:
|
|
status = DownloadStatus.cancelled;
|
|
case bd.TaskStatus.paused:
|
|
status = DownloadStatus.paused;
|
|
case bd.TaskStatus.waitingToRetry:
|
|
status = DownloadStatus.waiting;
|
|
}
|
|
|
|
AppLogger.d(
|
|
'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status',
|
|
);
|
|
|
|
final progress = status == DownloadStatus.completed ? 100 : 0;
|
|
_callbackHandler?.call(resolvedInternalId, status, progress);
|
|
}
|
|
|
|
/// background_downloader 进度回调
|
|
void _handleBdProgressUpdate(bd.TaskProgressUpdate update) {
|
|
final internalId = _externalTaskIdToInternalId[update.task.taskId];
|
|
if (internalId == null) return;
|
|
|
|
final progress = (update.progress * 100).toInt().clamp(0, 100);
|
|
_callbackHandler?.call(internalId, DownloadStatus.downloading, progress);
|
|
}
|
|
|
|
/// 开始下载(新任务)
|
|
Future<String?> startDownload(DownloadTaskModel task) async {
|
|
try {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
// 获取下载 URL
|
|
String url = task.downloadUrl ?? '';
|
|
if (url.isEmpty) {
|
|
final response = await _fileService.getDownloadUrls(
|
|
uris: [task.fileUri],
|
|
download: true,
|
|
);
|
|
|
|
final urls = response['urls'] as List<dynamic>? ?? [];
|
|
if (urls.isEmpty) {
|
|
throw Exception('无法获取下载链接');
|
|
}
|
|
|
|
final urlData = urls[0] as Map<String, dynamic>;
|
|
url = urlData['url'] as String;
|
|
}
|
|
|
|
// 确保保存目录存在
|
|
final file = File(task.savePath);
|
|
final dir = file.parent;
|
|
if (!await dir.exists()) {
|
|
await dir.create(recursive: true);
|
|
}
|
|
|
|
// 新任务:如果完整文件已存在,删除它
|
|
if (await file.exists() && task.downloadedBytes == 0) {
|
|
await file.delete();
|
|
}
|
|
|
|
return _startBdDownload(task, url, dir);
|
|
} catch (e) {
|
|
AppLogger.d('下载失败: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 使用 background_downloader 开始下载
|
|
Future<String?> _startBdDownload(
|
|
DownloadTaskModel task,
|
|
String url,
|
|
Directory dir,
|
|
) async {
|
|
final wifiOnly = await isWifiOnlyEnabled();
|
|
final retries = await getRetries();
|
|
final bdTask = bd.DownloadTask(
|
|
url: url,
|
|
filename: task.fileName,
|
|
directory: dir.path,
|
|
baseDirectory: bd.BaseDirectory.root,
|
|
updates: bd.Updates.statusAndProgress,
|
|
allowPause: true,
|
|
retries: retries,
|
|
requiresWiFi: wifiOnly,
|
|
metaData: task.id,
|
|
);
|
|
|
|
final success = await bd.FileDownloader().enqueue(bdTask);
|
|
|
|
if (!success) {
|
|
throw Exception('创建下载任务失败');
|
|
}
|
|
|
|
// 保存映射关系
|
|
_externalTaskIdToInternalId[bdTask.taskId] = task.id;
|
|
_internalIdToExternalTaskId[task.id] = bdTask.taskId;
|
|
_bdTasks[task.id] = bdTask;
|
|
|
|
// 保存 backgroundTaskId 到任务模型(持久化,用于重启后恢复映射)
|
|
task.backgroundTaskId = bdTask.taskId;
|
|
|
|
AppLogger.d(
|
|
'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries',
|
|
);
|
|
|
|
return bdTask.taskId;
|
|
}
|
|
|
|
/// 恢复下载(用于重启后恢复暂停的任务)
|
|
Future<String?> resumeDownloadAfterRestart(DownloadTaskModel task) async {
|
|
try {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
|
|
// 获取下载 URL
|
|
String url = task.downloadUrl ?? '';
|
|
if (url.isEmpty) {
|
|
final response = await _fileService.getDownloadUrls(
|
|
uris: [task.fileUri],
|
|
download: true,
|
|
);
|
|
|
|
final urls = response['urls'] as List<dynamic>? ?? [];
|
|
if (urls.isEmpty) {
|
|
throw Exception('无法获取下载链接');
|
|
}
|
|
|
|
final urlData = urls[0] as Map<String, dynamic>;
|
|
url = urlData['url'] as String;
|
|
}
|
|
|
|
final file = File(task.savePath);
|
|
final dir = file.parent;
|
|
if (!await dir.exists()) {
|
|
await dir.create(recursive: true);
|
|
}
|
|
|
|
// 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask
|
|
final wifiOnly = await isWifiOnlyEnabled();
|
|
final retries = await getRetries();
|
|
final bdTask = bd.DownloadTask(
|
|
url: url,
|
|
filename: task.fileName,
|
|
directory: dir.path,
|
|
baseDirectory: bd.BaseDirectory.root,
|
|
updates: bd.Updates.statusAndProgress,
|
|
allowPause: true,
|
|
retries: retries,
|
|
requiresWiFi: wifiOnly,
|
|
metaData: task.id,
|
|
);
|
|
|
|
// 恢复映射关系
|
|
_externalTaskIdToInternalId[bdTask.taskId] = task.id;
|
|
_internalIdToExternalTaskId[task.id] = bdTask.taskId;
|
|
_bdTasks[task.id] = bdTask;
|
|
task.backgroundTaskId = bdTask.taskId;
|
|
|
|
// 如果有已下载的部分,尝试 resume;否则 enqueue
|
|
final partialFile = File('${dir.path}/${task.fileName}.part');
|
|
if (task.downloadedBytes > 0 && await partialFile.exists()) {
|
|
AppLogger.d(
|
|
'断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes',
|
|
);
|
|
await bd.FileDownloader().resume(bdTask);
|
|
} else {
|
|
AppLogger.d('重新下载: ${task.fileName}');
|
|
final success = await bd.FileDownloader().enqueue(bdTask);
|
|
if (!success) {
|
|
throw Exception('创建下载任务失败');
|
|
}
|
|
}
|
|
|
|
return bdTask.taskId;
|
|
} catch (e) {
|
|
AppLogger.d('恢复下载失败: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// 暂停下载
|
|
Future<void> pauseDownload(String taskId) async {
|
|
final bdTask = _bdTasks[taskId];
|
|
if (bdTask != null) {
|
|
await bd.FileDownloader().pause(bdTask);
|
|
}
|
|
}
|
|
|
|
/// 恢复下载
|
|
Future<void> resumeDownload(String taskId) async {
|
|
if (!_isInitialized) {
|
|
await initialize();
|
|
}
|
|
final bdTask = _bdTasks[taskId];
|
|
if (bdTask != null) {
|
|
await bd.FileDownloader().resume(bdTask);
|
|
}
|
|
}
|
|
|
|
/// 取消下载
|
|
Future<void> cancelDownload(String taskId) async {
|
|
final bdTask = _bdTasks[taskId];
|
|
if (bdTask != null) {
|
|
await bd.FileDownloader().cancel(bdTask);
|
|
}
|
|
}
|
|
|
|
/// 删除下载任务
|
|
void disposeTask(String taskId) {
|
|
final externalId = _internalIdToExternalTaskId[taskId];
|
|
if (externalId != null) {
|
|
_externalTaskIdToInternalId.remove(externalId);
|
|
}
|
|
_internalIdToExternalTaskId.remove(taskId);
|
|
_bdTasks.remove(taskId);
|
|
|
|
// 关闭进度流
|
|
final controller = _progressControllers[taskId];
|
|
if (controller != null) {
|
|
controller.close();
|
|
_progressControllers.remove(taskId);
|
|
}
|
|
}
|
|
|
|
/// 删除已下载的文件
|
|
Future<void> deleteDownloadedFile(String savePath) async {
|
|
try {
|
|
final file = File(savePath);
|
|
if (await file.exists()) {
|
|
await file.delete();
|
|
}
|
|
// 同时删除部分文件
|
|
final partialFile = File('$savePath.part');
|
|
if (await partialFile.exists()) {
|
|
await partialFile.delete();
|
|
}
|
|
} catch (e) {
|
|
AppLogger.d('删除文件失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 获取可读的文件大小
|
|
static String getReadableFileSize(int bytes) {
|
|
if (bytes < 1024) {
|
|
return '$bytes B';
|
|
} else if (bytes < 1024 * 1024) {
|
|
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
} else if (bytes < 1024 * 1024 * 1024) {
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
} else {
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
}
|
|
|
|
/// 清理所有资源
|
|
void dispose() {
|
|
_externalTaskIdToInternalId.clear();
|
|
_internalIdToExternalTaskId.clear();
|
|
_bdTasks.clear();
|
|
|
|
// 关闭所有流
|
|
for (final controller in _progressControllers.values) {
|
|
if (!controller.isClosed) {
|
|
controller.close();
|
|
}
|
|
}
|
|
_progressControllers.clear();
|
|
}
|
|
}
|