移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。
将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,18 @@
|
||||
//
|
||||
// CameraAssetUploadSink.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 本地照片上传登记入口,由旅拍相册模块实现 OSS 上传与后端登记。
|
||||
@MainActor
|
||||
protocol CameraAssetUploadSink: AnyObject {
|
||||
/// 上传本地文件并返回远端 URL。
|
||||
func upload(
|
||||
localURL: URL,
|
||||
fileName: String,
|
||||
fileType: Int,
|
||||
progress: @escaping @Sendable (Int) -> Void
|
||||
) async throws -> String
|
||||
}
|
||||
@ -0,0 +1,221 @@
|
||||
//
|
||||
// CameraDownloadStorage.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// 有线传图本地文件存储,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。
|
||||
enum CameraDownloadStorage {
|
||||
static let cameraDownloadsFolderName = "CameraDownloads"
|
||||
|
||||
/// 有线传图文件的业务隔离作用域。
|
||||
struct Scope: Equatable {
|
||||
let accountKey: String
|
||||
let albumID: Int
|
||||
|
||||
/// 初始化账号与相册维度的下载作用域。
|
||||
init(accountKey: String, albumID: Int) {
|
||||
self.accountKey = accountKey
|
||||
self.albumID = albumID
|
||||
}
|
||||
|
||||
var accountDirectoryName: String {
|
||||
CameraDownloadStorage.sanitizePathComponent(accountKey, fallback: "guest")
|
||||
}
|
||||
|
||||
var albumDirectoryName: String {
|
||||
"\(max(albumID, 0))"
|
||||
}
|
||||
}
|
||||
|
||||
/// Documents 根目录。
|
||||
static var documentsDirectory: URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||
}
|
||||
|
||||
/// 有线传图照片下载根目录。
|
||||
static var downloadsDirectory: URL {
|
||||
let directory = documentsDirectory.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 当前账号与相册的下载根目录。
|
||||
static func scopeDirectory(for scope: Scope) -> URL {
|
||||
let directory = downloadsDirectory
|
||||
.appendingPathComponent(scope.accountDirectoryName, isDirectory: true)
|
||||
.appendingPathComponent(scope.albumDirectoryName, isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 当前账号与相册的原图目录。
|
||||
static func originalsDirectory(for scope: Scope) -> URL {
|
||||
let directory = scopeDirectory(for: scope).appendingPathComponent("originals", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 当前账号与相册的缩略图目录。
|
||||
static func thumbnailsDirectory(for scope: Scope) -> URL {
|
||||
let directory = scopeDirectory(for: scope).appendingPathComponent("thumbnails", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
|
||||
/// 将绝对路径转为相对 Documents 的路径。
|
||||
static func relativePath(for url: URL) -> String {
|
||||
let standardizedURL = url.standardizedFileURL
|
||||
let documentsPath = documentsDirectory.standardizedFileURL.path
|
||||
let filePath = standardizedURL.path
|
||||
if filePath == documentsPath {
|
||||
return ""
|
||||
}
|
||||
if filePath.hasPrefix(documentsPath + "/") {
|
||||
return String(filePath.dropFirst(documentsPath.count + 1))
|
||||
}
|
||||
let privateDocumentsPath = "/private" + documentsPath
|
||||
if filePath.hasPrefix(privateDocumentsPath + "/") {
|
||||
return String(filePath.dropFirst(privateDocumentsPath.count + 1))
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
/// 将持久化的相对路径、绝对路径或网络地址解析为可访问 URL。
|
||||
static func resolveLocalURL(from storedPath: String) -> URL? {
|
||||
guard !storedPath.isEmpty else { return nil }
|
||||
|
||||
if storedPath.hasPrefix("http://") || storedPath.hasPrefix("https://") {
|
||||
return URL(string: storedPath)
|
||||
}
|
||||
|
||||
if storedPath.hasPrefix("/") {
|
||||
return URL(fileURLWithPath: storedPath)
|
||||
}
|
||||
|
||||
return documentsDirectory.appendingPathComponent(storedPath)
|
||||
}
|
||||
|
||||
/// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。
|
||||
static func uniqueLocalURL(for filename: String, scope: Scope) -> URL {
|
||||
let timestamp = Int(Date().timeIntervalSince1970)
|
||||
let safeName = sanitizeFilename(filename)
|
||||
return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)")
|
||||
}
|
||||
|
||||
/// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。
|
||||
static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL {
|
||||
let safeName = sanitizeFilename(assetID)
|
||||
.replacingOccurrences(of: ".", with: "_")
|
||||
return thumbnailsDirectory(for: scope).appendingPathComponent("\(safeName)_thumb.jpg")
|
||||
}
|
||||
|
||||
/// 删除当前账号与相册的本地文件目录。
|
||||
static func removeScopeDirectory(for scope: Scope) {
|
||||
let directory = scopeDirectory(for: scope)
|
||||
if FileManager.default.fileExists(atPath: directory.path) {
|
||||
try? FileManager.default.removeItem(at: directory)
|
||||
}
|
||||
}
|
||||
|
||||
/// 将文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
||||
static func sanitizeFilename(_ filename: String) -> String {
|
||||
let trimmed = filename
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: ":", with: "_")
|
||||
|
||||
let ascii = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "." || scalar == "_" || scalar == "-")
|
||||
}
|
||||
|
||||
var name = String(String.UnicodeScalarView(ascii))
|
||||
if name.isEmpty {
|
||||
name = "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
||||
}
|
||||
if !name.contains(".") {
|
||||
name += ".JPG"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/// 将账号等动态字符串转为安全目录片段。
|
||||
static func sanitizePathComponent(_ value: String, fallback: String) -> String {
|
||||
let trimmed = value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: ":", with: "_")
|
||||
|
||||
let ascii = trimmed.unicodeScalars.filter { scalar in
|
||||
scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "_" || scalar == "-")
|
||||
}
|
||||
|
||||
let component = String(String.UnicodeScalarView(ascii))
|
||||
return component.isEmpty ? fallback : component
|
||||
}
|
||||
|
||||
/// 将本地存储路径转为可用于 UI 预览的 file URL 字符串。
|
||||
static func previewURLString(from storedPath: String) -> String {
|
||||
guard let url = resolveLocalURL(from: storedPath),
|
||||
FileManager.default.fileExists(atPath: url.path) else {
|
||||
return ""
|
||||
}
|
||||
return url.absoluteString
|
||||
}
|
||||
|
||||
/// 后台读取文件大小,避免在列表刷新路径上阻塞主线程。
|
||||
static func fileSizeBytes(for url: URL?) async -> Int64 {
|
||||
guard let url else { return 0 }
|
||||
return await Task.detached(priority: .utility) {
|
||||
(try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
/// 本地照片缩略图生成器,负责为列表生成小尺寸预览图。
|
||||
enum CameraThumbnailGenerator {
|
||||
private static let maxPixelSize: CGFloat = 160
|
||||
|
||||
/// 为本地照片生成列表缩略图,成功时返回相对 Documents 的持久化路径。
|
||||
static func generateThumbnail(for imageURL: URL, assetID: String, scope: CameraDownloadStorage.Scope) async -> String? {
|
||||
await Task.detached(priority: .utility) {
|
||||
guard FileManager.default.fileExists(atPath: imageURL.path),
|
||||
let source = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceShouldCacheImmediately: false,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
|
||||
]
|
||||
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let destination = CameraDownloadStorage.uniqueThumbnailURL(for: assetID, scope: scope)
|
||||
if FileManager.default.fileExists(atPath: destination.path) {
|
||||
try? FileManager.default.removeItem(at: destination)
|
||||
}
|
||||
guard let imageDestination = CGImageDestinationCreateWithURL(
|
||||
destination as CFURL,
|
||||
UTType.jpeg.identifier as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
CGImageDestinationAddImage(imageDestination, thumbnail, [
|
||||
kCGImageDestinationLossyCompressionQuality: 0.78
|
||||
] as CFDictionary)
|
||||
|
||||
guard CGImageDestinationFinalize(imageDestination) else { return nil }
|
||||
return CameraDownloadStorage.relativePath(for: destination)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
//
|
||||
// WiredCameraTransferPipeline.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 有线传图上传编排器,负责管理本地照片的上传任务(不含 USB/OTG 相机连接)。
|
||||
@MainActor
|
||||
final class WiredCameraTransferPipeline {
|
||||
var onTasksUpdated: (([CameraTransferTask]) -> Void)?
|
||||
|
||||
private(set) var tasks: [CameraTransferTask] = []
|
||||
private var uploadSink: (any CameraAssetUploadSink)?
|
||||
private var uploadEnabled = true
|
||||
private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0)
|
||||
private var activeUploadAssetIDs: Set<String> = []
|
||||
private var autoUploadEligibleAssetIDs: Set<String> = []
|
||||
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||
private var lastDeferredNotifyDate = Date.distantPast
|
||||
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
|
||||
private let maxRetries = 3
|
||||
private let deferredNotifyInterval: TimeInterval = 0.15
|
||||
private let maxConcurrentUploads = 3
|
||||
private var isUIAttached = false
|
||||
/// 用户已从列表删除、不应再展示的 asset ID。
|
||||
private(set) var excludedAssetIDs: Set<String> = []
|
||||
|
||||
/// 绑定 UI 回调。
|
||||
func attachToUI(onTasksUpdated: @escaping ([CameraTransferTask]) -> Void) {
|
||||
isUIAttached = true
|
||||
self.onTasksUpdated = onTasksUpdated
|
||||
}
|
||||
|
||||
/// 页面离开:取消 UI 回调与进行中的上传通知。
|
||||
func detachFromUI() {
|
||||
guard isUIAttached else { return }
|
||||
isUIAttached = false
|
||||
pendingDeferredNotifyTask?.cancel()
|
||||
pendingDeferredNotifyTask = nil
|
||||
activeUploadAssetIDs.removeAll()
|
||||
onTasksUpdated = nil
|
||||
}
|
||||
|
||||
/// 配置上传 Sink、自动上传开关与下载作用域。
|
||||
func configure(
|
||||
uploadSink: (any CameraAssetUploadSink)?,
|
||||
uploadEnabled: Bool,
|
||||
downloadScope: CameraDownloadStorage.Scope? = nil
|
||||
) {
|
||||
self.uploadSink = uploadSink
|
||||
self.uploadEnabled = uploadEnabled
|
||||
if let downloadScope {
|
||||
self.downloadScope = downloadScope
|
||||
}
|
||||
}
|
||||
|
||||
/// 重试所有失败的上传任务。
|
||||
func retryFailedUploads() async {
|
||||
guard uploadEnabled, uploadSink != nil else { return }
|
||||
let failedAssetIDs = tasks.filter { $0.status == .failed }.map(\.assetID)
|
||||
for index in tasks.indices where tasks[index].status == .failed {
|
||||
tasks[index].status = .downloaded
|
||||
tasks[index].errorMessage = nil
|
||||
}
|
||||
notify()
|
||||
await uploadAssets(withIDs: failedAssetIDs)
|
||||
}
|
||||
|
||||
/// 手动上传指定 asset 列表。
|
||||
func uploadAssets(withIDs assetIDs: [String]) async {
|
||||
guard uploadSink != nil else { return }
|
||||
var seenAssetIDs: Set<String> = []
|
||||
let uniqueAssetIDs = assetIDs.filter { seenAssetIDs.insert($0).inserted }
|
||||
var currentIndex = 0
|
||||
while currentIndex < uniqueAssetIDs.count {
|
||||
let upperBound = min(currentIndex + maxConcurrentUploads, uniqueAssetIDs.count)
|
||||
let batch = uniqueAssetIDs[currentIndex ..< upperBound]
|
||||
let uploadTasks = batch.map { assetID in
|
||||
Task { @MainActor in
|
||||
await self.uploadAssetIfNeeded(assetID: assetID)
|
||||
}
|
||||
}
|
||||
for task in uploadTasks {
|
||||
await task.value
|
||||
}
|
||||
currentIndex = upperBound
|
||||
}
|
||||
}
|
||||
|
||||
/// 重传单张照片;若管道中无任务则根据本地相对路径恢复。
|
||||
func retryUpload(assetID: String, filename: String, localPath: String) async {
|
||||
guard uploadSink != nil else { return }
|
||||
guard !activeUploadAssetIDs.contains(assetID) else { return }
|
||||
|
||||
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
||||
if tasks[index].status == .uploaded { return }
|
||||
if tasks[index].localPath?.isEmpty != false, !localPath.isEmpty {
|
||||
tasks[index].localPath = localPath
|
||||
}
|
||||
tasks[index].status = .downloaded
|
||||
tasks[index].errorMessage = nil
|
||||
} else {
|
||||
guard !localPath.isEmpty,
|
||||
let localURL = CameraDownloadStorage.resolveLocalURL(from: localPath),
|
||||
FileManager.default.fileExists(atPath: localURL.path) else {
|
||||
return
|
||||
}
|
||||
tasks.insert(
|
||||
CameraTransferTask(
|
||||
assetID: assetID,
|
||||
filename: filename,
|
||||
localPath: localPath,
|
||||
capturedAt: CameraTransferTask.capturedAtString(forLocalPath: localPath),
|
||||
status: .downloaded
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
notify()
|
||||
await uploadTask(assetID: assetID)
|
||||
}
|
||||
|
||||
/// 按 assetID 查找任务。
|
||||
func task(forAssetID assetID: String) -> CameraTransferTask? {
|
||||
tasks.first { $0.assetID == assetID }
|
||||
}
|
||||
|
||||
/// 同步用户已删除的 asset ID,防止再次带回。
|
||||
func setExcludedAssetIDs(_ ids: Set<String>) {
|
||||
excludedAssetIDs = ids
|
||||
}
|
||||
|
||||
/// 从管道移除 asset。
|
||||
func removeAsset(assetID: String) {
|
||||
guard !assetID.isEmpty else { return }
|
||||
excludedAssetIDs.insert(assetID)
|
||||
tasks.removeAll { $0.assetID == assetID }
|
||||
autoUploadEligibleAssetIDs.remove(assetID)
|
||||
activeUploadAssetIDs.remove(assetID)
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
}
|
||||
|
||||
private func uploadAssetIfNeeded(assetID: String) async {
|
||||
if activeUploadAssetIDs.contains(assetID) { return }
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if tasks[index].status == .uploaded { return }
|
||||
|
||||
activeUploadAssetIDs.insert(assetID)
|
||||
defer { activeUploadAssetIDs.remove(assetID) }
|
||||
|
||||
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
||||
tasks[refreshedIndex].status = .downloaded
|
||||
tasks[refreshedIndex].errorMessage = nil
|
||||
await uploadTask(assetID: assetID)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadTask(assetID: String) async {
|
||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }),
|
||||
let sink = uploadSink,
|
||||
let localURL = tasks[index].localURL
|
||||
else { return }
|
||||
|
||||
tasks[index].status = .uploading
|
||||
tasks[index].errorMessage = nil
|
||||
notify()
|
||||
|
||||
let filename = tasks[index].filename
|
||||
let fileType = filename.lowercased().hasSuffix(".mp4") ? 1 : 2
|
||||
var attempt = 0
|
||||
while attempt < maxRetries {
|
||||
do {
|
||||
let remoteURL = try await sink.upload(
|
||||
localURL: localURL,
|
||||
fileName: filename,
|
||||
fileType: fileType,
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
guard let self,
|
||||
let taskIndex = self.tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
let lastNotifiedProgress = self.lastNotifiedProgressByAssetID[assetID] ?? -1
|
||||
guard progress == 100 || progress - lastNotifiedProgress >= 10 else { return }
|
||||
self.lastNotifiedProgressByAssetID[assetID] = progress
|
||||
self.tasks[taskIndex].progress = progress
|
||||
self.notify(progress == 100 ? .immediate : .deferred)
|
||||
}
|
||||
}
|
||||
)
|
||||
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
tasks[taskIndex].status = .uploaded
|
||||
tasks[taskIndex].remoteURL = remoteURL
|
||||
tasks[taskIndex].progress = 100
|
||||
autoUploadEligibleAssetIDs.remove(assetID)
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
notify()
|
||||
return
|
||||
} catch {
|
||||
attempt += 1
|
||||
guard let taskIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||
if attempt >= maxRetries {
|
||||
tasks[taskIndex].status = .failed
|
||||
tasks[taskIndex].errorMessage = error.localizedDescription
|
||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||
notify()
|
||||
} else {
|
||||
let delay = UInt64(pow(2.0, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: delay * 1_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notify(_ mode: WiredTransferNotifyMode = .immediate) {
|
||||
switch mode {
|
||||
case .immediate:
|
||||
pendingDeferredNotifyTask?.cancel()
|
||||
pendingDeferredNotifyTask = nil
|
||||
onTasksUpdated?(tasks)
|
||||
case .deferred:
|
||||
let elapsed = Date().timeIntervalSince(lastDeferredNotifyDate)
|
||||
if elapsed >= deferredNotifyInterval {
|
||||
lastDeferredNotifyDate = Date()
|
||||
onTasksUpdated?(tasks)
|
||||
return
|
||||
}
|
||||
guard pendingDeferredNotifyTask == nil else { return }
|
||||
let delay = UInt64((deferredNotifyInterval - elapsed) * 1_000_000_000)
|
||||
pendingDeferredNotifyTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.lastDeferredNotifyDate = Date()
|
||||
self.pendingDeferredNotifyTask = nil
|
||||
self.onTasksUpdated?(self.tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum WiredTransferNotifyMode {
|
||||
case immediate
|
||||
case deferred
|
||||
}
|
||||
Reference in New Issue
Block a user