重构排队设置与变更日志交互,补充 Overlay 与资源,并将多页面主操作按钮统一到 UIButton Configuration,同步更新相关单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
334 lines
12 KiB
Swift
334 lines
12 KiB
Swift
//
|
||
// CloudTransferManager.swift
|
||
// suixinkan
|
||
//
|
||
|
||
import Foundation
|
||
|
||
@MainActor
|
||
/// 云盘上传/下载传输管理器,负责本地任务、进度和服务端登记。
|
||
final class CloudTransferManager {
|
||
static let shared = CloudTransferManager(
|
||
store: CloudTransferJSONStore(),
|
||
uploader: NetworkServices.shared.ossUploadService,
|
||
api: NetworkServices.shared.cloudDriveAPI,
|
||
downloader: CloudURLSessionDownloader(),
|
||
photoSaver: CloudPhotoLibrarySaver(),
|
||
scenicIdProvider: { AppStore.shared.session.currentScenicId }
|
||
)
|
||
|
||
private(set) var uploadTasks: [CloudTransferTask] = []
|
||
private(set) var downloadTasks: [CloudTransferTask] = []
|
||
|
||
var onTasksChange: (() -> Void)?
|
||
var onUploadCompleted: ((Int) -> Void)?
|
||
var onShowMessage: ((String) -> Void)?
|
||
|
||
private let store: CloudTransferTaskStoring
|
||
private let uploader: any CloudDriveUploading
|
||
private let api: any CloudDriveServing
|
||
private let downloader: any CloudFileDownloading
|
||
private let photoSaver: any CloudPhotoSaving
|
||
private let scenicIdProvider: () -> Int
|
||
private var runningTasks: [String: Task<Void, Never>] = [:]
|
||
private var operationGenerations: [String: Int] = [:]
|
||
|
||
/// 初始化传输管理器。
|
||
init(
|
||
store: CloudTransferTaskStoring,
|
||
uploader: any CloudDriveUploading,
|
||
api: any CloudDriveServing,
|
||
downloader: any CloudFileDownloading,
|
||
photoSaver: any CloudPhotoSaving,
|
||
scenicIdProvider: @escaping () -> Int
|
||
) {
|
||
self.store = store
|
||
self.uploader = uploader
|
||
self.api = api
|
||
self.downloader = downloader
|
||
self.photoSaver = photoSaver
|
||
self.scenicIdProvider = scenicIdProvider
|
||
let tasks = store.loadTasks()
|
||
uploadTasks = tasks.filter { $0.kind == .upload }
|
||
downloadTasks = tasks.filter { $0.kind == .download }
|
||
}
|
||
|
||
/// 添加上传任务并立即开始。
|
||
func addUploadTask(
|
||
localFileURL: URL,
|
||
fileName: String,
|
||
fileType: Int,
|
||
fileSize: Int64,
|
||
parentFolderId: Int
|
||
) {
|
||
let task = CloudTransferTask(
|
||
kind: .upload,
|
||
fileName: fileName,
|
||
fileType: fileType,
|
||
fileSize: fileSize,
|
||
parentFolderId: parentFolderId,
|
||
localPath: localFileURL.path,
|
||
status: .pending
|
||
)
|
||
uploadTasks.insert(task, at: 0)
|
||
persistAndNotify()
|
||
startUpload(task.id)
|
||
onShowMessage?("已添加上传任务")
|
||
}
|
||
|
||
/// 添加下载任务并立即开始。
|
||
func addDownloadTask(fileURL: String, fileName: String, fileType: Int, fileSize: Int64 = 0) {
|
||
let task = CloudTransferTask(
|
||
kind: .download,
|
||
fileName: fileName,
|
||
fileType: fileType,
|
||
fileSize: fileSize,
|
||
remoteURL: fileURL,
|
||
status: .pending
|
||
)
|
||
downloadTasks.insert(task, at: 0)
|
||
persistAndNotify()
|
||
startDownload(task.id)
|
||
onShowMessage?("已添加下载任务,可在传输管理查看")
|
||
}
|
||
|
||
/// 暂停上传任务。
|
||
func pauseUpload(id: String) {
|
||
runningTasks[id]?.cancel()
|
||
runningTasks[id] = nil
|
||
invalidateOperation(id: id)
|
||
updateUploadTask(id: id) { task in
|
||
task.status = .paused
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
/// 继续上传任务。
|
||
func resumeUpload(id: String) {
|
||
startUpload(id)
|
||
}
|
||
|
||
/// 取消上传任务。
|
||
func cancelUpload(id: String) {
|
||
runningTasks[id]?.cancel()
|
||
runningTasks[id] = nil
|
||
operationGenerations[id] = nil
|
||
if let task = uploadTasks.first(where: { $0.id == id }), !task.localPath.isEmpty {
|
||
try? FileManager.default.removeItem(atPath: task.localPath)
|
||
}
|
||
uploadTasks.removeAll { $0.id == id }
|
||
persistAndNotify()
|
||
}
|
||
|
||
/// 暂停下载任务。
|
||
func pauseDownload(id: String) {
|
||
runningTasks[id]?.cancel()
|
||
runningTasks[id] = nil
|
||
invalidateOperation(id: id)
|
||
updateDownloadTask(id: id) { task in
|
||
task.status = .paused
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
/// 继续下载任务。
|
||
func resumeDownload(id: String) {
|
||
startDownload(id)
|
||
}
|
||
|
||
/// 取消下载任务。
|
||
func cancelDownload(id: String) {
|
||
runningTasks[id]?.cancel()
|
||
runningTasks[id] = nil
|
||
operationGenerations[id] = nil
|
||
downloadTasks.removeAll { $0.id == id }
|
||
persistAndNotify()
|
||
}
|
||
|
||
private func startUpload(_ id: String) {
|
||
guard runningTasks[id] == nil,
|
||
let index = uploadTasks.firstIndex(where: { $0.id == id }) else { return }
|
||
|
||
uploadTasks[index].status = .uploading
|
||
uploadTasks[index].errorMessage = ""
|
||
uploadTasks[index].progress = max(uploadTasks[index].progress, 1)
|
||
uploadTasks[index].updatedAt = Date().timeIntervalSince1970
|
||
let seed = uploadTasks[index]
|
||
let generation = nextOperationGeneration(id: id)
|
||
persistAndNotify()
|
||
|
||
runningTasks[id] = Task { [weak self] in
|
||
guard let self else { return }
|
||
do {
|
||
guard FileManager.default.fileExists(atPath: seed.localPath) else {
|
||
throw CloudTransferError.localFileMissing
|
||
}
|
||
let data = try Data(contentsOf: URL(fileURLWithPath: seed.localPath))
|
||
try Task.checkCancellation()
|
||
let uploadedURL = try await uploader.uploadCloudDriveFile(
|
||
data: data,
|
||
fileName: seed.fileName,
|
||
fileType: seed.fileType,
|
||
scenicId: scenicIdProvider()
|
||
) { progress in
|
||
Task { @MainActor [weak self] in
|
||
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
|
||
self.updateUploadProgress(id: id, progress: progress)
|
||
}
|
||
}
|
||
try Task.checkCancellation()
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
try await api.uploadCloudFile(
|
||
parentFolderId: seed.parentFolderId,
|
||
fileUrl: uploadedURL,
|
||
fileName: seed.fileName
|
||
)
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
try? FileManager.default.removeItem(atPath: seed.localPath)
|
||
runningTasks[id] = nil
|
||
operationGenerations[id] = nil
|
||
uploadTasks.removeAll { $0.id == id }
|
||
persistAndNotify()
|
||
onUploadCompleted?(seed.parentFolderId)
|
||
} catch is CancellationError {
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
runningTasks[id] = nil
|
||
markUploadPaused(id: id)
|
||
} catch {
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
runningTasks[id] = nil
|
||
markUploadFailed(id: id, message: error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func startDownload(_ id: String) {
|
||
guard runningTasks[id] == nil,
|
||
let index = downloadTasks.firstIndex(where: { $0.id == id }) else { return }
|
||
|
||
downloadTasks[index].status = .downloading
|
||
downloadTasks[index].errorMessage = ""
|
||
downloadTasks[index].progress = max(downloadTasks[index].progress, 1)
|
||
downloadTasks[index].updatedAt = Date().timeIntervalSince1970
|
||
let seed = downloadTasks[index]
|
||
let generation = nextOperationGeneration(id: id)
|
||
persistAndNotify()
|
||
|
||
runningTasks[id] = Task { [weak self] in
|
||
guard let self else { return }
|
||
do {
|
||
let localURL = try await downloader.downloadFile(
|
||
remoteURL: seed.remoteURL,
|
||
fileName: seed.fileName
|
||
) { progress in
|
||
Task { @MainActor [weak self] in
|
||
guard let self, self.isCurrentOperation(id: id, generation: generation) else { return }
|
||
self.updateDownloadProgress(id: id, progress: progress)
|
||
}
|
||
}
|
||
try Task.checkCancellation()
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
try await photoSaver.saveToPhotoLibrary(fileURL: localURL, fileType: seed.fileType)
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
try? FileManager.default.removeItem(at: localURL)
|
||
runningTasks[id] = nil
|
||
operationGenerations[id] = nil
|
||
updateDownloadTask(id: id) { task in
|
||
task.status = .completed
|
||
task.progress = 100
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
} catch is CancellationError {
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
runningTasks[id] = nil
|
||
markDownloadPaused(id: id)
|
||
} catch {
|
||
guard isCurrentOperation(id: id, generation: generation) else { return }
|
||
runningTasks[id] = nil
|
||
markDownloadFailed(id: id, message: error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateUploadProgress(id: String, progress: Int) {
|
||
updateUploadTask(id: id) { task in
|
||
task.progress = max(task.progress, progress.coerceIn(0 ... 100))
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
private func updateDownloadProgress(id: String, progress: Int) {
|
||
updateDownloadTask(id: id) { task in
|
||
task.progress = max(task.progress, progress.coerceIn(0 ... 100))
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
private func markUploadPaused(id: String) {
|
||
updateUploadTask(id: id) { task in
|
||
task.status = .paused
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
private func markDownloadPaused(id: String) {
|
||
updateDownloadTask(id: id) { task in
|
||
task.status = .paused
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
private func markUploadFailed(id: String, message: String) {
|
||
updateUploadTask(id: id) { task in
|
||
task.status = .failed
|
||
task.errorMessage = message
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
private func markDownloadFailed(id: String, message: String) {
|
||
updateDownloadTask(id: id) { task in
|
||
task.status = .failed
|
||
task.errorMessage = message
|
||
task.updatedAt = Date().timeIntervalSince1970
|
||
}
|
||
}
|
||
|
||
private func updateUploadTask(id: String, mutation: (inout CloudTransferTask) -> Void) {
|
||
guard let index = uploadTasks.firstIndex(where: { $0.id == id }) else { return }
|
||
mutation(&uploadTasks[index])
|
||
persistAndNotify()
|
||
}
|
||
|
||
private func updateDownloadTask(id: String, mutation: (inout CloudTransferTask) -> Void) {
|
||
guard let index = downloadTasks.firstIndex(where: { $0.id == id }) else { return }
|
||
mutation(&downloadTasks[index])
|
||
persistAndNotify()
|
||
}
|
||
|
||
private func persistAndNotify() {
|
||
store.saveTasks(uploadTasks + downloadTasks)
|
||
onTasksChange?()
|
||
}
|
||
|
||
private func nextOperationGeneration(id: String) -> Int {
|
||
let generation = (operationGenerations[id] ?? 0) + 1
|
||
operationGenerations[id] = generation
|
||
return generation
|
||
}
|
||
|
||
private func invalidateOperation(id: String) {
|
||
operationGenerations[id] = (operationGenerations[id] ?? 0) + 1
|
||
}
|
||
|
||
private func isCurrentOperation(id: String, generation: Int) -> Bool {
|
||
operationGenerations[id] == generation
|
||
}
|
||
}
|
||
|
||
private extension Comparable {
|
||
func coerceIn(_ range: ClosedRange<Self>) -> Self {
|
||
min(max(self, range.lowerBound), range.upperBound)
|
||
}
|
||
}
|