移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。

将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 10:55:40 +08:00
parent a0549e068a
commit d7fec35715
42 changed files with 187 additions and 5278 deletions

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}