feat: 添加云盘与消息中心功能
This commit is contained in:
@ -0,0 +1,44 @@
|
||||
//
|
||||
// CloudTransferJSONStore.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// JSON 文件实现的云盘传输任务存储。
|
||||
final class CloudTransferJSONStore: CloudTransferTaskStoring {
|
||||
private let fileURL: URL
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
/// 初始化本地 JSON 存储。
|
||||
init(fileURL: URL? = nil) {
|
||||
if let fileURL {
|
||||
self.fileURL = fileURL
|
||||
} else {
|
||||
let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.temporaryDirectory
|
||||
self.fileURL = baseURL
|
||||
.appendingPathComponent("suixinkan", isDirectory: true)
|
||||
.appendingPathComponent("cloud_transfer_tasks.json")
|
||||
}
|
||||
}
|
||||
|
||||
func loadTasks() -> [CloudTransferTask] {
|
||||
guard let data = try? Data(contentsOf: fileURL) else { return [] }
|
||||
return (try? decoder.decode([CloudTransferTask].self, from: data)) ?? []
|
||||
}
|
||||
|
||||
func saveTasks(_ tasks: [CloudTransferTask]) {
|
||||
do {
|
||||
let directory = fileURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let data = try encoder.encode(tasks)
|
||||
try data.write(to: fileURL, options: [.atomic])
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("CloudTransferJSONStore save failed: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,300 @@
|
||||
//
|
||||
// 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.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>] = [:]
|
||||
|
||||
/// 初始化传输管理器。
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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]
|
||||
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()
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.updateUploadProgress(id: id, progress: progress)
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
try await api.uploadCloudFile(
|
||||
parentFolderId: seed.parentFolderId,
|
||||
fileUrl: uploadedURL,
|
||||
fileName: seed.fileName
|
||||
)
|
||||
try? FileManager.default.removeItem(atPath: seed.localPath)
|
||||
runningTasks[id] = nil
|
||||
uploadTasks.removeAll { $0.id == id }
|
||||
persistAndNotify()
|
||||
onUploadCompleted?(seed.parentFolderId)
|
||||
} catch is CancellationError {
|
||||
runningTasks[id] = nil
|
||||
markUploadPaused(id: id)
|
||||
} catch {
|
||||
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]
|
||||
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
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.updateDownloadProgress(id: id, progress: progress)
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
try await photoSaver.saveToPhotoLibrary(fileURL: localURL, fileType: seed.fileType)
|
||||
try? FileManager.default.removeItem(at: localURL)
|
||||
runningTasks[id] = nil
|
||||
updateDownloadTask(id: id) { task in
|
||||
task.status = .completed
|
||||
task.progress = 100
|
||||
task.updatedAt = Date().timeIntervalSince1970
|
||||
}
|
||||
} catch is CancellationError {
|
||||
runningTasks[id] = nil
|
||||
markDownloadPaused(id: id)
|
||||
} catch {
|
||||
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 extension Comparable {
|
||||
func coerceIn(_ range: ClosedRange<Self>) -> Self {
|
||||
min(max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
//
|
||||
// CloudTransferModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 云盘传输任务类型。
|
||||
enum CloudTransferKind: String, Codable, Sendable {
|
||||
case upload
|
||||
case download
|
||||
}
|
||||
|
||||
/// 云盘传输任务状态。
|
||||
enum CloudTransferStatus: String, Codable, Sendable {
|
||||
case pending
|
||||
case uploading
|
||||
case downloading
|
||||
case paused
|
||||
case failed
|
||||
case completed
|
||||
|
||||
/// 展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .pending:
|
||||
"等待中"
|
||||
case .uploading:
|
||||
"上传中"
|
||||
case .downloading:
|
||||
"下载中"
|
||||
case .paused:
|
||||
"已暂停"
|
||||
case .failed:
|
||||
"失败"
|
||||
case .completed:
|
||||
"已完成"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘本地传输任务记录。
|
||||
struct CloudTransferTask: Codable, Sendable, Equatable, Hashable, Identifiable {
|
||||
let id: String
|
||||
let kind: CloudTransferKind
|
||||
let fileName: String
|
||||
let fileType: Int
|
||||
let fileSize: Int64
|
||||
let parentFolderId: Int
|
||||
let localPath: String
|
||||
let remoteURL: String
|
||||
var status: CloudTransferStatus
|
||||
var progress: Int
|
||||
var errorMessage: String
|
||||
var createdAt: TimeInterval
|
||||
var updatedAt: TimeInterval
|
||||
|
||||
/// 创建传输任务。
|
||||
init(
|
||||
id: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""),
|
||||
kind: CloudTransferKind,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
fileSize: Int64,
|
||||
parentFolderId: Int = 0,
|
||||
localPath: String = "",
|
||||
remoteURL: String = "",
|
||||
status: CloudTransferStatus = .pending,
|
||||
progress: Int = 0,
|
||||
errorMessage: String = "",
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970,
|
||||
updatedAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.fileName = fileName
|
||||
self.fileType = fileType
|
||||
self.fileSize = fileSize
|
||||
self.parentFolderId = parentFolderId
|
||||
self.localPath = localPath
|
||||
self.remoteURL = remoteURL
|
||||
self.status = status
|
||||
self.progress = progress
|
||||
self.errorMessage = errorMessage
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘传输任务持久化协议。
|
||||
protocol CloudTransferTaskStoring: AnyObject {
|
||||
/// 读取全部本地任务。
|
||||
func loadTasks() -> [CloudTransferTask]
|
||||
|
||||
/// 保存全部本地任务。
|
||||
func saveTasks(_ tasks: [CloudTransferTask])
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
//
|
||||
// CloudTransferWorkers.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
|
||||
/// 云盘 OSS 上传协议,便于传输管理注入测试替身。
|
||||
@MainActor
|
||||
protocol CloudDriveUploading {
|
||||
/// 上传云盘文件并返回远程 URL。
|
||||
func uploadCloudDriveFile(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String
|
||||
}
|
||||
|
||||
extension OSSUploadService: CloudDriveUploading {}
|
||||
|
||||
/// 云盘下载协议。
|
||||
protocol CloudFileDownloading {
|
||||
/// 下载远程文件并返回本地临时文件 URL。
|
||||
func downloadFile(
|
||||
remoteURL: String,
|
||||
fileName: String,
|
||||
onProgress: @escaping @Sendable (Int) -> Void
|
||||
) async throws -> URL
|
||||
}
|
||||
|
||||
/// URLSession 实现的云盘下载器。
|
||||
struct CloudURLSessionDownloader: CloudFileDownloading {
|
||||
func downloadFile(
|
||||
remoteURL: String,
|
||||
fileName: String,
|
||||
onProgress: @escaping @Sendable (Int) -> Void
|
||||
) async throws -> URL {
|
||||
guard let url = URL(string: remoteURL) else {
|
||||
throw CloudTransferError.invalidURL
|
||||
}
|
||||
onProgress(1)
|
||||
let (temporaryURL, _) = try await URLSession.shared.download(from: url)
|
||||
try Task.checkCancellation()
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("cloud_drive_downloads", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let destinationURL = directory.appendingPathComponent(CloudTransferFileName.sanitized(fileName))
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
try FileManager.default.moveItem(at: temporaryURL, to: destinationURL)
|
||||
onProgress(100)
|
||||
return destinationURL
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘下载后的系统相册保存协议。
|
||||
protocol CloudPhotoSaving {
|
||||
/// 将图片或视频保存到系统相册。
|
||||
func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws
|
||||
}
|
||||
|
||||
/// Photos 框架实现的相册保存器。
|
||||
struct CloudPhotoLibrarySaver: CloudPhotoSaving {
|
||||
func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws {
|
||||
let status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
|
||||
guard status == .authorized || status == .limited else {
|
||||
throw CloudTransferError.photoPermissionDenied
|
||||
}
|
||||
|
||||
try await PHPhotoLibrary.shared().performChanges {
|
||||
if fileType == 1 {
|
||||
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: fileURL)
|
||||
} else {
|
||||
PHAssetChangeRequest.creationRequestForAssetFromImage(atFileURL: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘传输错误。
|
||||
enum CloudTransferError: LocalizedError, Equatable {
|
||||
case invalidURL
|
||||
case localFileMissing
|
||||
case photoPermissionDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
"文件地址不存在"
|
||||
case .localFileMissing:
|
||||
"本地文件不存在"
|
||||
case .photoPermissionDenied:
|
||||
"请允许访问相册后再下载"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 云盘传输文件名工具。
|
||||
enum CloudTransferFileName {
|
||||
/// 清理文件名中不适合落盘的字符。
|
||||
static func sanitized(_ fileName: String) -> String {
|
||||
let trimmed = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var unsafe = CharacterSet.controlCharacters
|
||||
unsafe.formUnion(CharacterSet(charactersIn: "/\\"))
|
||||
let value = trimmed.unicodeScalars.reduce(into: "") { result, scalar in
|
||||
result += unsafe.contains(scalar) ? "_" : String(scalar)
|
||||
}
|
||||
return value.isEmpty ? "cloud_file" : value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user