移除 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,30 @@
import Foundation
/// 线UI 使OTG
enum CameraConnectionState: Equatable {
case disconnected
case searching
case connecting
case connected(deviceName: String)
case error(String)
var displayText: String {
switch self {
case .disconnected:
"未连接"
case .searching:
"正在搜索相机…"
case .connecting:
"正在连接…"
case .connected(let name):
"已连接:\(name)"
case .error(let message):
"错误:\(message)"
}
}
var isConnected: Bool {
if case .connected = self { return true }
return false
}
}

View File

@ -0,0 +1,83 @@
//
// CameraTransferModels.swift
// suixinkan
//
import Foundation
/// 线
enum CameraTransferStatus: String, Codable, Equatable {
case pending
case downloading
case downloaded
case uploading
case uploaded
case failed
}
/// 线
struct CameraTransferTask: Identifiable, Equatable, Codable {
let id: UUID
let assetID: String
let filename: String
var localPath: String?
var remoteURL: String?
/// `yyyy-MM-dd HH:mm:ss`
var capturedAt: String?
var status: CameraTransferStatus
var progress: Int
var errorMessage: String?
///
init(
id: UUID = UUID(),
assetID: String,
filename: String,
localPath: String? = nil,
remoteURL: String? = nil,
capturedAt: String? = nil,
status: CameraTransferStatus = .pending,
progress: Int = 0,
errorMessage: String? = nil
) {
self.id = id
self.assetID = assetID
self.filename = filename
self.localPath = localPath
self.remoteURL = remoteURL
self.capturedAt = capturedAt
self.status = status
self.progress = progress
self.errorMessage = errorMessage
}
/// 使
static func capturedAtString(from date: Date?) -> String {
Self.capturedAtFormatter.string(from: date ?? Date())
}
///
static func capturedAtString(forLocalPath path: String) -> String? {
guard let url = CameraDownloadStorage.resolveLocalURL(from: path),
FileManager.default.fileExists(atPath: url.path),
let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)
else { return nil }
let date = (attributes[.creationDate] as? Date)
?? (attributes[.modificationDate] as? Date)
return date.map { capturedAtString(from: $0) }
}
private static let capturedAtFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
/// URL Documents scoped
var localURL: URL? {
guard let localPath, !localPath.isEmpty else { return nil }
return CameraDownloadStorage.resolveLocalURL(from: localPath)
}
}

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
}

View File

@ -53,7 +53,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
## 有线传图交互流程
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()`同步相机历史文件
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()`刷新本地持久化记录
2. **批量上传三态**
- 首次点击 → 进入勾选模式
- 有选中项 → 上传选中照片
@ -69,7 +69,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
## 业务流程
1. 创建任务:`POST .../travel-album/create`
2. 进入有线传图:Sony/Canon USB 有线传图(`Core/CameraTethering`,自动识别品牌
2. 进入有线传图:有线传图 UI 页USB/OTG 相机连接能力已移除,保留页面与本地照片上传
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
4. 素材登记:`POST .../travel-album/upload-material`
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
@ -83,18 +83,18 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
## 解耦关系
- `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
- `WiredCameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
- 与「相册管理」(`Features/Assets`API、Store、路由完全隔离
## 依赖模块
- `Core/CameraTethering` — USB/PTP 相机
- `Core/CameraTransfer` — 传输编排
- `Features/TravelAlbum/Services` — 本地存储、上传编排
- `Core/Upload` — OSS STS 上传
## 二期待补齐
- RAW 预览
- 格式 Chip 扩展 RAW / JPEG+RAW并与 `CameraTransferPipeline` 联动
- USB/OTG 相机有线连接与边拍边传
- 格式 Chip 扩展 RAW / JPEG+RAW并与 `WiredCameraTransferPipeline` 联动
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)

View File

@ -441,7 +441,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
@Published var errorMessage: String?
let context: WiredTransferContext
private let pipeline: CameraTransferPipeline
private let pipeline = WiredCameraTransferPipeline()
private var photoStore: WiredTransferPhotoStore?
private var userIDProvider: (() -> String)?
private var downloadScope: CameraDownloadStorage.Scope?
@ -457,12 +457,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
private var isLeavingPage = false
/// 线 ViewModel
init(
context: WiredTransferContext,
cameraService: any CameraServiceProtocol
) {
init(context: WiredTransferContext) {
self.context = context
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
}
/// Sink
@ -473,16 +469,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
accountContext: AccountContext
) async {
isLeavingPage = false
pipeline.attachToUI(
onConnectionStateChange: { [weak self] state in
self?.handleConnectionStateChange(state)
},
onTasksUpdated: { [weak self] tasks in
Task { @MainActor in
await self?.applyPipelineTasks(tasks)
}
pipeline.attachToUI { [weak self] tasks in
Task { @MainActor in
await self?.applyPipelineTasks(tasks)
}
)
}
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
let currentDownloadScope = CameraDownloadStorage.Scope(
@ -511,19 +502,10 @@ final class WiredCameraTransferViewModel: ObservableObject {
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
refreshDeviceStorageInfo()
loadPersistedPhotos()
connectionState = .disconnected
do {
await pipeline.connect()
connectionState = pipeline.connectionState
wasConnected = connectionState.isConnected
while !Task.isCancelled {
try await Task.sleep(nanoseconds: 1_000_000_000)
}
} catch is CancellationError {
// task
} catch {
errorMessage = error.localizedDescription
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
detachFromUI()
}
@ -600,25 +582,20 @@ final class WiredCameraTransferViewModel: ObservableObject {
errorMessage = nil
}
/// USB
/// OTG
func refreshCameraFiles() async {
if connectionState.isConnected {
guard !isRefreshingCameraFiles else { return }
isRefreshingCameraFiles = true
defer { isRefreshingCameraFiles = false }
await pipeline.syncExistingPhotos()
return
}
await restartCameraSearch()
guard !isRefreshingCameraFiles else { return }
isRefreshingCameraFiles = true
defer { isRefreshingCameraFiles = false }
loadPersistedPhotos()
}
/// USB
/// OTG
func restartCameraSearch() async {
guard !connectionState.isConnected, !isSearchingCamera else { return }
guard !isSearchingCamera else { return }
isSearchingCamera = true
defer { isSearchingCamera = false }
await pipeline.restartCameraDiscovery()
connectionState = pipeline.connectionState
connectionState = .disconnected
}
/// / /

View File

@ -24,12 +24,7 @@ struct WiredCameraTransferView: View {
init(context: WiredTransferContext) {
self.context = context
_viewModel = StateObject(
wrappedValue: WiredCameraTransferViewModel(
context: context,
cameraService: CameraServiceFactory.makeAutoDetect()
)
)
_viewModel = StateObject(wrappedValue: WiredCameraTransferViewModel(context: context))
}
private let tabTitles = ["全部", "已上传", "失败"]