diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 0644cbd..2252dc6 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,15 +64,11 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanUITests; sourceTree = ""; }; @@ -328,10 +324,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/App/AppDelegate.swift b/suixinkan/App/AppDelegate.swift index 0be1134..47f2f43 100644 --- a/suixinkan/App/AppDelegate.swift +++ b/suixinkan/App/AppDelegate.swift @@ -87,12 +87,5 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } } - func applicationWillTerminate(_ application: UIApplication) { - let semaphore = DispatchSemaphore(value: 0) - Task { @MainActor in - await CameraTetheringSession.shutdown() - semaphore.signal() - } - _ = semaphore.wait(timeout: .now() + 2) - } + func applicationWillTerminate(_ application: UIApplication) {} } diff --git a/suixinkan/Core/CameraTethering/AutoDetectCameraService.swift b/suixinkan/Core/CameraTethering/AutoDetectCameraService.swift deleted file mode 100644 index c6cb34a..0000000 --- a/suixinkan/Core/CameraTethering/AutoDetectCameraService.swift +++ /dev/null @@ -1,213 +0,0 @@ -import Foundation -import ImageCaptureCore - -/// 自动识别 USB 相机品牌并委托给对应品牌服务。 -@MainActor -final class AutoDetectCameraService: NSObject, CameraServiceProtocol { - private(set) var brand: CameraBrand = .sony - private(set) var connectionState: CameraConnectionState = .disconnected - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - - private var activeService: (any CameraServiceProtocol)? - private var pendingCamera: ICCameraDevice? - private let usbBrowser = SharedUSBCameraBrowserHub.controller - private var isConnectingCamera = false - private var isDisconnecting = false - - override init() { - super.init() - bindUSBBrowserCallbacks() - } - - func connect() async { - CameraTetheringLogger.log("AutoDetectCameraService.connect()") - if let activeService { - CameraTetheringLogger.log("AutoDetect: 已有活动服务,复用连接") - connectionState = activeService.connectionState - rebindActiveServiceCallbacks() - flushPendingAssetsIfNeeded(on: activeService) - onConnectionStateChange?(connectionState) - return - } - - isConnectingCamera = false - connectionState = .searching - onConnectionStateChange?(.searching) - - let started = await usbBrowser.start() - guard started else { - connectionState = .error("权限被拒绝") - onConnectionStateChange?(.error("权限被拒绝")) - return - } - } - - /// 页面离开:清空 UI 回调,保持 ICC/PTP 会话与 Browser。 - func detachFromUI() { - CameraTetheringLogger.log("AutoDetectCameraService.detachFromUI()") - onConnectionStateChange = nil - onNewAsset = nil - activeService?.detachFromUI() - } - - func shutdown() async { - guard !isDisconnecting else { return } - isDisconnecting = true - defer { isDisconnecting = false } - - CameraTetheringLogger.log("AutoDetectCameraService.shutdown()") - isConnectingCamera = false - - if let activeService { - await activeService.shutdown() - } - activeService = nil - pendingCamera = nil - - usbBrowser.stop() - USBCameraReconnectHint.markDisconnected() - try? await Task.sleep(nanoseconds: 300_000_000) - connectionState = .disconnected - onConnectionStateChange?(.disconnected) - } - - /// 重新搜索 USB 相机(未连接时可手动触发,或 App 回到前台时调用)。 - func restartDiscovery() async { - CameraTetheringLogger.log("AutoDetectCameraService.restartDiscovery()") - guard activeService == nil else { - CameraTetheringLogger.log("AutoDetect: 已连接,跳过 restartDiscovery") - return - } - isConnectingCamera = false - connectionState = .searching - onConnectionStateChange?(.searching) - await usbBrowser.restart() - } - - func listAssets() async throws -> [CameraAsset] { - guard let activeService else { - throw CameraServiceError.notConnected - } - return try await activeService.listAssets() - } - - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - guard let activeService else { - throw CameraServiceError.notConnected - } - return try await activeService.downloadAsset(asset) - } - - private func bindUSBBrowserCallbacks() { - usbBrowser.onCameraDiscovered = { [weak self] camera, source in - Task { @MainActor in - await self?.handleCameraDiscovered(camera, source: source) - } - } - usbBrowser.onCameraRemoved = { [weak self] device in - Task { @MainActor in - await self?.handleCameraRemoved(device) - } - } - usbBrowser.onAuthorizationDenied = { [weak self] in - Task { @MainActor in - guard let self else { return } - self.connectionState = .error("权限被拒绝") - self.onConnectionStateChange?(.error("权限被拒绝")) - } - } - usbBrowser.onScanTimeout = { [weak self] in - Task { @MainActor in - guard let self, self.activeService == nil, !self.isConnectingCamera else { return } - let message = "未发现 USB 相机。请确认:① 相机 USB 设为「照片传输/电脑遥控」② 存储卡内至少有 1 张照片 ③ 先连手机再连相机 ④ 进入页面后重新插拔 USB 线" - self.connectionState = .error(message) - self.onConnectionStateChange?(.error(message)) - } - } - } - - private func handleCameraDiscovered(_ camera: ICCameraDevice, source: String) async { - guard activeService == nil, !isConnectingCamera, !isDisconnecting else { - CameraTetheringLogger.log("AutoDetect: 忽略重复发现 (\(source))") - return - } - - let vendorID = Int(camera.usbVendorID) - guard let detectedBrand = CameraBrand.detect(from: camera) else { - let message = "不支持的相机品牌 (Vendor ID: 0x\(String(vendorID, radix: 16)))" - CameraTetheringLogger.log(message) - connectionState = .error(message) - onConnectionStateChange?(.error(message)) - return - } - - isConnectingCamera = true - await activateService(for: camera, brand: detectedBrand) - isConnectingCamera = false - } - - private func handleCameraRemoved(_ device: ICDevice) async { - guard !isDisconnecting else { return } - guard activeService != nil else { return } - guard device.uuidString == pendingCamera?.uuidString else { return } - - CameraTetheringLogger.log("AutoDetect: 相机已物理移除 \(device.name ?? "?")") - pendingCamera = nil - if let activeService { - await activeService.shutdown() - } - activeService = nil - usbBrowser.resetNotifiedDevices() - connectionState = .disconnected - onConnectionStateChange?(.disconnected) - } - - private func activateService(for camera: ICCameraDevice, brand detectedBrand: CameraBrand) async { - brand = detectedBrand - let service = CameraServiceFactory.make(brand: detectedBrand) - - activeService = service - pendingCamera = camera - rebindActiveServiceCallbacks() - - connectionState = .connecting - onConnectionStateChange?(.connecting) - - CameraTetheringLogger.log("AutoDetect: 挂载 \(detectedBrand.displayName) 服务 (source attach)…") - switch detectedBrand { - case .sony: - if let sony = service as? SonyCameraService { - await sony.attach(to: camera) - } - case .canon: - if let canon = service as? CanonCameraService { - await canon.attach(to: camera) - } - case .nikon: - connectionState = .error("Nikon 相机尚未实现") - onConnectionStateChange?(.error("Nikon 相机尚未实现")) - } - } - - private func rebindActiveServiceCallbacks() { - guard let activeService else { return } - activeService.onConnectionStateChange = { [weak self] state in - Task { @MainActor in - self?.connectionState = state - self?.onConnectionStateChange?(state) - } - } - activeService.onNewAsset = { [weak self] asset in - self?.onNewAsset?(asset) - } - } - - private func flushPendingAssetsIfNeeded(on service: any CameraServiceProtocol) { - if let sony = service as? SonyCameraService { - sony.reattachToUI() - } else if let canon = service as? CanonCameraService { - canon.reattachToUI() - } - } -} diff --git a/suixinkan/Core/CameraTethering/CameraTethering.md b/suixinkan/Core/CameraTethering/CameraTethering.md deleted file mode 100644 index 72ef78d..0000000 --- a/suixinkan/Core/CameraTethering/CameraTethering.md +++ /dev/null @@ -1,74 +0,0 @@ -# CameraTethering - -## 职责 - -提供 USB OTG 相机有线连接与 Sony/Canon PTP 边拍边传能力,不包含业务 API 或 OSS 上传。 - -## 核心组件 - -| 组件 | 说明 | -| --- | --- | -| `CameraServiceProtocol` | 相机服务抽象,上层只依赖此协议 | -| `CameraTetheringSession` | 进程级单例,持有共享 `AutoDetectCameraService` | -| `AutoDetectCameraService` | 按 USB Vendor ID 自动识别品牌并委托 | -| `SonyCameraService` | Sony 相机实现,封装 SDIO/PTP 下载 | -| `CanonCameraService` | Canon EOS 相机实现,封装 GetEvent/PTP 下载 | -| `ImageCaptureDeviceManager` | Sony ICC 设备管理 | -| `CanonImageCaptureDeviceManager` | Canon ICC 设备管理 | -| `PTPHelper` | 共享 PTP 编解码与命令发送 | -| `CameraDownloadStorage` | 本地下载作用域、目录与文件名消毒 | - -## 品牌识别 - -插入 USB 相机后,`AutoDetectCameraService` 读取 `ICCameraDevice.usbVendorID`: - -- `0x054C` → Sony -- `0x04A9` → Canon -- 其他 → 提示不支持的相机品牌 - -## 业务流程 - -1. `AutoDetectCameraService.connect()` 启动 USB 设备搜索(或复用已有连接)。 -2. 发现相机并完成系统授权后,按 Vendor ID 创建品牌 Service 并 attach。 -3. 品牌 Service 打开 ICC 会话并执行品牌专有 PTP 握手(Sony SDIO / Canon SetRemoteMode)。 -4. 拍摄事件触发 PTP 下载或 catalog 回退,底层先写入临时目录。 -5. 通过 `onNewAsset` 回调通知上层有新照片。 - -## 相机端前置条件 - -- **Sony**:设为电脑遥控(PC Remote)模式 -- **Canon**:设为电脑遥控 / PC Remote / 照片传输(PTP)模式 -- 均需关闭「仅充电」模式 - -## 依赖 - -- ImageCaptureCore(系统框架) -- 无第三方库依赖 - -## 页面生命周期与资源释放 - -OTG 相机连接由 `CameraTetheringSession.sharedService` 进程级持有;`CameraServiceFactory.makeAutoDetect()` 始终返回该单例。 - -**进入 OTG 页:** - -1. `.task` 调用 `WiredCameraTransferViewModel.start()`。 -2. `pipeline.attachToUI(...)` 绑定 UI 回调。 -3. `connect()` 启动或复用 USB 搜索;若已连接则跳过 attach,同步状态并补发 detach 期间缓冲的新片。 -4. 发现相机后按 Vendor ID attach 品牌服务,打开 ICC 会话并执行 PTP 握手。 - -**离开 OTG 页(detach):** - -1. `.task` 取消或 `onDisappear` 触发 `detachFromUI()`(幂等)。 -2. 仅清空 `onNewAsset` / `onConnectionStateChange` 等 UI 回调,取消 Pipeline 上传/通知任务。 -3. **不** PTP CloseSession、**不** stop Browser、**不** nil `activeService`。 -4. 底层 D215 轮询与 PTP 下载可继续,新片写入 `pendingAssetsWhileDetached`,再次进入时 flush。 - -**App 即将终止(shutdown):** - -1. `AppDelegate.applicationWillTerminate` 调用 `CameraTetheringSession.shutdown()`。 -2. 品牌 Service 完整断开(Sony 先 `Position_Key_Setting=Local` 再 PTP CloseSession)。 -3. `usbBrowser.stop()` 停止 USB Browser 并释放授权状态。 - -进入后台**不**主动 shutdown;系统挂起 USB 时由 `deviceBrowserDidResumeOperations` 处理恢复。 - -若返回后再进入无法连接,优先检查日志中是否出现「会话关闭超时」或在非 terminate 场景误调用了 `shutdown()`。 diff --git a/suixinkan/Core/CameraTethering/CameraTetheringSession.swift b/suixinkan/Core/CameraTethering/CameraTetheringSession.swift deleted file mode 100644 index 09b33b8..0000000 --- a/suixinkan/Core/CameraTethering/CameraTetheringSession.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -/// 进程级 OTG 相机会话:页面离开仅 detach UI 回调,App 终止时 shutdown 释放全部资源。 -@MainActor -enum CameraTetheringSession { - static let sharedService = AutoDetectCameraService() - - /// 页面离开:清空 UI 回调,保持 ICC/PTP 会话与 Browser。 - static func detachFromUI() { - sharedService.detachFromUI() - } - - /// App 终止:完整释放相机连接与 USB Browser。 - static func shutdown() async { - await sharedService.shutdown() - } -} diff --git a/suixinkan/Core/CameraTethering/Canon/Canon.md b/suixinkan/Core/CameraTethering/Canon/Canon.md deleted file mode 100644 index 0d90fe0..0000000 --- a/suixinkan/Core/CameraTethering/Canon/Canon.md +++ /dev/null @@ -1,39 +0,0 @@ -# Canon CameraTethering - -## 职责 - -提供 Canon EOS 相机 USB OTG 有线连接与边拍边传(PC Remote)能力。 - -## 核心组件 - -| 组件 | 说明 | -| --- | --- | -| `CanonCameraService` | 实现 `CameraServiceProtocol`,管理基线与去重 | -| `CanonImageCaptureDeviceManager` | ImageCaptureCore 会话、GetEvent 轮询、PTP/Catalog 下载 | -| `CanonPTPCommands` | Canon EOS PTP 厂商扩展命令 | -| `CanonEosEventParser` | GetEvent blob 解析(ObjectAddedEx / ObjectAddedEx2) | - -## PTP 会话时序 - -1. ImageCaptureCore 打开 ICC 会话(不发 PTP OpenSession)。 -2. `SetRemoteMode` (0x9114) — 进入 PC 遥控模式。 -3. `SetEventMode` (0x9115) — 订阅扩展事件。 -4. `KeepDeviceOn` (0x911D) — 重置自动关机(部分机身不支持,非致命)。 -5. 每 500ms 轮询 `GetEvent` (0x9116)。 -6. 收到 `ObjectAddedEx` / `ObjectAddedEx2` 后通过 `EOS_GetObject` (0x9104) 下载。 -7. 发送 `TransferComplete` (0x9117) 释放相机缓冲。 - -## Catalog 回退 - -若 PTP 实时路径不可用,仍可通过 `deviceDidBecomeReady` + `didAddItems` + 1.5s 轮询检测 catalog 新文件,并使用 `requestDownloadFile` 下载。 - -## 相机端设置 - -- USB 模式设为 **电脑遥控 / PC Remote / 照片传输(PTP)** -- 关闭「仅充电」模式 -- 边拍边传时勿调用 `SetUILock` (0x911B) - -## 依赖 - -- ImageCaptureCore(系统框架) -- 共享 `PTPHelper` / `CameraDownloadStorage` diff --git a/suixinkan/Core/CameraTethering/Canon/CanonCameraService.swift b/suixinkan/Core/CameraTethering/Canon/CanonCameraService.swift deleted file mode 100644 index 398b167..0000000 --- a/suixinkan/Core/CameraTethering/Canon/CanonCameraService.swift +++ /dev/null @@ -1,181 +0,0 @@ -import Foundation -import ImageCaptureCore - -/// Canon EOS 相机有线连接服务,实现边拍边传与目录回退下载。 -@MainActor -final class CanonCameraService: CameraServiceProtocol { - let brand: CameraBrand = .canon - - private(set) var connectionState: CameraConnectionState = .disconnected - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - - private let deviceManager = CanonImageCaptureDeviceManager() - private var knownAssetIDs: Set = [] - private var baselineEstablished = false - private var pendingAssetsWhileDetached: [CameraAsset] = [] - - init() { - deviceManager.onConnectionStateChange = { [weak self] state in - Task { @MainActor in - self?.connectionState = state - self?.onConnectionStateChange?(state) - } - } - - deviceManager.onNewItems = { [weak self] items in - Task { @MainActor in - self?.handleNewItems(items, source: "didAddItems") - } - } - - deviceManager.onBaselineCatalogReady = { [weak self] in - Task { @MainActor in - self?.establishBaseline() - } - } - - deviceManager.onPollTick = { [weak self] in - Task { @MainActor in - self?.scanForNewAssets(source: "poll") - } - } - - deviceManager.onPTPAssetReady = { [weak self] asset, _ in - Task { @MainActor in - self?.handlePTPAsset(asset) - } - } - - deviceManager.onError = { [weak self] message in - Task { @MainActor in - CameraTetheringLogger.log("Canon 相机错误: \(message)") - self?.connectionState = .error(message) - self?.onConnectionStateChange?(.error(message)) - } - } - } - - func connect() async { - CameraTetheringLogger.log("CanonCameraService.connect()") - let shouldResetBaseline = !deviceManager.isBrowsing - deviceManager.startBrowsing() - if shouldResetBaseline { - knownAssetIDs.removeAll() - baselineEstablished = false - } - } - - /// 由 AutoDetect 在已完成授权后直接挂载设备。 - func attach(to camera: ICCameraDevice) async { - CameraTetheringLogger.log("CanonCameraService.attach()") - knownAssetIDs.removeAll() - baselineEstablished = false - pendingAssetsWhileDetached.removeAll() - deviceManager.attach(to: camera) - } - - /// 页面离开:仅清空 UI 回调,底层 PTP 会话与轮询继续。 - func detachFromUI() { - CameraTetheringLogger.log("CanonCameraService.detachFromUI()") - onConnectionStateChange = nil - onNewAsset = nil - } - - /// 再次进入页面:补发 detach 期间缓冲的新照片。 - func reattachToUI() { - guard !pendingAssetsWhileDetached.isEmpty else { return } - let pending = pendingAssetsWhileDetached - pendingAssetsWhileDetached.removeAll() - CameraTetheringLogger.log("CanonCameraService.reattachToUI: 补发 \(pending.count) 张离线新片") - for asset in pending { - onNewAsset?(asset) - } - } - - func shutdown() async { - CameraTetheringLogger.log("CanonCameraService.shutdown()") - await deviceManager.stopBrowsing() - knownAssetIDs.removeAll() - baselineEstablished = false - pendingAssetsWhileDetached.removeAll() - connectionState = .disconnected - onConnectionStateChange?(.disconnected) - } - - func listAssets() async throws -> [CameraAsset] { - guard connectionState.isConnected else { - throw CameraServiceError.notConnected - } - return deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) } - } - - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - guard connectionState.isConnected else { - throw CameraServiceError.notConnected - } - if let cachedURL = deviceManager.localURL(forAssetID: asset.id) { - return cachedURL - } - guard let file = deviceManager.file(forAssetID: asset.id) else { - throw CameraServiceError.assetNotFound - } - let directory = CameraDownloadStorage.temporaryDownloadsDirectory - return try await deviceManager.download(file: file, to: directory) - } - - private func handlePTPAsset(_ asset: CameraAsset) { - deliverNewAsset(asset, source: "Canon PTP") - } - - private func establishBaseline() { - guard !baselineEstablished else { return } - baselineEstablished = true - - let files = deviceManager.allFiles() - var skipped = 0 - var newCount = 0 - - for file in files { - if file.wasAddedAfterContentCatalogCompleted { - emitAssetIfNew(file: file, source: "baseline-new") - newCount += 1 - } else { - knownAssetIDs.insert(deviceManager.assetIdentifier(for: file)) - skipped += 1 - } - } - CameraTetheringLogger.log("Canon 基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount) 张") - } - - private func handleNewItems(_ items: [ICCameraItem], source: String) { - guard baselineEstablished else { return } - for item in items { - guard let file = item as? ICCameraFile else { continue } - emitAssetIfNew(file: file, source: source) - } - } - - private func scanForNewAssets(source: String) { - guard baselineEstablished else { return } - for file in deviceManager.allFiles() { - emitAssetIfNew(file: file, source: source) - } - } - - private func emitAssetIfNew(file: ICCameraFile, source: String) { - let asset = deviceManager.makeAsset(from: file) - deliverNewAsset(asset, source: source) - } - - private func deliverNewAsset(_ asset: CameraAsset, source: String) { - guard !knownAssetIDs.contains(asset.id) else { return } - knownAssetIDs.insert(asset.id) - CameraTetheringLogger.log("[Canon \(source)] 发现新照片: \(asset.filename)", logger: CameraTetheringLogger.transfer) - if let onNewAsset { - onNewAsset(asset) - } else { - pendingAssetsWhileDetached.append(asset) - } - } -} diff --git a/suixinkan/Core/CameraTethering/Canon/CanonEosEventParser.swift b/suixinkan/Core/CameraTethering/Canon/CanonEosEventParser.swift deleted file mode 100644 index 1c938a4..0000000 --- a/suixinkan/Core/CameraTethering/Canon/CanonEosEventParser.swift +++ /dev/null @@ -1,188 +0,0 @@ -import Foundation - -/// Canon EOS GetEvent 返回的事件 blob 解析器。 -enum CanonEosEventParser { - private static let recordHeaderSize = 8 - private static let objectAddedExMinPayload = 33 - private static let objectAddedEx2MinPayload = 41 - - /// 解析后的 Canon 事件。 - enum ParsedEvent { - case objectAdded(ObjectAdded) - case requestObjectTransfer(handle: UInt32) - case cameraStatus(status: Int32) - } - - /// ObjectAddedEx / ObjectAddedEx2 事件载荷。 - struct ObjectAdded { - let objectHandle: UInt32 - let storageId: UInt32 - let formatCode: UInt16 - let parentHandle: UInt32 - let size: Int64 - let filename: String - } - - /// 解析 GetEvent 返回的二进制 blob。 - static func parse(_ blob: Data) -> [ParsedEvent] { - guard !blob.isEmpty else { return [] } - - var events: [ParsedEvent] = [] - var offset = 0 - - while offset + recordHeaderSize <= blob.count { - let recordStart = offset - let recordSizeRaw = Int64(PTPHelper.readUInt32LE(blob, offset: offset)) - offset += 4 - if recordSizeRaw == 0 { break } - if recordSizeRaw < Int64(recordHeaderSize) { break } - - let recordSize = Int(recordSizeRaw) - let type = Int(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF) - offset += 4 - let payloadSize = recordSize - recordHeaderSize - - if type == 0, payloadSize == 0 { break } - if offset + payloadSize > blob.count { break } - - let payloadEnd = offset + payloadSize - - switch UInt16(type) { - case CanonPTPCommand.eosObjectAddedEx: - if let added = parseObjectAdded( - blob, - offset: offset, - payloadSize: payloadSize, - filenameOffset: 32, - minPayload: objectAddedExMinPayload - ) { - events.append(.objectAdded(added)) - } - case CanonPTPCommand.eosObjectAddedEx2: - if let added = parseObjectAdded( - blob, - offset: offset, - payloadSize: payloadSize, - filenameOffset: 40, - minPayload: objectAddedEx2MinPayload - ) { - events.append(.objectAdded(added)) - } - case CanonPTPCommand.eosRequestObjectTransfer: - let handle: UInt32 = payloadSize >= 4 ? PTPHelper.readUInt32LE(blob, offset: offset) : 0 - events.append(.requestObjectTransfer(handle: handle)) - case CanonPTPCommand.eosCameraStatusChanged: - let status: Int32 = payloadSize >= 4 - ? Int32(bitPattern: PTPHelper.readUInt32LE(blob, offset: offset)) - : 0 - events.append(.cameraStatus(status: status)) - case CanonPTPCommand.eosObjectInfoChangedEx, - CanonPTPCommand.eosStorageInfoChanged: - break - default: - break - } - - offset = payloadEnd - if offset == recordStart { break } - } - - return events - } - - /// 将 ObjectAdded 事件转为 CameraAsset。 - static func makeAsset(from event: ObjectAdded) -> CameraAsset { - let filename = event.filename.isEmpty - ? "IMG_\(event.objectHandle).JPG" - : CameraDownloadStorage.sanitizeFilename(event.filename) - let isMovie = isVideoFormat(event.formatCode) || isVideoExtension(filename) - return CameraAsset( - id: CanonPTPHelper.assetID(handle: event.objectHandle, filename: filename, size: event.size), - filename: filename, - fileSize: event.size, - creationDate: Date(), - isMovie: isMovie - ) - } - - private static func parseObjectAdded( - _ blob: Data, - offset payloadStart: Int, - payloadSize: Int, - filenameOffset: Int, - minPayload: Int - ) -> ObjectAdded? { - guard payloadSize >= minPayload else { return nil } - - var offset = payloadStart - let objectHandle = PTPHelper.readUInt32LE(blob, offset: offset) - offset += 4 - let storageId = PTPHelper.readUInt32LE(blob, offset: offset) - offset += 4 - let ofc = UInt16(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF) - offset += 4 - - let size: Int64 - let parentHandle: UInt32 - - if filenameOffset == 32 { - offset += 4 // flags - parentHandle = PTPHelper.readUInt32LE(blob, offset: offset) - offset += 4 - offset += 4 // reserved - size = Int64(PTPHelper.readUInt32LE(blob, offset: offset)) - offset += 4 - offset += 4 // reserved - } else { - offset += 4 // reserved - parentHandle = PTPHelper.readUInt32LE(blob, offset: offset) - offset += 4 - let sizeLow = Int64(PTPHelper.readUInt32LE(blob, offset: offset)) - offset += 4 - let sizeHigh = Int64(PTPHelper.readUInt32LE(blob, offset: offset)) - offset += 4 - size = sizeLow | (sizeHigh << 32) - offset += 4 // reserved - offset += 4 // relatedHandle - } - - let expected = payloadStart + filenameOffset - if offset != expected { offset = expected } - - let filenameBytesAvailable = payloadSize - filenameOffset - guard filenameBytesAvailable > 0, offset + filenameBytesAvailable <= blob.count else { return nil } - - let filenameData = blob.subdata(in: offset..<(offset + filenameBytesAvailable)) - let filename = extractCString(filenameData) - - return ObjectAdded( - objectHandle: objectHandle, - storageId: storageId, - formatCode: ofc, - parentHandle: parentHandle, - size: size, - filename: filename - ) - } - - private static func extractCString(_ bytes: Data) -> String { - if let nulIndex = bytes.firstIndex(of: 0) { - return String(bytes: bytes.prefix(upTo: nulIndex), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "" - } - return String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? "" - } - - private static func isVideoFormat(_ ofc: UInt16) -> Bool { - switch ofc { - case 0x300D, 0x3000, 0xB982, 0xB983, 0x300F: - return true - default: - return false - } - } - - private static func isVideoExtension(_ filename: String) -> Bool { - let lower = filename.lowercased() - return [".mov", ".mp4", ".m4v", ".avi", ".mpg", ".mpeg"].contains { lower.hasSuffix($0) } - } -} diff --git a/suixinkan/Core/CameraTethering/Canon/CanonImageCaptureDeviceManager.swift b/suixinkan/Core/CameraTethering/Canon/CanonImageCaptureDeviceManager.swift deleted file mode 100644 index 599e0c2..0000000 --- a/suixinkan/Core/CameraTethering/Canon/CanonImageCaptureDeviceManager.swift +++ /dev/null @@ -1,597 +0,0 @@ -import CoreGraphics -import Foundation -import ImageCaptureCore - -/// Canon 相机 ImageCaptureCore 设备管理:会话、GetEvent 轮询与 PTP/Catalog 双路径下载。 -final class CanonImageCaptureDeviceManager: NSObject { - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewItems: (([ICCameraItem]) -> Void)? - var onBaselineCatalogReady: (() -> Void)? - var onPollTick: (() -> Void)? - var onPTPAssetReady: ((CameraAsset, URL) -> Void)? - var onError: ((String) -> Void)? - - private(set) var cameraDevice: ICCameraDevice? - private(set) var isCatalogReady = false - private var deviceBrowser: ICDeviceBrowser? - private let downloadDelegate = CameraDownloadDelegate() - private var transactionID: UInt32 = 1 - private var pendingDownloads: [String: CheckedContinuation] = [:] - private var ptpAssetURLs: [String: URL] = [:] - private var pollTimer: Timer? - private var baselineNotified = false - private var isRemoteSessionReady = false - private var isExternalAttach = false - private var eventPollTask: Task? - private var keepDeviceOnSupported = false - private var eventPollTick = 0 - private var seenHandles: Set = [] - private var isDownloadingPTPObject = false - private var sessionSetupTask: Task? - private var sessionCloseContinuation: CheckedContinuation? - private var sessionCloseTimeoutTask: Task? - private var closingDeviceUUID: String? - private var isDisconnecting = false - - var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach } - - /// 跳过 Browser,直接连接 AutoDetect 已发现并授权的设备。 - func attach(to camera: ICCameraDevice) { - guard cameraDevice == nil else { - CameraTetheringLogger.log("Canon attach: 已有连接中的相机,忽略") - return - } - isExternalAttach = true - CameraTetheringLogger.log("Canon attach: \(camera.name ?? "Unknown")") - openSession(for: camera) - } - - func startBrowsing() { - guard deviceBrowser == nil else { - CameraTetheringLogger.log("Canon: 已在监听 USB 相机") - if cameraDevice == nil { - updateState(.searching) - } - return - } - - CameraTetheringLogger.log("Canon: 开始搜索 USB 相机…") - updateState(.searching) - - let browser = ICDeviceBrowser() - browser.delegate = self - if #available(iOS 15.2, *) { - let mask = ICDeviceTypeMask( - rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue - ) ?? .camera - browser.browsedDeviceTypeMask = mask - } - deviceBrowser = browser - browser.start() - } - - func stopBrowsing() async { - CameraTetheringLogger.log("Canon: 停止搜索并断开") - stopEventPolling() - stopPolling() - await disconnect() - if !isExternalAttach { - deviceBrowser?.stop() - } - deviceBrowser = nil - isExternalAttach = false - isCatalogReady = false - baselineNotified = false - updateState(.disconnected) - } - - func disconnect() async { - guard !isDisconnecting else { return } - isDisconnecting = true - defer { isDisconnecting = false } - - sessionSetupTask?.cancel() - sessionSetupTask = nil - - let shouldClosePTP = isRemoteSessionReady - isRemoteSessionReady = false - isDownloadingPTPObject = false - - stopEventPolling() - stopPolling() - cancelPendingDownloads() - - guard let camera = cameraDevice else { - resetDisconnectLocalState() - return - } - - let deviceUUID = camera.uuidString ?? "" - camera.ptpEventHandler = { _ in } - - if shouldClosePTP { - var localTransactionID = transactionID - await CanonPTPHelper.closeRemoteSession(on: camera, transactionID: &localTransactionID) - } - - await waitForSessionClose(on: camera, deviceUUID: deviceUUID) - camera.delegate = nil - resetDisconnectLocalState() - } - - private func cancelPendingDownloads() { - let disconnected = CameraServiceError.notConnected - for (_, continuation) in pendingDownloads { - continuation.resume(throwing: disconnected) - } - pendingDownloads.removeAll() - } - - private func waitForSessionClose(on camera: ICCameraDevice, deviceUUID: String) async { - sessionCloseTimeoutTask?.cancel() - await withCheckedContinuation { (continuation: CheckedContinuation) in - sessionCloseContinuation = continuation - closingDeviceUUID = deviceUUID - camera.requestCloseSession() - - sessionCloseTimeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 3_000_000_000) - guard let self, self.sessionCloseContinuation != nil else { return } - CameraTetheringLogger.log("Canon 会话关闭超时,继续 teardown") - self.finishSessionClose() - } - } - sessionCloseTimeoutTask?.cancel() - sessionCloseTimeoutTask = nil - } - - private func finishSessionClose() { - sessionCloseContinuation?.resume() - sessionCloseContinuation = nil - closingDeviceUUID = nil - } - - private func resetDisconnectLocalState() { - cameraDevice = nil - transactionID = 1 - isCatalogReady = false - baselineNotified = false - isRemoteSessionReady = false - keepDeviceOnSupported = false - eventPollTick = 0 - seenHandles.removeAll() - ptpAssetURLs.removeAll() - isExternalAttach = false - } - - func localURL(forAssetID assetID: String) -> URL? { - ptpAssetURLs[assetID] - } - - func allFiles() -> [ICCameraFile] { - guard let camera = cameraDevice else { return [] } - if let mediaFiles = camera.mediaFiles, !mediaFiles.isEmpty { - return mediaFiles.compactMap { $0 as? ICCameraFile } - } - return collectFiles(from: camera.contents ?? []) - } - - func file(forAssetID assetID: String) -> ICCameraFile? { - allFiles().first { assetIdentifier(for: $0) == assetID } - } - - func download(file: ICCameraFile, to directory: URL) async throws -> URL { - guard let camera = cameraDevice else { - throw CameraServiceError.notConnected - } - - let filename = file.name ?? "photo_\(UUID().uuidString).jpg" - let assetID = assetIdentifier(for: file) - - CameraTetheringLogger.log("Canon 开始下载 \(filename), handle=\(file.ptpObjectHandle)", logger: CameraTetheringLogger.transfer) - - return try await withCheckedThrowingContinuation { continuation in - pendingDownloads[assetID] = continuation - - downloadDelegate.onComplete = { [weak self] completedFile, url, error in - guard let self else { return } - let completedID = self.assetIdentifier(for: completedFile) - guard let pending = self.pendingDownloads.removeValue(forKey: completedID) else { return } - - if let error { - pending.resume(throwing: error) - } else if let url { - pending.resume(returning: url) - } else { - pending.resume(throwing: CameraServiceError.downloadFailed("未知错误")) - } - } - - let options: [ICDownloadOption: Any] = [ - .downloadsDirectoryURL: directory, - .savedFilename: filename, - .overwrite: true - ] - - camera.requestDownloadFile( - file, - options: options, - downloadDelegate: downloadDelegate, - didDownloadSelector: #selector(CameraDownloadDelegate.didDownloadFile(_:error:options:contextInfo:)), - contextInfo: nil - ) - } - } - - func assetIdentifier(for file: ICCameraFile) -> String { - if file.ptpObjectHandle != 0 { - return "ptp_\(file.ptpObjectHandle)" - } - if let persistentID = file.value(forKey: "persistentID") as? String, !persistentID.isEmpty { - return persistentID - } - let name = file.name ?? "unknown" - let size = file.fileSize - let date = file.creationDate?.timeIntervalSince1970 ?? 0 - return "\(name)_\(size)_\(Int(date))" - } - - func makeAsset(from file: ICCameraFile) -> CameraAsset { - let filename = file.name ?? "unknown" - let isMovie = file.duration > 0 || Self.movieExtensions.contains(where: { filename.lowercased().hasSuffix($0) }) - return CameraAsset( - id: assetIdentifier(for: file), - filename: filename, - fileSize: file.fileSize, - creationDate: file.creationDate, - isMovie: isMovie - ) - } - - private static let movieExtensions = [".mp4", ".mov", ".m4v", ".avi"] - - private func collectFiles(from items: [ICCameraItem]) -> [ICCameraFile] { - var files: [ICCameraFile] = [] - for item in items { - if let file = item as? ICCameraFile { - files.append(file) - } else if let folder = item as? ICCameraFolder { - files.append(contentsOf: collectFiles(from: folder.contents ?? [])) - } - } - return files - } - - private func updateState(_ state: CameraConnectionState) { - CameraTetheringLogger.log("Canon 连接状态: \(state.displayText)") - onConnectionStateChange?(state) - } - - private func requestAuthorizations(completion: @escaping (Bool, Bool) -> Void) { - guard let browser = deviceBrowser else { - completion(false, false) - return - } - - var contentsGranted = false - var controlGranted = false - let group = DispatchGroup() - - group.enter() - browser.requestContentsAuthorization { status in - contentsGranted = status == .authorized - group.leave() - } - - group.enter() - browser.requestControlAuthorization { status in - controlGranted = status == .authorized - group.leave() - } - - group.notify(queue: .main) { - completion(contentsGranted, controlGranted) - } - } - - private func openSession(for camera: ICCameraDevice) { - updateState(.connecting) - camera.delegate = self - cameraDevice = camera - CameraTetheringLogger.log("Canon 打开相机会务: \(camera.name ?? "Unknown")") - camera.requestOpenSession(options: nil) { [weak self] error in - guard let self else { return } - if let error { - CameraTetheringLogger.log("Canon 会话打开失败: \(error.localizedDescription)") - self.onError?(error.localizedDescription) - self.updateState(.error(error.localizedDescription)) - return - } - let name = camera.name ?? "Canon Camera" - CameraTetheringLogger.log("Canon 会话已打开: \(name)") - self.updateState(.connected(deviceName: name)) - self.configureCameraAfterSession(camera) - } - } - - private func configureCameraAfterSession(_ camera: ICCameraDevice) { - let capabilities = camera.capabilities.joined(separator: ", ") - CameraTetheringLogger.log("Canon 相机能力: [\(capabilities)]") - - camera.ptpEventHandler = { [weak self] eventData in - self?.handlePTPEvent(eventData) - } - - sessionSetupTask = Task { [weak self] in - guard let self else { return } - let session = await CanonPTPHelper.initializeRemoteSession( - on: camera, - startingTransactionID: transactionID - ) - guard !Task.isCancelled, !self.isDisconnecting else { return } - transactionID = session.transactionID - keepDeviceOnSupported = session.keepDeviceOnSupported - isRemoteSessionReady = true - startEventPolling() - } - } - - private func handlePTPEvent(_ eventData: Data) { - guard !isDisconnecting else { return } - let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ") - CameraTetheringLogger.log("Canon 收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp) - } - - private func startEventPolling() { - stopEventPolling() - CameraTetheringLogger.log("Canon: 启动 GetEvent 轮询 (每 \(CanonPTPCommand.getEventInterval)s)", logger: CameraTetheringLogger.ptp) - - eventPollTask = Task { [weak self] in - while !Task.isCancelled { - guard let self, !self.isDisconnecting else { return } - await self.pollEventsOnce() - try? await Task.sleep(nanoseconds: UInt64(CanonPTPCommand.getEventInterval * 1_000_000_000)) - } - } - } - - private func stopEventPolling() { - eventPollTask?.cancel() - eventPollTask = nil - } - - private func pollEventsOnce() async { - guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return } - - eventPollTick += 1 - if keepDeviceOnSupported, eventPollTick % CanonPTPCommand.keepOnIntervalTicks == 0 { - var localID = transactionID - _ = await PTPHelper.sendCommand( - on: camera, - code: CanonPTPCommand.eosKeepDeviceOn, - transactionID: &localID, - parameters: [], - outData: nil, - label: "KeepDeviceOn(poll)" - ) - transactionID = localID - } - - var localID = transactionID - let blob = await CanonPTPHelper.pollEvents(on: camera, transactionID: &localID) - transactionID = localID - - guard !blob.isEmpty else { return } - - let parsed = CanonEosEventParser.parse(blob) - let addedCount = parsed.filter { - if case .objectAdded = $0 { return true } - return false - }.count - - if addedCount > 0 { - CameraTetheringLogger.log("Canon GetEvent: \(parsed.count) 事件, 新增 \(addedCount)", logger: CameraTetheringLogger.ptp) - } - - for event in parsed { - switch event { - case .objectAdded(let added): - await handleObjectAdded(added) - case .requestObjectTransfer(let handle): - CameraTetheringLogger.log("Canon RequestObjectTransfer handle=\(handle)", logger: CameraTetheringLogger.ptp) - case .cameraStatus: - break - } - } - } - - private func handleObjectAdded(_ added: CanonEosEventParser.ObjectAdded) async { - guard !isDisconnecting else { return } - guard seenHandles.insert(added.objectHandle).inserted else { return } - - let asset = CanonEosEventParser.makeAsset(from: added) - guard CanonPTPHelper.isSupportedFilename(asset.filename) else { - CameraTetheringLogger.log("Canon: 跳过不支持的文件 \(asset.filename)", logger: CameraTetheringLogger.transfer) - return - } - - guard !isDownloadingPTPObject else { - CameraTetheringLogger.log("Canon: 下载进行中,稍后重试 \(asset.filename)", logger: CameraTetheringLogger.transfer) - seenHandles.remove(added.objectHandle) - return - } - - isDownloadingPTPObject = true - defer { isDownloadingPTPObject = false } - - guard let camera = cameraDevice else { return } - - var localID = transactionID - guard let data = await CanonPTPHelper.downloadObject( - handle: added.objectHandle, - on: camera, - transactionID: &localID - ) else { - seenHandles.remove(added.objectHandle) - CameraTetheringLogger.log("Canon: PTP 下载失败 \(asset.filename)", logger: CameraTetheringLogger.transfer) - return - } - transactionID = localID - - await CanonPTPHelper.transferComplete( - handle: added.objectHandle, - on: camera, - transactionID: &localID - ) - transactionID = localID - - let destination = CameraDownloadStorage.uniqueTemporaryURL(for: asset.filename) - do { - try data.write(to: destination, options: .atomic) - } catch { - seenHandles.remove(added.objectHandle) - CameraTetheringLogger.log("Canon: 文件写入失败 \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) - return - } - - ptpAssetURLs[asset.id] = destination - CameraTetheringLogger.log( - "Canon PTP 下载完成: \(asset.filename) -> \(destination.path)", - logger: CameraTetheringLogger.transfer - ) - onPTPAssetReady?(asset, destination) - } - - private func notifyBaselineIfNeeded() { - guard !baselineNotified else { return } - baselineNotified = true - isCatalogReady = true - - let fileCount = allFiles().count - CameraTetheringLogger.log("Canon 目录就绪,当前 \(fileCount) 个文件,建立基线") - onBaselineCatalogReady?() - startPolling() - } - - private func startPolling() { - stopPolling() - CameraTetheringLogger.log("Canon: 启动轮询检测新照片 (每 1.5s)") - - pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in - guard let self, self.isCatalogReady else { return } - self.onPollTick?() - } - } - - private func stopPolling() { - pollTimer?.invalidate() - pollTimer = nil - } -} - -extension CanonImageCaptureDeviceManager: ICDeviceBrowserDelegate { - @objc(deviceBrowser:didAddDevice:moreComing:) - func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) { - CameraTetheringLogger.log("Canon 发现设备: \(device.name ?? "?")") - - guard let camera = device as? ICCameraDevice else { return } - guard cameraDevice == nil else { return } - - requestAuthorizations { [weak self] contentsGranted, controlGranted in - guard let self else { return } - guard contentsGranted else { - let message = "未获得相机内容访问权限,请在系统设置中允许" - self.onError?(message) - self.updateState(.error("权限被拒绝")) - return - } - if !controlGranted { - CameraTetheringLogger.log("Canon: 控制授权未通过,仍尝试连接") - } - self.openSession(for: camera) - } - } - - @objc(deviceBrowser:didRemoveDevice:moreGoing:) - func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) { - guard !isDisconnecting else { return } - if device.uuidString == cameraDevice?.uuidString { - Task { await disconnect() } - updateState(.disconnected) - } - } -} - -extension CanonImageCaptureDeviceManager: ICDeviceDelegate { - func didRemove(_ device: ICDevice) { - guard !isDisconnecting else { return } - if device.uuidString == cameraDevice?.uuidString { - Task { await disconnect() } - updateState(.disconnected) - } - } - - func device(_ device: ICDevice, didOpenSessionWithError error: Error?) { - if let error { - CameraTetheringLogger.log("Canon delegate didOpenSession 失败: \(error.localizedDescription)") - } - } - - func device(_ device: ICDevice, didCloseSessionWithError error: Error?) { - if device.uuidString == closingDeviceUUID { - finishSessionClose() - } - guard !isDisconnecting else { return } - if device.uuidString == cameraDevice?.uuidString { - cameraDevice = nil - isCatalogReady = false - baselineNotified = false - isRemoteSessionReady = false - stopPolling() - stopEventPolling() - updateState(.disconnected) - } - } -} - -extension CanonImageCaptureDeviceManager: ICCameraDeviceDelegate { - @objc(cameraDevice:didAddItems:) - func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) { - let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name } - CameraTetheringLogger.log("Canon didAddItems: \(names.joined(separator: ", "))") - onNewItems?(items) - } - - @objc(cameraDevice:didRemoveItems:) - func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) { - } - - @objc(cameraDevice:didRenameItems:) - func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) { - } - - func cameraDevice(_ camera: ICCameraDevice, didReceiveThumbnail thumbnail: CGImage?, for item: ICCameraItem, error: Error?) { - } - - func cameraDevice(_ camera: ICCameraDevice, didReceiveMetadata metadata: [AnyHashable: Any]?, for item: ICCameraItem, error: Error?) { - } - - func deviceDidBecomeReady(withCompleteContentCatalog device: ICCameraDevice) { - CameraTetheringLogger.log("Canon deviceDidBecomeReady, 文件数=\(allFiles().count)") - notifyBaselineIfNeeded() - } - - func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) { - } - - func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) { - } - - func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) { - onError?("设备已锁定,解锁后才能读取媒体文件") - } - - func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) { - handlePTPEvent(eventData) - } -} diff --git a/suixinkan/Core/CameraTethering/Canon/CanonPTPCommands.swift b/suixinkan/Core/CameraTethering/Canon/CanonPTPCommands.swift deleted file mode 100644 index 037f319..0000000 --- a/suixinkan/Core/CameraTethering/Canon/CanonPTPCommands.swift +++ /dev/null @@ -1,177 +0,0 @@ -import Foundation -import ImageCaptureCore - -/// Canon EOS PTP 厂商扩展命令码。 -enum CanonPTPCommand { - static let eosGetObject: UInt16 = 0x9104 - static let eosGetStorageIDs: UInt16 = 0x9101 - static let eosGetObjectInfoEx: UInt16 = 0x9109 - static let eosGetPartialObject: UInt16 = 0x9107 - static let eosGetThumbEx: UInt16 = 0x910A - static let eosSetRemoteMode: UInt16 = 0x9114 - static let eosSetEventMode: UInt16 = 0x9115 - static let eosGetEvent: UInt16 = 0x9116 - static let eosTransferComplete: UInt16 = 0x9117 - static let eosCancelTransfer: UInt16 = 0x9118 - static let eosKeepDeviceOn: UInt16 = 0x911D - - static let eosObjectAddedEx: UInt16 = 0xC181 - static let eosObjectRemoved: UInt16 = 0xC182 - static let eosRequestGetObjectInfoEx: UInt16 = 0xC183 - static let eosStorageStatusChanged: UInt16 = 0xC184 - static let eosStorageInfoChanged: UInt16 = 0xC185 - static let eosObjectInfoChangedEx: UInt16 = 0xC189 - static let eosCameraStatusChanged: UInt16 = 0xC18A - static let eosRequestObjectTransfer: UInt16 = 0xC18B - static let eosObjectAddedEx2: UInt16 = 0xC1A7 - - static let getObjectInfo: UInt16 = 0x1008 - static let getObject: UInt16 = 0x1009 - static let getThumb: UInt16 = 0x100A - static let closeSession: UInt16 = 0x1003 - - static let getEventInterval: TimeInterval = 0.5 - static let keepOnIntervalTicks: Int = 60 - - static let supportedExtensions: Set = [ - "jpg", "jpeg", "png", "cr2", "cr3", "mp4", "mov" - ] -} - -/// Canon EOS PTP 命令封装与会话初始化。 -enum CanonPTPHelper { - /// ImageCaptureCore 已打开会话后,进入 Canon PC Remote 模式并启用事件推送。 - static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> (transactionID: UInt32, keepDeviceOnSupported: Bool) { - var transactionID = startingTransactionID - - guard camera.capabilities.contains("ICCameraDeviceCanAcceptPTPCommands") else { - CameraTetheringLogger.log("Canon: 相机不支持 PTP 指令", logger: CameraTetheringLogger.ptp) - return (transactionID, false) - } - - CameraTetheringLogger.log("开始 Canon EOS 远程会话初始化", logger: CameraTetheringLogger.ptp) - - let remoteMode = await sendCommand( - on: camera, - code: CanonPTPCommand.eosSetRemoteMode, - transactionID: &transactionID, - parameters: [1], - outData: nil, - label: "SetRemoteMode" - ) - CameraTetheringLogger.log("SetRemoteMode: \(remoteMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp) - - let eventMode = await sendCommand( - on: camera, - code: CanonPTPCommand.eosSetEventMode, - transactionID: &transactionID, - parameters: [1], - outData: nil, - label: "SetEventMode" - ) - CameraTetheringLogger.log("SetEventMode: \(eventMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp) - - let keepOn = await sendCommand( - on: camera, - code: CanonPTPCommand.eosKeepDeviceOn, - transactionID: &transactionID, - parameters: [], - outData: nil, - label: "KeepDeviceOn" - ) - let keepOnSupported = keepOn.success - CameraTetheringLogger.log("KeepDeviceOn: \(keepOnSupported ? "OK" : "skipped")", logger: CameraTetheringLogger.ptp) - - CameraTetheringLogger.log("Canon EOS 远程会话初始化完成", logger: CameraTetheringLogger.ptp) - return (transactionID, keepOnSupported) - } - - /// 关闭 PTP 远程会话,释放相机 PC Remote 占用。 - static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async { - _ = await sendCommand( - on: camera, - code: CanonPTPCommand.closeSession, - transactionID: &transactionID, - parameters: [], - outData: nil, - label: "CloseSession" - ) - } - - /// 轮询 Canon 事件队列。 - static func pollEvents( - on camera: ICCameraDevice, - transactionID: inout UInt32 - ) async -> Data { - let result = await sendCommand( - on: camera, - code: CanonPTPCommand.eosGetEvent, - transactionID: &transactionID, - parameters: [], - outData: nil, - label: "GetEvent" - ) - return result.success ? result.inData : Data() - } - - /// 通过 Canon 扩展 GetObject 下载文件。 - static func downloadObject( - handle: UInt32, - on camera: ICCameraDevice, - transactionID: inout UInt32 - ) async -> Data? { - let result = await sendCommand( - on: camera, - code: CanonPTPCommand.eosGetObject, - transactionID: &transactionID, - parameters: [handle], - outData: nil, - label: "EOS_GetObject(0x\(String(format: "%08X", handle)))" - ) - guard result.success, !result.inData.isEmpty else { return nil } - return result.inData - } - - /// 通知相机传输完成,释放缓冲。 - static func transferComplete( - handle: UInt32, - on camera: ICCameraDevice, - transactionID: inout UInt32 - ) async { - _ = await sendCommand( - on: camera, - code: CanonPTPCommand.eosTransferComplete, - transactionID: &transactionID, - parameters: [handle], - outData: nil, - label: "TransferComplete" - ) - } - - static func isSupportedFilename(_ filename: String) -> Bool { - let ext = (filename as NSString).pathExtension.lowercased() - return CanonPTPCommand.supportedExtensions.contains(ext) - } - - static func assetID(handle: UInt32, filename: String, size: Int64) -> String { - "canon_\(handle)_\(filename)_\(size)" - } - - private static func sendCommand( - on camera: ICCameraDevice, - code: UInt16, - transactionID: inout UInt32, - parameters: [UInt32], - outData: Data?, - label: String - ) async -> PTPCommandResult { - await PTPHelper.sendCommand( - on: camera, - code: code, - transactionID: &transactionID, - parameters: parameters, - outData: outData, - label: label - ) - } -} diff --git a/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift b/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift deleted file mode 100644 index 25b163b..0000000 --- a/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Foundation - -enum CameraServiceFactory { - static func make(brand: CameraBrand) -> any CameraServiceProtocol { - switch brand { - case .sony: - SonyCameraService() - case .canon: - CanonCameraService() - case .nikon: - preconditionFailure("\(brand.displayName) 相机尚未实现") - } - } - - /// 自动识别 USB 相机品牌;返回进程级共享实例以支持页面 detach / 再进入复连。 - static func makeAutoDetect() -> any CameraServiceProtocol { - CameraTetheringSession.sharedService - } -} diff --git a/suixinkan/Core/CameraTethering/Models/CameraAsset.swift b/suixinkan/Core/CameraTethering/Models/CameraAsset.swift deleted file mode 100644 index 46e0447..0000000 --- a/suixinkan/Core/CameraTethering/Models/CameraAsset.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -struct CameraAsset: Identifiable, Equatable, Hashable { - let id: String - let filename: String - let fileSize: Int64 - let creationDate: Date? - let isMovie: Bool - - init( - id: String, - filename: String, - fileSize: Int64, - creationDate: Date? = nil, - isMovie: Bool = false - ) { - self.id = id - self.filename = filename - self.fileSize = fileSize - self.creationDate = creationDate - self.isMovie = isMovie - } -} diff --git a/suixinkan/Core/CameraTethering/Models/CameraBrand.swift b/suixinkan/Core/CameraTethering/Models/CameraBrand.swift deleted file mode 100644 index cb24f7a..0000000 --- a/suixinkan/Core/CameraTethering/Models/CameraBrand.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Foundation - -enum CameraBrand: String, CaseIterable, Identifiable { - case sony - case canon - case nikon - - var id: String { rawValue } - - var displayName: String { - switch self { - case .sony: "Sony" - case .canon: "Canon" - case .nikon: "Nikon" - } - } -} diff --git a/suixinkan/Core/CameraTethering/Models/CameraUSBVendorID.swift b/suixinkan/Core/CameraTethering/Models/CameraUSBVendorID.swift deleted file mode 100644 index 040a701..0000000 --- a/suixinkan/Core/CameraTethering/Models/CameraUSBVendorID.swift +++ /dev/null @@ -1,25 +0,0 @@ -import Foundation -import ImageCaptureCore - -/// USB Vendor ID 常量,用于自动识别相机品牌。 -enum CameraUSBVendorID { - static let sony: Int = 0x054C - static let canon: Int = 0x04A9 - static let nikon: Int = 0x04B0 -} - -extension CameraBrand { - /// 根据 ICCameraDevice 的 USB Vendor ID 识别品牌。 - static func detect(from camera: ICCameraDevice) -> CameraBrand? { - switch Int(camera.usbVendorID) { - case CameraUSBVendorID.sony: - return .sony - case CameraUSBVendorID.canon: - return .canon - case CameraUSBVendorID.nikon: - return .nikon - default: - return nil - } - } -} diff --git a/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift b/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift deleted file mode 100644 index 1286867..0000000 --- a/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift +++ /dev/null @@ -1,43 +0,0 @@ -import Foundation - -enum CameraServiceError: LocalizedError { - case notConnected - case assetNotFound - case downloadFailed(String) - case permissionDenied - - var errorDescription: String? { - switch self { - case .notConnected: - "相机未连接" - case .assetNotFound: - "找不到相机内的文件" - case .downloadFailed(let reason): - "下载失败:\(reason)" - case .permissionDenied: - "未获得相机访问权限" - } - } -} - -protocol CameraServiceProtocol: AnyObject { - var brand: CameraBrand { get } - var connectionState: CameraConnectionState { get } - var onConnectionStateChange: ((CameraConnectionState) -> Void)? { get set } - var onNewAsset: ((CameraAsset) -> Void)? { get set } - - func connect() async - /// 页面离开:仅清空 UI 回调,不关闭相机会话。 - func detachFromUI() - /// App 终止或物理断开:完整释放相机资源。 - func shutdown() async - func listAssets() async throws -> [CameraAsset] - func downloadAsset(_ asset: CameraAsset) async throws -> URL -} - -extension CameraServiceProtocol { - /// 兼容旧调用,语义等同 `shutdown()`。 - func disconnect() async { - await shutdown() - } -} diff --git a/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift b/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift deleted file mode 100644 index e2d7c4e..0000000 --- a/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift +++ /dev/null @@ -1,886 +0,0 @@ -import CoreGraphics -import Foundation -import ImageCaptureCore - -final class ImageCaptureDeviceManager: NSObject { - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewItems: (([ICCameraItem]) -> Void)? - var onBaselineCatalogReady: (() -> Void)? - var onPollTick: (() -> Void)? - var onPTPAssetReady: ((CameraAsset, URL) -> Void)? - var onError: ((String) -> Void)? - - private(set) var cameraDevice: ICCameraDevice? - private(set) var isCatalogReady = false - private var deviceBrowser: ICDeviceBrowser? - private let downloadDelegate = CameraDownloadDelegate() - private var transactionID: UInt32 = 1 - private var pendingDownloads: [String: CheckedContinuation] = [:] - private var ptpAssetURLs: [String: URL] = [:] - private var pollTimer: Timer? - private var baselineNotified = false - private var isRemoteSessionReady = false - private var isDownloadingPTPObject = false - private var propertyCheckTask: Task? - private var shotBufferPollTimer: Timer? - private var lastDownloadedObjectSignature: String? - private var lastShootingFileInfoValue: Int64? - private var shotDownloadQueue: [(handle: UInt32, reason: String)] = [] - private var isDrainingShotQueue = false - private var isEvaluatingShotBuffer = false - private var propertyCheckCoalesceScheduled = false - private var isExternalAttach = false - private var sessionSetupTask: Task? - private var sessionCloseContinuation: CheckedContinuation? - private var sessionCloseTimeoutTask: Task? - private var closingDeviceUUID: String? - private var shotDownloadDrainTask: Task? - private var coalescedCheckTask: Task? - private var isDisconnecting = false - - var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach } - - /// 跳过 Browser,直接连接 AutoDetect 已发现并授权的设备。 - func attach(to camera: ICCameraDevice) { - guard cameraDevice == nil else { - CameraTetheringLogger.log("Sony attach: 已有连接中的相机,忽略") - return - } - isExternalAttach = true - CameraTetheringLogger.log("Sony attach: \(camera.name ?? "Unknown")") - openSession(for: camera) - } - - func startBrowsing() { - guard deviceBrowser == nil else { - CameraTetheringLogger.log("已在监听 USB 相机") - if cameraDevice == nil { - updateState(.searching) - } - return - } - - CameraTetheringLogger.log("开始搜索 USB 相机…") - updateState(.searching) - - let browser = ICDeviceBrowser() - browser.delegate = self - if #available(iOS 15.2, *) { - let mask = ICDeviceTypeMask( - rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue - ) ?? .camera - browser.browsedDeviceTypeMask = mask - CameraTetheringLogger.log("已设置 browsedDeviceTypeMask = Camera | Local USB") - } - deviceBrowser = browser - browser.start() - } - - func stopBrowsing() async { - CameraTetheringLogger.log("停止搜索并断开") - propertyCheckTask?.cancel() - shotDownloadQueue.removeAll() - isDrainingShotQueue = false - isEvaluatingShotBuffer = false - propertyCheckCoalesceScheduled = false - propertyCheckTask = nil - stopShotBufferPolling() - stopPolling() - await disconnect() - if !isExternalAttach { - deviceBrowser?.stop() - } - deviceBrowser = nil - isExternalAttach = false - isCatalogReady = false - baselineNotified = false - updateState(.disconnected) - } - - func disconnect() async { - guard !isDisconnecting else { return } - isDisconnecting = true - defer { isDisconnecting = false } - - sessionSetupTask?.cancel() - sessionSetupTask = nil - shotDownloadDrainTask?.cancel() - shotDownloadDrainTask = nil - coalescedCheckTask?.cancel() - coalescedCheckTask = nil - propertyCheckTask?.cancel() - propertyCheckTask = nil - - let shouldClosePTP = isRemoteSessionReady - isRemoteSessionReady = false - isDownloadingPTPObject = false - - shotDownloadQueue.removeAll() - isDrainingShotQueue = false - isEvaluatingShotBuffer = false - propertyCheckCoalesceScheduled = false - stopShotBufferPolling() - stopPolling() - cancelPendingDownloads() - - guard let camera = cameraDevice else { - resetDisconnectLocalState() - return - } - - let deviceUUID = camera.uuidString ?? "" - camera.ptpEventHandler = { _ in } - - if shouldClosePTP { - var localTransactionID = transactionID - await SonyPTPHelper.teardownRemoteSession(on: camera, transactionID: &localTransactionID) - } - - await waitForSessionClose(on: camera, deviceUUID: deviceUUID) - camera.delegate = nil - resetDisconnectLocalState() - } - - private func cancelPendingDownloads() { - let disconnected = CameraServiceError.notConnected - for (_, continuation) in pendingDownloads { - continuation.resume(throwing: disconnected) - } - pendingDownloads.removeAll() - } - - private func waitForSessionClose(on camera: ICCameraDevice, deviceUUID: String) async { - sessionCloseTimeoutTask?.cancel() - await withCheckedContinuation { (continuation: CheckedContinuation) in - sessionCloseContinuation = continuation - closingDeviceUUID = deviceUUID - camera.requestCloseSession() - - sessionCloseTimeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 3_000_000_000) - guard let self, self.sessionCloseContinuation != nil else { return } - CameraTetheringLogger.log("会话关闭超时,继续 teardown") - self.finishSessionClose() - } - } - sessionCloseTimeoutTask?.cancel() - sessionCloseTimeoutTask = nil - } - - private func finishSessionClose() { - sessionCloseContinuation?.resume() - sessionCloseContinuation = nil - closingDeviceUUID = nil - } - - private func resetDisconnectLocalState() { - cameraDevice = nil - transactionID = 1 - isCatalogReady = false - baselineNotified = false - isRemoteSessionReady = false - ptpAssetURLs.removeAll() - lastDownloadedObjectSignature = nil - lastShootingFileInfoValue = nil - shotDownloadQueue.removeAll() - isDrainingShotQueue = false - isEvaluatingShotBuffer = false - propertyCheckCoalesceScheduled = false - isExternalAttach = false - } - - func localURL(forAssetID assetID: String) -> URL? { - ptpAssetURLs[assetID] - } - - func allFiles() -> [ICCameraFile] { - guard let camera = cameraDevice else { return [] } - - if let mediaFiles = camera.mediaFiles, !mediaFiles.isEmpty { - return mediaFiles.compactMap { $0 as? ICCameraFile } - } - return collectFiles(from: camera.contents ?? []) - } - - func file(forAssetID assetID: String) -> ICCameraFile? { - allFiles().first { assetIdentifier(for: $0) == assetID } - } - - func download(file: ICCameraFile, to directory: URL) async throws -> URL { - guard let camera = cameraDevice else { - throw CameraServiceError.notConnected - } - - let filename = file.name ?? "photo_\(UUID().uuidString).jpg" - let assetID = assetIdentifier(for: file) - - CameraTetheringLogger.log("开始下载 \(filename), handle=\(file.ptpObjectHandle)", logger: CameraTetheringLogger.transfer) - - return try await withCheckedThrowingContinuation { continuation in - pendingDownloads[assetID] = continuation - - downloadDelegate.onComplete = { [weak self] completedFile, url, error in - guard let self else { return } - let completedID = self.assetIdentifier(for: completedFile) - guard let pending = self.pendingDownloads.removeValue(forKey: completedID) else { return } - - if let error { - pending.resume(throwing: error) - } else if let url { - pending.resume(returning: url) - } else { - pending.resume(throwing: CameraServiceError.downloadFailed("未知错误")) - } - } - - let options: [ICDownloadOption: Any] = [ - .downloadsDirectoryURL: directory, - .savedFilename: filename, - .overwrite: true - ] - - camera.requestDownloadFile( - file, - options: options, - downloadDelegate: downloadDelegate, - didDownloadSelector: #selector(CameraDownloadDelegate.didDownloadFile(_:error:options:contextInfo:)), - contextInfo: nil - ) - } - } - - func assetIdentifier(for file: ICCameraFile) -> String { - if file.ptpObjectHandle != 0 { - return "ptp_\(file.ptpObjectHandle)" - } - if let persistentID = file.value(forKey: "persistentID") as? String, !persistentID.isEmpty { - return persistentID - } - let name = file.name ?? "unknown" - let size = file.fileSize - let date = file.creationDate?.timeIntervalSince1970 ?? 0 - return "\(name)_\(size)_\(Int(date))" - } - - func makeAsset(from file: ICCameraFile) -> CameraAsset { - let filename = file.name ?? "unknown" - let isMovie = file.duration > 0 || Self.movieExtensions.contains(where: { filename.lowercased().hasSuffix($0) }) - return CameraAsset( - id: assetIdentifier(for: file), - filename: filename, - fileSize: file.fileSize, - creationDate: file.creationDate, - isMovie: isMovie - ) - } - - private static let movieExtensions = [".mp4", ".mov", ".m4v", ".avi"] - - private func collectFiles(from items: [ICCameraItem]) -> [ICCameraFile] { - var files: [ICCameraFile] = [] - for item in items { - if let file = item as? ICCameraFile { - files.append(file) - } else if let folder = item as? ICCameraFolder { - files.append(contentsOf: collectFiles(from: folder.contents ?? [])) - } - } - return files - } - - private func updateState(_ state: CameraConnectionState) { - CameraTetheringLogger.log("连接状态: \(state.displayText)") - onConnectionStateChange?(state) - } - - private func requestAuthorizations(completion: @escaping (Bool, Bool) -> Void) { - guard let browser = deviceBrowser else { - completion(false, false) - return - } - - var contentsGranted = false - var controlGranted = false - let group = DispatchGroup() - - group.enter() - browser.requestContentsAuthorization { status in - contentsGranted = status == .authorized - CameraTetheringLogger.log("内容授权: \(String(describing: status))") - group.leave() - } - - group.enter() - browser.requestControlAuthorization { status in - controlGranted = status == .authorized - CameraTetheringLogger.log("控制授权: \(String(describing: status))") - group.leave() - } - - group.notify(queue: .main) { - completion(contentsGranted, controlGranted) - } - } - - private func openSession(for camera: ICCameraDevice) { - updateState(.connecting) - camera.delegate = self - cameraDevice = camera - CameraTetheringLogger.log("打开相机会话: \(camera.name ?? "Unknown")") - camera.requestOpenSession() - } - - private func configureCameraAfterSession(_ camera: ICCameraDevice) { - let capabilities = camera.capabilities.joined(separator: ", ") - CameraTetheringLogger.log("相机能力: [\(capabilities)]") - CameraTetheringLogger.log("tetheredCaptureEnabled=\(camera.tetheredCaptureEnabled)") - - camera.ptpEventHandler = { [weak self] eventData in - self?.handlePTPEvent(eventData) - } - - sessionSetupTask = Task { [weak self] in - guard let self else { return } - let updatedID = await SonyPTPHelper.initializeRemoteSession( - on: camera, - startingTransactionID: transactionID - ) - guard !Task.isCancelled, !self.isDisconnecting else { return } - transactionID = updatedID - isRemoteSessionReady = true - startShotBufferPolling() - schedulePropertyBasedShotCheck(reason: "PostHandshake", delay: 0.2) - } - } - - private func handlePTPEvent(_ eventData: Data) { - guard !isDisconnecting, isRemoteSessionReady else { return } - let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ") - CameraTetheringLogger.log("收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp) - - guard let container = try? SonyPTPHelper.parseContainer(eventData) else { - CameraTetheringLogger.log("PTP 事件解析失败", logger: CameraTetheringLogger.ptp) - return - } - - CameraTetheringLogger.log( - "PTP 事件码: 0x\(String(format: "%04X", container.code)), params=\(container.params)", - logger: CameraTetheringLogger.ptp - ) - - switch container.code { - case SonyPTPCommand.objectAdded, SonyPTPCommand.sdieObjectAdded: - guard isRemoteSessionReady else { break } - let handle = container.params.first ?? SonyPTPCommand.shotObjectHandle - enqueueShotDownload(handle: handle, reason: "ObjectAdded") - case SonyPTPCommand.sdieCapturedEvent: - guard isRemoteSessionReady else { break } - enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: "CapturedEvent") - case SonyPTPCommand.sdieObjectRemoved: - lastShootingFileInfoValue = nil - case SonyPTPCommand.sdieDevicePropChanged: - guard isRemoteSessionReady else { break } - schedulePropertyBasedShotCheck(reason: "DevicePropChanged") - default: - break - } - } - - private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval = 0.12) { - guard !isDisconnecting else { return } - if isEvaluatingShotBuffer { - propertyCheckCoalesceScheduled = true - return - } - propertyCheckTask?.cancel() - propertyCheckTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - guard !Task.isCancelled else { return } - await self?.runPropertyBasedShotCheck(reason: reason) - } - } - - private func runPropertyBasedShotCheck(reason: String) async { - guard !isDisconnecting else { return } - if isEvaluatingShotBuffer { - propertyCheckCoalesceScheduled = true - return - } - isEvaluatingShotBuffer = true - defer { - isEvaluatingShotBuffer = false - if propertyCheckCoalesceScheduled, !isDisconnecting { - propertyCheckCoalesceScheduled = false - coalescedCheckTask?.cancel() - coalescedCheckTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 80_000_000) - guard !Task.isCancelled else { return } - await self?.runPropertyBasedShotCheck(reason: "Coalesced") - } - } else { - propertyCheckCoalesceScheduled = false - } - } - await evaluateShotBufferFromProperties(reason: reason) - } - - private func evaluateShotBufferFromProperties(reason: String) async { - guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return } - - var localTransactionID = transactionID - var properties = await SonyPTPHelper.fetchExtDeviceProperties( - on: camera, - transactionID: &localTransactionID, - onlyDiff: true - ) - transactionID = localTransactionID - - var shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo] - - // diff 响应常不含 D215(仅其它属性变化),需 fallback 全量读取或直接 Probe - if shootingFileInfo == nil, shouldFallbackFullPropertyFetch(for: reason) { - properties = await SonyPTPHelper.fetchExtDeviceProperties( - on: camera, - transactionID: &localTransactionID, - onlyDiff: false - ) - transactionID = localTransactionID - shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo] - CameraTetheringLogger.log("\(reason): diff 无 D215,已读取全量属性", logger: CameraTetheringLogger.ptp) - } - - if let shootingFileInfo { - CameraTetheringLogger.log( - "\(reason): D215(Shooting_File_Info)=0x\(String(format: "%04X", shootingFileInfo))", - logger: CameraTetheringLogger.ptp - ) - lastShootingFileInfoValue = shootingFileInfo - } else { - CameraTetheringLogger.log("\(reason): 属性包无 D215,改用 Probe 检测缓冲", logger: CameraTetheringLogger.ptp) - } - - if SonyPTPHelper.isShotBufferReady(shootingFileInfo) { - _ = await attemptFetchPendingShot(reason: "\(reason)-D215") - return - } - - if shouldProbeWithoutD215Ready(for: reason) { - _ = await attemptFetchPendingShot(reason: "\(reason)-Probe") - } - } - - private func shouldFallbackFullPropertyFetch(for reason: String) -> Bool { - reason.contains("DevicePropChanged") - || reason.hasPrefix("D215Poll") - || reason.hasPrefix("Coalesced") - } - - private func shouldProbeWithoutD215Ready(for reason: String) -> Bool { - reason.contains("DevicePropChanged") - || reason.hasPrefix("D215Poll") - || reason.hasPrefix("Coalesced") - || reason.hasPrefix("BufferDrain") - } - - /// Probe 缓冲区内对象;若 filename+size 与上次不同则入队下载。 - @discardableResult - private func attemptFetchPendingShot(reason: String) async -> Bool { - guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return false } - - var localTransactionID = transactionID - guard let probe = await SonyPTPHelper.probeShotObject( - on: camera, - handle: SonyPTPCommand.shotObjectHandle, - transactionID: &localTransactionID - ) else { - return false - } - transactionID = localTransactionID - - let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size) - if signature == lastDownloadedObjectSignature { - CameraTetheringLogger.log("\(reason): 缓冲无新片 signature=\(signature)", logger: CameraTetheringLogger.ptp) - return false - } - - enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: reason) - return true - } - - private func startShotBufferPolling() { - stopShotBufferPolling() - CameraTetheringLogger.log("启动 D215 缓冲轮询 (连拍时 0.25s / 空闲 1.0s)", logger: CameraTetheringLogger.ptp) - refreshShotBufferPollInterval() - } - - private func refreshShotBufferPollInterval() { - guard !isDisconnecting, isRemoteSessionReady else { return } - let interval = (isDrainingShotQueue || !shotDownloadQueue.isEmpty) ? 0.25 : 1.0 - stopShotBufferPolling() - shotBufferPollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in - guard let self, !self.isDisconnecting, self.isRemoteSessionReady else { return } - Task { await self.evaluateShotBufferFromProperties(reason: "D215Poll") } - } - } - - private func stopShotBufferPolling() { - shotBufferPollTimer?.invalidate() - shotBufferPollTimer = nil - } - - /// 连拍时多个 PTP 事件会连续到达;入队串行处理,避免「下载进行中直接丢弃」。 - private func enqueueShotDownload(handle: UInt32, reason: String) { - guard !isDisconnecting else { return } - shotDownloadQueue.append((handle: handle, reason: reason)) - CameraTetheringLogger.log( - "\(reason): 入队 PTP 下载 (队列=\(shotDownloadQueue.count))", - logger: CameraTetheringLogger.ptp - ) - refreshShotBufferPollInterval() - startShotDownloadDrainIfNeeded() - } - - private func startShotDownloadDrainIfNeeded() { - guard !isDisconnecting else { return } - guard !isDrainingShotQueue else { return } - guard !shotDownloadQueue.isEmpty else { return } - - isDrainingShotQueue = true - shotDownloadDrainTask?.cancel() - shotDownloadDrainTask = Task { [weak self] in - await self?.drainShotDownloadQueue() - } - } - - private func drainShotDownloadQueue() async { - defer { - isDrainingShotQueue = false - shotDownloadDrainTask = nil - if !isDisconnecting { - refreshShotBufferPollInterval() - if !shotDownloadQueue.isEmpty { - startShotDownloadDrainIfNeeded() - } - } - } - - while !shotDownloadQueue.isEmpty, !isDisconnecting { - let item = shotDownloadQueue.removeFirst() - _ = await downloadPTPObject( - handle: item.handle, - reason: item.reason, - maxAttempts: 8, - tryDirectGetObject: true - ) - try? await Task.sleep(nanoseconds: 120_000_000) - } - - guard !isDisconnecting else { return } - await drainRemainingBuffer(maxExtraDownloads: 12) - } - - /// 连拍时部分事件可能在队列排空期间才写入缓冲;按 D215/Probe 继续拉取直到无新片。 - private func drainRemainingBuffer(maxExtraDownloads: Int) async { - for round in 1...maxExtraDownloads { - guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { break } - - var localTransactionID = transactionID - guard let probe = await SonyPTPHelper.probeShotObject( - on: camera, - handle: SonyPTPCommand.shotObjectHandle, - transactionID: &localTransactionID - ) else { - break - } - transactionID = localTransactionID - - let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size) - if signature == lastDownloadedObjectSignature { - break - } - - let downloaded = await downloadPTPObject( - handle: SonyPTPCommand.shotObjectHandle, - reason: "BufferDrain-\(round)", - maxAttempts: 6, - tryDirectGetObject: true - ) - guard downloaded else { break } - try? await Task.sleep(nanoseconds: 100_000_000) - } - } - - @discardableResult - private func downloadPTPObject( - handle: UInt32, - reason: String, - maxAttempts: Int = 3, - tryDirectGetObject: Bool = false - ) async -> Bool { - guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { - CameraTetheringLogger.log("\(reason): Sony 远程会话尚未就绪", logger: CameraTetheringLogger.ptp) - return false - } - guard !isDownloadingPTPObject else { - CameraTetheringLogger.log("\(reason): 下载重入,重新入队", logger: CameraTetheringLogger.ptp) - shotDownloadQueue.insert((handle: handle, reason: reason), at: 0) - return false - } - - isDownloadingPTPObject = true - defer { isDownloadingPTPObject = false } - - var localTransactionID = transactionID - - for attempt in 1...maxAttempts { - guard !isDisconnecting else { return false } - if attempt > 1 { - try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000) - } - - var probeFilename = "DSC_\(Int(Date().timeIntervalSince1970)).JPG" - var probeSize: UInt32 = 0 - - if let probe = await SonyPTPHelper.probeShotObject( - on: camera, - handle: handle, - transactionID: &localTransactionID - ) { - probeFilename = probe.filename - probeSize = probe.size - } else if !tryDirectGetObject { - if attempt == maxAttempts { - CameraTetheringLogger.log("\(reason): 相机缓冲区内暂无可用照片", logger: CameraTetheringLogger.ptp) - } - continue - } - - let signature: String - if probeSize > 0 { - signature = SonyPTPHelper.shotObjectSignature(filename: probeFilename, size: probeSize) - if signature == lastDownloadedObjectSignature { - CameraTetheringLogger.log("\(reason): 照片已下载过 \(probeFilename),跳过", logger: CameraTetheringLogger.ptp) - return false - } - } else { - signature = "\(probeFilename)_attempt\(attempt)_\(Int(Date().timeIntervalSince1970))" - } - - guard let result = await SonyPTPHelper.downloadObject( - handle: handle, - on: camera, - transactionID: &localTransactionID, - skipObjectInfo: tryDirectGetObject && probeSize == 0 - ) else { - if attempt == maxAttempts { - CameraTetheringLogger.log("\(reason): PTP 下载失败 handle=0x\(String(format: "%08X", handle))", logger: CameraTetheringLogger.ptp) - } - continue - } - - transactionID = localTransactionID - lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)" - - let assetID = "ptp_\(handle)_\(result.filename)_\(result.data.count)" - let destination = CameraDownloadStorage.uniqueTemporaryURL(for: result.filename) - do { - try result.data.write(to: destination, options: .atomic) - } catch { - CameraTetheringLogger.log("PTP 文件写入失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) - return false - } - - let asset = CameraAsset( - id: assetID, - filename: result.filename, - fileSize: Int64(result.data.count), - creationDate: Date(), - isMovie: false - ) - ptpAssetURLs[assetID] = destination - - CameraTetheringLogger.log( - "[\(reason)] PTP 下载完成: \(result.filename) -> \(destination.path)", - logger: CameraTetheringLogger.transfer - ) - onPTPAssetReady?(asset, destination) - return true - } - - return false - } - - private func notifyBaselineOrPoll(reason: String) { - guard isCatalogReady else { - CameraTetheringLogger.log("\(reason): 目录尚未就绪,等待 catalog ready") - return - } - onPollTick?() - } - - private func notifyBaselineIfNeeded() { - guard !baselineNotified else { return } - baselineNotified = true - isCatalogReady = true - - let fileCount = allFiles().count - CameraTetheringLogger.log("目录就绪,当前 \(fileCount) 个文件,建立基线") - onBaselineCatalogReady?() - startPolling() - } - - private func startPolling() { - stopPolling() - CameraTetheringLogger.log("启动轮询检测新照片 (每 1.5s)") - - pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in - guard let self, self.isCatalogReady else { return } - self.onPollTick?() - } - } - - private func stopPolling() { - pollTimer?.invalidate() - pollTimer = nil - } -} - -extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate { - @objc(deviceBrowser:didAddDevice:moreComing:) - func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) { - CameraTetheringLogger.log("发现设备: \(device.name ?? "?"), type=\(device.type), transport=\(device.transportType ?? "?")") - - guard let camera = device as? ICCameraDevice else { - CameraTetheringLogger.log("非相机设备,忽略") - return - } - guard cameraDevice == nil else { - CameraTetheringLogger.log("已有连接中的相机,忽略") - return - } - - requestAuthorizations { [weak self] contentsGranted, controlGranted in - guard let self else { return } - guard contentsGranted else { - let message = "未获得相机内容访问权限,请在系统设置中允许" - CameraTetheringLogger.log(message) - self.onError?(message) - self.updateState(.error("权限被拒绝")) - return - } - if !controlGranted { - CameraTetheringLogger.log("警告: 控制授权未通过,仍尝试连接(下载通常只需内容授权)") - } - self.openSession(for: camera) - } - } - - @objc(deviceBrowser:didRemoveDevice:moreGoing:) - func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) { - guard !isDisconnecting else { return } - CameraTetheringLogger.log("设备移除: \(device.name ?? "?")") - if device.uuidString == cameraDevice?.uuidString { - Task { await disconnect() } - updateState(.disconnected) - } - } -} - -extension ImageCaptureDeviceManager: ICDeviceDelegate { - func didRemove(_ device: ICDevice) { - guard !isDisconnecting else { return } - CameraTetheringLogger.log("didRemove: \(device.name ?? "?")") - if device.uuidString == cameraDevice?.uuidString { - Task { await disconnect() } - updateState(.disconnected) - } - } - - func device(_ device: ICDevice, didOpenSessionWithError error: Error?) { - if let error { - CameraTetheringLogger.log("会话打开失败: \(error.localizedDescription)") - onError?(error.localizedDescription) - updateState(.error(error.localizedDescription)) - return - } - - guard let camera = device as? ICCameraDevice else { return } - - let name = camera.name ?? "Camera" - CameraTetheringLogger.log("会话已打开: \(name)") - updateState(.connected(deviceName: name)) - configureCameraAfterSession(camera) - } - - func device(_ device: ICDevice, didCloseSessionWithError error: Error?) { - if let error { - CameraTetheringLogger.log("会话关闭: \(error.localizedDescription)") - } - if device.uuidString == closingDeviceUUID { - finishSessionClose() - } - guard !isDisconnecting else { return } - if device.uuidString == cameraDevice?.uuidString { - cameraDevice = nil - isCatalogReady = false - baselineNotified = false - isRemoteSessionReady = false - stopPolling() - stopShotBufferPolling() - updateState(.disconnected) - } - } -} - -extension ImageCaptureDeviceManager: ICCameraDeviceDelegate { - @objc(cameraDevice:didAddItems:) - func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) { - let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name } - CameraTetheringLogger.log("didAddItems: \(names.joined(separator: ", "))") - - for item in items { - if let file = item as? ICCameraFile { - CameraTetheringLogger.log(" + \(file.name ?? "?") addedAfterCatalog=\(file.wasAddedAfterContentCatalogCompleted)") - } - } - - onNewItems?(items) - } - - @objc(cameraDevice:didRemoveItems:) - func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) { - let names = items.compactMap { $0.name } - CameraTetheringLogger.log("didRemoveItems: \(names.joined(separator: ", "))") - } - - @objc(cameraDevice:didRenameItems:) - func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) { - } - - func cameraDevice(_ camera: ICCameraDevice, didReceiveThumbnail thumbnail: CGImage?, for item: ICCameraItem, error: Error?) { - } - - func cameraDevice(_ camera: ICCameraDevice, didReceiveMetadata metadata: [AnyHashable: Any]?, for item: ICCameraItem, error: Error?) { - } - - func deviceDidBecomeReady(withCompleteContentCatalog device: ICCameraDevice) { - CameraTetheringLogger.log("deviceDidBecomeReadyWithCompleteContentCatalog, 文件数=\(allFiles().count)") - notifyBaselineIfNeeded() - } - - func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) { - CameraTetheringLogger.log("cameraDeviceDidChangeCapability") - } - - func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) { - CameraTetheringLogger.log("cameraDeviceDidRemoveAccessRestriction") - } - - func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) { - CameraTetheringLogger.log("cameraDeviceDidEnableAccessRestriction") - } - - func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) { - handlePTPEvent(eventData) - } -} diff --git a/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift b/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift deleted file mode 100644 index 62c4bad..0000000 --- a/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift +++ /dev/null @@ -1,195 +0,0 @@ -import Foundation -import ImageCaptureCore - -@MainActor -final class SonyCameraService: CameraServiceProtocol { - let brand: CameraBrand = .sony - - private(set) var connectionState: CameraConnectionState = .disconnected - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - - private let deviceManager = ImageCaptureDeviceManager() - private var knownAssetIDs: Set = [] - private var baselineEstablished = false - private var pendingAssetsWhileDetached: [CameraAsset] = [] - - init() { - deviceManager.onConnectionStateChange = { [weak self] state in - Task { @MainActor in - self?.connectionState = state - self?.onConnectionStateChange?(state) - } - } - - deviceManager.onNewItems = { [weak self] items in - Task { @MainActor in - self?.handleNewItems(items, source: "didAddItems") - } - } - - deviceManager.onBaselineCatalogReady = { [weak self] in - Task { @MainActor in - self?.establishBaseline() - } - } - - deviceManager.onPollTick = { [weak self] in - Task { @MainActor in - self?.scanForNewAssets(source: "poll") - } - } - - deviceManager.onPTPAssetReady = { [weak self] asset, _ in - Task { @MainActor in - self?.handlePTPAsset(asset) - } - } - - deviceManager.onError = { [weak self] message in - Task { @MainActor in - CameraTetheringLogger.log("相机错误: \(message)") - self?.connectionState = .error(message) - self?.onConnectionStateChange?(.error(message)) - } - } - } - - func connect() async { - CameraTetheringLogger.log("SonyCameraService.connect()") - let shouldResetBaseline = !deviceManager.isBrowsing - deviceManager.startBrowsing() - if shouldResetBaseline { - knownAssetIDs.removeAll() - baselineEstablished = false - } - } - - /// 由 AutoDetect 在已完成授权后直接挂载设备。 - func attach(to camera: ICCameraDevice) async { - CameraTetheringLogger.log("SonyCameraService.attach()") - knownAssetIDs.removeAll() - baselineEstablished = false - pendingAssetsWhileDetached.removeAll() - deviceManager.attach(to: camera) - } - - /// 页面离开:仅清空 UI 回调,底层 PTP 会话与轮询继续。 - func detachFromUI() { - CameraTetheringLogger.log("SonyCameraService.detachFromUI()") - onConnectionStateChange = nil - onNewAsset = nil - } - - /// 再次进入页面:补发 detach 期间缓冲的新照片。 - func reattachToUI() { - guard !pendingAssetsWhileDetached.isEmpty else { return } - let pending = pendingAssetsWhileDetached - pendingAssetsWhileDetached.removeAll() - CameraTetheringLogger.log("SonyCameraService.reattachToUI: 补发 \(pending.count) 张离线新片") - for asset in pending { - onNewAsset?(asset) - } - } - - func shutdown() async { - CameraTetheringLogger.log("SonyCameraService.shutdown()") - await deviceManager.stopBrowsing() - knownAssetIDs.removeAll() - baselineEstablished = false - pendingAssetsWhileDetached.removeAll() - connectionState = .disconnected - onConnectionStateChange?(.disconnected) - } - - func listAssets() async throws -> [CameraAsset] { - guard connectionState.isConnected else { - throw CameraServiceError.notConnected - } - let assets = deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) } - CameraTetheringLogger.log("listAssets: \(assets.count) 个文件") - return assets - } - - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - guard connectionState.isConnected else { - throw CameraServiceError.notConnected - } - if let cachedURL = deviceManager.localURL(forAssetID: asset.id) { - CameraTetheringLogger.log("downloadAsset: 返回 PTP 缓存 \(asset.filename)", logger: CameraTetheringLogger.transfer) - return cachedURL - } - guard let file = deviceManager.file(forAssetID: asset.id) else { - CameraTetheringLogger.log("downloadAsset: 找不到文件 \(asset.filename) id=\(asset.id)", logger: CameraTetheringLogger.transfer) - throw CameraServiceError.assetNotFound - } - - let directory = CameraDownloadStorage.temporaryDownloadsDirectory - return try await deviceManager.download(file: file, to: directory) - } - - private func handlePTPAsset(_ asset: CameraAsset) { - deliverNewAsset(asset, source: "PTP") - } - - /// 连接后首次目录就绪:已有照片记入基线;拍摄于枚举期间的新照片立即传输。 - private func establishBaseline() { - guard !baselineEstablished else { return } - baselineEstablished = true - - let files = deviceManager.allFiles() - var skipped = 0 - var newCount = 0 - - for file in files { - if file.wasAddedAfterContentCatalogCompleted { - emitAssetIfNew(file: file, source: "baseline-new") - newCount += 1 - } else { - knownAssetIDs.insert(deviceManager.assetIdentifier(for: file)) - skipped += 1 - } - } - CameraTetheringLogger.log("基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount) 张") - } - - private func handleNewItems(_ items: [ICCameraItem], source: String) { - guard baselineEstablished else { - CameraTetheringLogger.log("\(source): 基线未建立,暂存处理") - return - } - - for item in items { - guard let file = item as? ICCameraFile else { continue } - emitAssetIfNew(file: file, source: source) - } - } - - private func scanForNewAssets(source: String) { - guard baselineEstablished else { return } - - let files = deviceManager.allFiles() - for file in files { - emitAssetIfNew(file: file, source: source) - } - } - - private func emitAssetIfNew(file: ICCameraFile, source: String) { - let asset = deviceManager.makeAsset(from: file) - deliverNewAsset(asset, source: source) - } - - private func deliverNewAsset(_ asset: CameraAsset, source: String) { - guard !knownAssetIDs.contains(asset.id) else { return } - knownAssetIDs.insert(asset.id) - CameraTetheringLogger.log( - "[\(source)] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)", - logger: CameraTetheringLogger.transfer - ) - if let onNewAsset { - onNewAsset(asset) - } else { - pendingAssetsWhileDetached.append(asset) - } - } -} diff --git a/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift b/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift deleted file mode 100644 index ae14ccf..0000000 --- a/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift +++ /dev/null @@ -1,444 +0,0 @@ -import Foundation -import ImageCaptureCore - -enum SonyPTPCommand { - static let getDeviceInfo: UInt16 = 0x1001 - static let openSession: UInt16 = 0x1002 - static let closeSession: UInt16 = 0x1003 - static let getObjectHandles: UInt16 = 0x1007 - static let getObjectInfo: UInt16 = 0x1008 - static let getObject: UInt16 = 0x1009 - static let objectAdded: UInt16 = 0x4002 - static let sdieObjectAdded: UInt16 = 0xC201 - static let sdieObjectRemoved: UInt16 = 0xC202 - static let sdieDevicePropChanged: UInt16 = 0xC203 - static let sdieCapturedEvent: UInt16 = 0xC206 - static let sdioConnect: UInt16 = 0x9201 - static let sdioGetExtDeviceInfo: UInt16 = 0x9202 - static let sdioSetExtDevicePropValue: UInt16 = 0x9205 - static let sdioGetAllExtDevicePropInfo: UInt16 = 0x9209 - static let positionKeySetting: UInt16 = 0xD25A - static let stillImageSaveDestination: UInt16 = 0xD222 - static let stillImageTransSize: UInt16 = 0xD268 - static let shootingFileInfo: UInt16 = 0xD215 - static let shotObjectHandle: UInt32 = 0xFFFF_C001 - static let ptpResponseOk: UInt16 = PTPResponseCode.ok.rawValue - static let saveDestinationHostPC: UInt16 = 1 - static let saveDestinationHostAndCamera: UInt16 = 2 - static let shotBufferReadyThreshold: Int64 = 0x8000 -} - -private enum PTPDataType: UInt16 { - case int8 = 0x0001 - case uint8 = 0x0002 - case int16 = 0x0003 - case uint16 = 0x0004 - case int32 = 0x0005 - case uint32 = 0x0006 - case int64 = 0x0007 - case uint64 = 0x0008 - case string = 0xFFFF -} - -enum SonyPTPHelper { - static func buildCommand( - code: UInt16, - transactionID: UInt32, - parameters: [UInt32] = [] - ) -> Data { - PTPHelper.buildCommand(code: code, transactionID: transactionID, parameters: parameters) - } - - static func parseContainer(_ data: Data) throws -> PTPContainer { - try PTPHelper.parseContainer(data) - } - - static func parseEventCode(from eventData: Data) -> UInt16? { - PTPHelper.parseEventCode(from: eventData) - } - - static func parseObjectCompressedSize(_ data: Data) -> UInt32? { - PTPHelper.parseObjectCompressedSize(data) - } - - static func parseObjectInfoFilename(_ data: Data) -> String? { - PTPHelper.parseObjectInfoFilename(data) - } - - static func parsePTPString(_ data: Data, offset start: Int) -> (string: String, nextOffset: Int)? { - PTPHelper.parsePTPString(data, offset: start) - } - - static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 { - PTPHelper.readUInt16LE(data, offset: offset) - } - - static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 { - PTPHelper.readUInt32LE(data, offset: offset) - } - - /// ImageCaptureCore 已建立 PTP 会话,此处执行 Sony SDIO 握手并进入 PC Remote 模式。 - static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> UInt32 { - var transactionID = startingTransactionID - - CameraTetheringLogger.log("开始 Sony SDIO 握手", logger: CameraTetheringLogger.ptp) - - let step1 = await sendCommand( - on: camera, - code: SonyPTPCommand.sdioConnect, - transactionID: &transactionID, - parameters: [1, 0, 0], - outData: nil, - label: "SDIO Connect(1)" - ) - guard step1.success else { return transactionID } - - let step2 = await sendCommand( - on: camera, - code: SonyPTPCommand.sdioConnect, - transactionID: &transactionID, - parameters: [2, 0, 0], - outData: nil, - label: "SDIO Connect(2)" - ) - guard step2.success else { return transactionID } - - let step3 = await sendCommand( - on: camera, - code: SonyPTPCommand.sdioGetExtDeviceInfo, - transactionID: &transactionID, - parameters: [0x012C, 1], - outData: nil, - label: "SDIO GetExtDeviceInfo" - ) - guard step3.success else { return transactionID } - - let step4 = await sendCommand( - on: camera, - code: SonyPTPCommand.sdioConnect, - transactionID: &transactionID, - parameters: [3, 0, 0], - outData: nil, - label: "SDIO Connect(3)" - ) - guard step4.success else { return transactionID } - - _ = await sendCommand( - on: camera, - code: SonyPTPCommand.sdioGetAllExtDevicePropInfo, - transactionID: &transactionID, - parameters: [1], - outData: nil, - label: "SDIO GetAllExtDevicePropInfo" - ) - - await configureRemoteCapture(on: camera, transactionID: &transactionID) - CameraTetheringLogger.log("Sony SDIO 握手完成", logger: CameraTetheringLogger.ptp) - return transactionID - } - - /// 关闭 Sony PC Remote 并释放 PTP 会话。 - static func teardownRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async { - _ = await setDeviceProperty( - on: camera, - propertyCode: SonyPTPCommand.positionKeySetting, - value: 0, - transactionID: &transactionID, - label: "Position_Key_Setting=Local" - ) - await closeRemoteSession(on: camera, transactionID: &transactionID) - } - - /// 关闭 PTP 远程会话,释放相机 PC Remote 占用。 - static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async { - _ = await sendCommand( - on: camera, - code: SonyPTPCommand.closeSession, - transactionID: &transactionID, - parameters: [], - outData: nil, - label: "CloseSession" - ) - } - - static func probeShotObject( - on camera: ICCameraDevice, - handle: UInt32 = SonyPTPCommand.shotObjectHandle, - transactionID: inout UInt32 - ) async -> (filename: String, size: UInt32)? { - let infoResult = await sendCommand( - on: camera, - code: SonyPTPCommand.getObjectInfo, - transactionID: &transactionID, - parameters: [handle], - outData: nil, - label: "ProbeObjectInfo(0x\(String(format: "%08X", handle)))" - ) - guard infoResult.success else { return nil } - - guard let size = parseObjectCompressedSize(infoResult.inData), size > 0 else { - return nil - } - - let filename = parseObjectInfoFilename(infoResult.inData) - ?? CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG") - return (filename, size) - } - - static func downloadObject( - handle: UInt32, - on camera: ICCameraDevice, - transactionID: inout UInt32, - skipObjectInfo: Bool = false - ) async -> (filename: String, data: Data)? { - var filename = CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG") - - if !skipObjectInfo { - let infoResult = await sendCommand( - on: camera, - code: SonyPTPCommand.getObjectInfo, - transactionID: &transactionID, - parameters: [handle], - outData: nil, - label: "GetObjectInfo(0x\(String(format: "%08X", handle)))" - ) - if infoResult.success, let parsedName = parseObjectInfoFilename(infoResult.inData) { - filename = parsedName - } - } - - let objectResult = await sendCommand( - on: camera, - code: SonyPTPCommand.getObject, - transactionID: &transactionID, - parameters: [handle], - outData: nil, - label: "GetObject(0x\(String(format: "%08X", handle)))" - ) - guard objectResult.success, PTPHelper.isLikelyImageData(objectResult.inData) else { return nil } - - return (filename, objectResult.inData) - } - - static func isLikelyImageData(_ data: Data) -> Bool { - PTPHelper.isLikelyImageData(data) - } - - static func isResponseOK(_ response: Data) -> Bool { - PTPHelper.isResponseOK(response) - } - - static func fetchExtDeviceProperties( - on camera: ICCameraDevice, - transactionID: inout UInt32, - onlyDiff: Bool = true - ) async -> [UInt16: Int64] { - let result = await sendCommand( - on: camera, - code: SonyPTPCommand.sdioGetAllExtDevicePropInfo, - transactionID: &transactionID, - parameters: [onlyDiff ? 1 : 0], - outData: nil, - label: "GetAllExtDevicePropInfo(\(onlyDiff ? "diff" : "all"))" - ) - guard result.success else { return [:] } - return parseExtDeviceProperties(result.inData) - } - - static func parseExtDeviceProperties(_ data: Data) -> [UInt16: Int64] { - guard data.count >= 8 else { return [:] } - - var properties: [UInt16: Int64] = [:] - var offset = 8 - - while offset + 6 <= data.count { - let propertyCode = readUInt16LE(data, offset: offset) - offset += 2 - let dataType = readUInt16LE(data, offset: offset) - offset += 2 - offset += 2 // getset, isenabled - - guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { break } - guard let currentValue = readVariableValue(dataType: dataType, data: data, offset: &offset) else { break } - properties[propertyCode] = currentValue - - guard offset < data.count else { break } - let formFlag = data[offset] - offset += 1 - guard skipFormPayload(formFlag: formFlag, dataType: dataType, data: data, offset: &offset) else { break } - } - - return properties - } - - static func isShotBufferReady(_ shootingFileInfo: Int64?) -> Bool { - guard let shootingFileInfo else { return false } - return shootingFileInfo > SonyPTPCommand.shotBufferReadyThreshold - } - - static func shotObjectSignature(filename: String, size: UInt32) -> String { - "\(filename)_\(size)" - } - - private static func readVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Int64? { - guard let type = PTPDataType(rawValue: dataType) else { return nil } - - switch type { - case .int8: - guard offset < data.count else { return nil } - let value = Int64(Int8(bitPattern: data[offset])) - offset += 1 - return value - case .uint8: - guard offset < data.count else { return nil } - let value = Int64(data[offset]) - offset += 1 - return value - case .int16: - guard offset + 1 < data.count else { return nil } - let value = Int64(Int16(bitPattern: readUInt16LE(data, offset: offset))) - offset += 2 - return value - case .uint16: - guard offset + 1 < data.count else { return nil } - let value = Int64(readUInt16LE(data, offset: offset)) - offset += 2 - return value - case .int32: - guard offset + 3 < data.count else { return nil } - let value = Int64(Int32(bitPattern: readUInt32LE(data, offset: offset))) - offset += 4 - return value - case .uint32: - guard offset + 3 < data.count else { return nil } - let value = Int64(readUInt32LE(data, offset: offset)) - offset += 4 - return value - case .int64, .uint64: - guard offset + 7 < data.count else { return nil } - offset += 8 - return 0 - case .string: - guard offset < data.count else { return nil } - let length = Int(data[offset]) - offset += 1 + length * 2 - return 0 - } - } - - private static func skipVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Bool { - readVariableValue(dataType: dataType, data: data, offset: &offset) != nil - } - - private static func skipFormPayload(formFlag: UInt8, dataType: UInt16, data: Data, offset: inout Int) -> Bool { - switch formFlag { - case 0: - return true - case 1: - for _ in 0..<3 { - guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false } - } - return true - case 2: - guard offset + 1 < data.count else { return false } - var enumCount = Int(readUInt16LE(data, offset: offset)) - offset += 2 - for _ in 0.. Bool { - var payload = Data() - PTPHelper.appendUInt32LE(value, to: &payload) - - return await sendCommand( - on: camera, - code: SonyPTPCommand.sdioSetExtDevicePropValue, - transactionID: &transactionID, - parameters: [UInt32(propertyCode), 1], - outData: payload, - label: label - ).success - } - - private static func setDeviceProperty( - on camera: ICCameraDevice, - propertyCode: UInt16, - value: UInt16, - transactionID: inout UInt32, - label: String - ) async -> Bool { - var payload = Data() - PTPHelper.appendUInt16LE(value, to: &payload) - - return await sendCommand( - on: camera, - code: SonyPTPCommand.sdioSetExtDevicePropValue, - transactionID: &transactionID, - parameters: [UInt32(propertyCode), 1], - outData: payload, - label: label - ).success - } - - private static func sendCommand( - on camera: ICCameraDevice, - code: UInt16, - transactionID: inout UInt32, - parameters: [UInt32], - outData: Data?, - label: String - ) async -> PTPCommandResult { - await PTPHelper.sendCommand( - on: camera, - code: code, - transactionID: &transactionID, - parameters: parameters, - outData: outData, - label: label - ) - } -} diff --git a/suixinkan/Core/CameraTethering/Utilities/CameraDownloadDelegate.swift b/suixinkan/Core/CameraTethering/Utilities/CameraDownloadDelegate.swift deleted file mode 100644 index 41a2328..0000000 --- a/suixinkan/Core/CameraTethering/Utilities/CameraDownloadDelegate.swift +++ /dev/null @@ -1,37 +0,0 @@ -import CoreGraphics -import Foundation -import ImageCaptureCore - -/// ImageCaptureCore 文件下载回调代理。 -final class CameraDownloadDelegate: NSObject, ICCameraDeviceDownloadDelegate { - var onComplete: ((ICCameraFile, URL?, Error?) -> Void)? - var onProgress: ((String, Double) -> Void)? - - @objc func didDownloadFile(_ file: ICCameraFile, error: Error?, options: [String: Any], contextInfo: UnsafeMutableRawPointer?) { - if let error { - CameraTetheringLogger.log("下载失败 \(file.name ?? "?"): \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) - onComplete?(file, nil, error) - return - } - - let downloadOptions = options as? [ICDownloadOption: Any] ?? [:] - if let directory = downloadOptions[.downloadsDirectoryURL] as? URL, - let filename = downloadOptions[.savedFilename] as? String { - let url = directory.appendingPathComponent(filename) - CameraTetheringLogger.log("下载完成 \(filename) -> \(url.path)", logger: CameraTetheringLogger.transfer) - onComplete?(file, url, nil) - } else if let urlString = file.value(forKey: "url") as? String { - let url = URL(fileURLWithPath: urlString) - CameraTetheringLogger.log("下载完成 \(file.name ?? "?") -> \(url.path)", logger: CameraTetheringLogger.transfer) - onComplete?(file, url, nil) - } else { - CameraTetheringLogger.log("下载完成但无法定位路径: \(file.name ?? "?")", logger: CameraTetheringLogger.transfer) - onComplete?(file, nil, CameraServiceError.downloadFailed("无法定位下载文件路径")) - } - } - - @objc func didReceiveDownloadProgress(for file: ICCameraFile, downloadedBytes: off_t, maxBytes: off_t) { - guard maxBytes > 0, let name = file.name else { return } - onProgress?(name, Double(downloadedBytes) / Double(maxBytes)) - } -} diff --git a/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift b/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift deleted file mode 100644 index 04ffe5f..0000000 --- a/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// CameraTetheringLogger.swift -// suixinkan -// -// Created by Codex on 2026/6/29. -// - -import Foundation -import os - -/// 相机有线传输模块日志工具,按 Camera / Transfer / PTP 分类输出。 -enum CameraTetheringLogger { - /// 控制台统一前缀,便于 Xcode / Console.app 过滤:`[OTG]` - static let logPrefix = "[OTG]" - - private static let subsystem = Bundle.main.bundleIdentifier ?? "com.suixinkan" - - static let camera = Logger(subsystem: subsystem, category: "Camera") - static let transfer = Logger(subsystem: subsystem, category: "Transfer") - static let ptp = Logger(subsystem: subsystem, category: "PTP") - - /// 写入指定分类的 info 日志,消息前自动附加 `[OTG]` 前缀。 - static func log(_ message: String, logger: Logger = camera) { - let formatted = prefixed(message) - logger.info("\(formatted, privacy: .public)") - } - - /// 为消息附加 OTG 统一前缀;若已含前缀则不再重复添加。 - static func prefixed(_ message: String) -> String { - if message.hasPrefix(logPrefix) { - return message - } - return "\(logPrefix) \(message)" - } -} diff --git a/suixinkan/Core/CameraTethering/Utilities/PTPCommon.swift b/suixinkan/Core/CameraTethering/Utilities/PTPCommon.swift deleted file mode 100644 index d97026c..0000000 --- a/suixinkan/Core/CameraTethering/Utilities/PTPCommon.swift +++ /dev/null @@ -1,231 +0,0 @@ -import Foundation -import ImageCaptureCore - -/// PTP 容器类型。 -enum PTPContainerType: UInt16 { - case command = 0x0001 - case data = 0x0002 - case response = 0x0003 - case event = 0x0004 -} - -/// PTP 命令/响应/事件容器。 -struct PTPContainer { - let length: UInt32 - let type: UInt16 - let code: UInt16 - let transactionID: UInt32 - let params: [UInt32] -} - -enum PTPParseError: Error { - case tooShort - case invalidLength -} - -/// 标准 PTP 响应码。 -enum PTPResponseCode: UInt16 { - case ok = 0x2001 - case generalError = 0x2002 - case sessionNotOpen = 0x2003 - case invalidTransaction = 0x2004 - case operationNotSupported = 0x2005 -} - -/// PTP 命令发送结果。 -struct PTPCommandResult { - let success: Bool - let response: Data - let inData: Data -} - -/// 各品牌共用的 PTP 编解码与命令发送工具。 -enum PTPHelper { - static func buildCommand( - code: UInt16, - transactionID: UInt32, - parameters: [UInt32] = [] - ) -> Data { - let length = UInt32(12 + parameters.count * 4) - var data = Data() - appendUInt32LE(length, to: &data) - appendUInt16LE(PTPContainerType.command.rawValue, to: &data) - appendUInt16LE(code, to: &data) - appendUInt32LE(transactionID, to: &data) - for parameter in parameters { - appendUInt32LE(parameter, to: &data) - } - return data - } - - static func parseContainer(_ data: Data) throws -> PTPContainer { - guard data.count >= 12 else { throw PTPParseError.tooShort } - - let length = readUInt32LE(data, offset: 0) - guard length >= 12, data.count >= Int(length) else { throw PTPParseError.invalidLength } - - let type = readUInt16LE(data, offset: 4) - let code = readUInt16LE(data, offset: 6) - let transactionID = readUInt32LE(data, offset: 8) - - var params: [UInt32] = [] - var offset = 12 - while offset + 4 <= Int(length) { - params.append(readUInt32LE(data, offset: offset)) - offset += 4 - } - - return PTPContainer( - length: length, - type: type, - code: code, - transactionID: transactionID, - params: params - ) - } - - static func parseEventCode(from eventData: Data) -> UInt16? { - guard let container = try? parseContainer(eventData) else { return nil } - return container.code - } - - static func parseObjectCompressedSize(_ data: Data) -> UInt32? { - guard data.count >= 12 else { return nil } - return readUInt32LE(data, offset: 8) - } - - static func parseObjectInfoFilename(_ data: Data) -> String? { - guard data.count >= 53 else { return nil } - - if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty { - return CameraDownloadStorage.sanitizeFilename(filename) - } - - var offset = 52 - var codeUnits: [UInt16] = [] - while offset + 1 < data.count { - let unit = readUInt16LE(data, offset: offset) - if unit == 0 { break } - codeUnits.append(unit) - offset += 2 - } - - guard !codeUnits.isEmpty else { return nil } - let raw = String(utf16CodeUnits: codeUnits, count: codeUnits.count) - return CameraDownloadStorage.sanitizeFilename(raw) - } - - static func parsePTPString(_ data: Data, offset start: Int) -> (string: String, nextOffset: Int)? { - guard start < data.count else { return nil } - let charCount = Int(data[start]) - var offset = start + 1 - guard charCount > 0, offset + charCount * 2 <= data.count else { return nil } - - var codeUnits: [UInt16] = [] - codeUnits.reserveCapacity(charCount) - for index in 0.. Bool { - guard data.count >= 3 else { return false } - if data[0] == 0xFF, data[1] == 0xD8, data[2] == 0xFF { return true } - if data.count >= 4, data[0] == 0x89, data[1] == 0x50, data[2] == 0x4E, data[3] == 0x47 { return true } - return data.count > 1024 - } - - static func isResponseOK(_ response: Data) -> Bool { - guard !response.isEmpty else { return true } - guard let parsed = try? parseContainer(response) else { return false } - return parsed.code == PTPResponseCode.ok.rawValue - } - - /// 通过 ImageCaptureCore 发送 PTP 命令。 - static func sendCommand( - on camera: ICCameraDevice, - code: UInt16, - transactionID: inout UInt32, - parameters: [UInt32], - outData: Data?, - label: String - ) async -> PTPCommandResult { - let currentID = transactionID - transactionID += 1 - - let commandData = buildCommand( - code: code, - transactionID: currentID, - parameters: parameters - ) - - return await withCheckedContinuation { continuation in - camera.requestSendPTPCommand( - commandData, - outData: outData, - completion: { inData, response, error in - if let error { - CameraTetheringLogger.log("PTP \(label) 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.ptp) - continuation.resume(returning: PTPCommandResult(success: false, response: response, inData: inData)) - return - } - - var commandSuccess = true - if !response.isEmpty { - do { - let parsed = try parseContainer(response) - if parsed.code != PTPResponseCode.ok.rawValue { - CameraTetheringLogger.log( - "PTP \(label) 响应码: 0x\(String(format: "%04X", parsed.code)) (非 OK)", - logger: CameraTetheringLogger.ptp - ) - commandSuccess = false - } - } catch { - CameraTetheringLogger.log("PTP \(label) 响应解析失败", logger: CameraTetheringLogger.ptp) - commandSuccess = false - } - } - - if commandSuccess { - CameraTetheringLogger.log( - "PTP \(label) 成功, inData=\(inData.count) bytes", - logger: CameraTetheringLogger.ptp - ) - } - continuation.resume(returning: PTPCommandResult(success: commandSuccess, response: response, inData: inData)) - } - ) - } - } - - static func appendUInt16LE(_ value: UInt16, to data: inout Data) { - var littleEndian = value.littleEndian - withUnsafeBytes(of: &littleEndian) { raw in - data.append(contentsOf: raw) - } - } - - static func appendUInt32LE(_ value: UInt32, to data: inout Data) { - var littleEndian = value.littleEndian - withUnsafeBytes(of: &littleEndian) { raw in - data.append(contentsOf: raw) - } - } - - static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 { - UInt16(data[offset]) - | (UInt16(data[offset + 1]) << 8) - } - - static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 { - UInt32(data[offset]) - | (UInt32(data[offset + 1]) << 8) - | (UInt32(data[offset + 2]) << 16) - | (UInt32(data[offset + 3]) << 24) - } -} diff --git a/suixinkan/Core/CameraTethering/Utilities/SharedUSBCameraBrowserHub.swift b/suixinkan/Core/CameraTethering/Utilities/SharedUSBCameraBrowserHub.swift deleted file mode 100644 index 722ce4a..0000000 --- a/suixinkan/Core/CameraTethering/Utilities/SharedUSBCameraBrowserHub.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -/// 进程内共享 USB Browser,避免页面离开后 stop Browser 导致 iOS 无法再次枚举仍连接的相机。 -enum SharedUSBCameraBrowserHub { - static let controller = USBDeviceBrowserController() -} diff --git a/suixinkan/Core/CameraTethering/Utilities/USBDeviceBrowserController.swift b/suixinkan/Core/CameraTethering/Utilities/USBDeviceBrowserController.swift deleted file mode 100644 index 08be115..0000000 --- a/suixinkan/Core/CameraTethering/Utilities/USBDeviceBrowserController.swift +++ /dev/null @@ -1,393 +0,0 @@ -import Foundation -import ImageCaptureCore - -/// 记录最近一次 USB 相机会话断开,用于再次进入页面时触发 Browser 热重启重新枚举。 -enum USBCameraReconnectHint { - private(set) static var lastDisconnectedAt: Date? - - /// 标记 USB 相机已断开,供下次 start 判断是否需热重启。 - static func markDisconnected() { - lastDisconnectedAt = Date() - } - - /// 近期是否刚断开(60s 内),需要主动 re-enumerate。 - static var shouldReEnumerate: Bool { - guard let lastDisconnectedAt else { return false } - return Date().timeIntervalSince(lastDisconnectedAt) < 60 - } -} - -/// 非 MainActor 的 USB 设备浏览器。 -/// ICDeviceBrowserDelegate 不应挂在 @MainActor 类型上,否则部分系统版本上 didAdd 可能永不触发。 -final class USBDeviceBrowserController: NSObject { - var onCameraDiscovered: ((ICCameraDevice, String) -> Void)? - var onCameraRemoved: ((ICDevice) -> Void)? - var onAuthorizationDenied: (() -> Void)? - var onScanTimeout: (() -> Void)? - - private let browser = ICDeviceBrowser() - private var pollTimer: Timer? - private var scanTimeoutTask: Task? - private var notifiedUUIDs: Set = [] - private var pollTick = 0 - private var isAuthorized = false - private var emptyPollStreak = 0 - private var isRestarting = false - private var isStopped = false - private var prefersCombinedBrowseMask = true - private var isReEnumerating = false - - /// Browser 是否仍在运行(未 stop)。 - var isSessionBrowsing: Bool { - browser.isBrowsing && !isStopped - } - - override init() { - super.init() - browser.delegate = self - configureBrowseMask() - } - - /// 请求授权并启动设备搜索;若 Browser 已在运行则直接扫描已连接设备。 - func start() async -> Bool { - isStopped = false - browser.delegate = self - - if browser.isBrowsing { - log("Browser 已在运行,扫描已连接 USB 相机…") - prepareForNewSession() - startScanTimeout() - let found = await waitForDiscovery(maxAttempts: 8, intervalNanoseconds: 300_000_000) - if !found, USBCameraReconnectHint.shouldReEnumerate { - await reEnumerateConnectedDevices(reason: "再次进入") - } - return true - } - - stopPolling() - scanTimeoutTask?.cancel() - pollTick = 0 - emptyPollStreak = 0 - notifiedUUIDs.removeAll() - - if !isAuthorized { - let granted = await requestAuthorizations(on: browser) - guard granted else { - onAuthorizationDenied?() - return false - } - isAuthorized = true - } - - await beginBrowsing(label: "首次启动") - startPolling() - startScanTimeout() - - let foundImmediately = await waitForDiscovery(maxAttempts: 4, intervalNanoseconds: 300_000_000) - if !foundImmediately { - await reEnumerateConnectedDevices(reason: "首次空结果") - } - return true - } - - /// 页面离开:保持 Browser 运行,仅重置发现状态供下次 attach。 - func pauseForSessionEnd() { - guard !isStopped else { return } - scanTimeoutTask?.cancel() - scanTimeoutTask = nil - notifiedUUIDs.removeAll() - emptyPollStreak = 0 - pollTick = 0 - isReEnumerating = false - isRestarting = false - log("会话结束,Browser 保持运行等待再次 attach") - } - - /// 立即扫描 browser.devices,用于 Browser 已运行时的再次进入。 - func discoverAlreadyConnectedDevices() { - guard !isStopped, browser.isBrowsing else { return } - pollDiscoveredDevices() - } - - private func prepareForNewSession() { - notifiedUUIDs.removeAll() - emptyPollStreak = 0 - pollTick = 0 - if pollTimer == nil { - startPolling() - } - } - - /// 在未发现设备时重启 Browser(热插拔 / 用户手动触发)。 - func restart() async { - guard !isRestarting, !isReEnumerating else { return } - isRestarting = true - defer { isRestarting = false } - - guard isAuthorized else { - _ = await start() - return - } - - log("手动/自动重启 Browser 搜索…") - if browser.isBrowsing { - browser.stop() - } - try? await Task.sleep(nanoseconds: 500_000_000) - notifiedUUIDs.removeAll() - pollTick = 0 - emptyPollStreak = 0 - scanTimeoutTask?.cancel() - toggleBrowseMask() - await beginBrowsing(label: "重启") - startScanTimeout() - _ = await waitForDiscovery(maxAttempts: 6, intervalNanoseconds: 300_000_000) - } - - /// 停止搜索。 - func stop() { - isStopped = true - stopPolling() - scanTimeoutTask?.cancel() - scanTimeoutTask = nil - pollTick = 0 - emptyPollStreak = 0 - if browser.isBrowsing { - browser.stop() - } - browser.delegate = nil - notifiedUUIDs.removeAll() - isAuthorized = false - isRestarting = false - } - - /// 测试用:当前是否已缓存 USB 授权结果。 - var isAuthorizationGrantedForTesting: Bool { isAuthorized } - /// 测试用:Browser 是否已停止。 - var isStoppedForTesting: Bool { isStopped } - - func resetNotifiedDevices() { - notifiedUUIDs.removeAll() - } - - private func configureBrowseMask() { - applyBrowseMask(combined: prefersCombinedBrowseMask) - } - - /// 切换 Camera|LocalUSB 与仅 Camera 掩码,提高 re-enumerate 成功率。 - private func toggleBrowseMask() { - prefersCombinedBrowseMask.toggle() - applyBrowseMask(combined: prefersCombinedBrowseMask) - } - - private func applyBrowseMask(combined: Bool) { - guard #available(iOS 15.2, *) else { return } - let mask: ICDeviceTypeMask - if combined { - mask = ICDeviceTypeMask( - rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue - ) ?? .camera - log("browsedDeviceTypeMask=0x\(String(mask.rawValue, radix: 16)) (Camera|LocalUSB)") - } else { - mask = .camera - log("browsedDeviceTypeMask=0x\(String(mask.rawValue, radix: 16)) (CameraOnly)") - } - browser.browsedDeviceTypeMask = mask - } - - /// 轮询等待 didAdd / devices 列表出现相机。 - private func waitForDiscovery(maxAttempts: Int, intervalNanoseconds: UInt64) async -> Bool { - for _ in 0 ..< maxAttempts { - guard !isStopped else { return !notifiedUUIDs.isEmpty } - try? await Task.sleep(nanoseconds: intervalNanoseconds) - pollDiscoveredDevices() - if !notifiedUUIDs.isEmpty { return true } - } - return !notifiedUUIDs.isEmpty - } - - /// 会话关闭后 iOS 常不触发 didAdd;stop → wait → start 强制重新枚举仍连接的 USB 相机。 - private func reEnumerateConnectedDevices(reason: String) async { - guard !isStopped, notifiedUUIDs.isEmpty, !isReEnumerating else { return } - isReEnumerating = true - defer { isReEnumerating = false } - - for attempt in 1 ... 2 { - guard !isStopped, notifiedUUIDs.isEmpty else { return } - log("\(reason):第 \(attempt) 次热重启 Browser 以重新枚举 USB 相机…") - - if browser.isBrowsing { - browser.stop() - } - try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000) - toggleBrowseMask() - notifiedUUIDs.removeAll() - pollTick = 0 - emptyPollStreak = 0 - - await beginBrowsing(label: "\(reason)-热重启\(attempt)") - if await waitForDiscovery(maxAttempts: 6, intervalNanoseconds: 300_000_000) { - log("\(reason):热重启第 \(attempt) 次成功发现相机") - return - } - } - log("\(reason):热重启后仍未发现 USB 相机") - } - - private func beginBrowsing(label: String) async { - log("\(label) ICDeviceBrowser.start() suspended=\(browser.isSuspended)") - browser.start() - log("isBrowsing=\(browser.isBrowsing) devices.count=\(browser.devices?.count ?? 0)") - } - - private func stopPolling() { - pollTimer?.invalidate() - pollTimer = nil - } - - private func startPolling() { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.stopPolling() - self.pollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.pollDiscoveredDevices() - } - } - } - - private func startScanTimeout() { - scanTimeoutTask?.cancel() - scanTimeoutTask = Task { [weak self] in - try? await Task.sleep(nanoseconds: 45_000_000_000) - guard !Task.isCancelled, let self else { return } - guard self.browser.isBrowsing else { return } - guard self.notifiedUUIDs.isEmpty else { return } - self.log("45s 内未发现 USB 相机") - self.onScanTimeout?() - } - } - - private func pollDiscoveredDevices() { - guard !isStopped else { return } - pollTick += 1 - - let devices = browser.devices ?? [] - if devices.isEmpty { - emptyPollStreak += 1 - } else { - emptyPollStreak = 0 - } - - if pollTick == 1 || pollTick % 10 == 0 { - log( - "轮询 tick=\(pollTick) devices.count=\(devices.count) " + - "isBrowsing=\(browser.isBrowsing) suspended=\(browser.isSuspended)" - ) - for device in devices { - logDeviceSummary(device, prefix: " devices[]") - } - } - - // 连续 6 次(约 3s)无设备时自动重启 Browser;不与 reEnumerate 并发。 - if emptyPollStreak > 0, emptyPollStreak % 6 == 0, notifiedUUIDs.isEmpty, !isRestarting, !isReEnumerating { - Task { await self.restart() } - } - - for device in devices { - handleDiscoveredDevice(device, source: "poll") - } - } - - private func handleDiscoveredDevice(_ device: ICDevice, source: String) { - guard !isStopped else { return } - guard let camera = device as? ICCameraDevice else { - log( - "[\(source)] 非 ICCameraDevice: \(device.name ?? "?") " + - "type=\(device.type) transport=\(device.transportType ?? "?")" - ) - return - } - - let uuid = camera.uuidString ?? "\(camera.usbVendorID)-\(camera.usbLocationID)" - guard notifiedUUIDs.insert(uuid).inserted else { return } - - let vendorID = Int(camera.usbVendorID) - log( - "[\(source)] 发现相机: \(camera.name ?? "?") vid=0x\(String(vendorID, radix: 16)) " + - "transport=\(camera.transportType ?? "?")" - ) - scanTimeoutTask?.cancel() - scanTimeoutTask = nil - onCameraDiscovered?(camera, source) - } - - private func requestAuthorizations(on browser: ICDeviceBrowser) async -> Bool { - log( - "contentsAuthorizationStatus=\(browser.contentsAuthorizationStatus), " + - "controlAuthorizationStatus=\(browser.controlAuthorizationStatus)" - ) - - let contentsGranted = await withCheckedContinuation { continuation in - browser.requestContentsAuthorization { status in - CameraTetheringLogger.log("AutoDetect 内容授权: \(String(describing: status))") - continuation.resume(returning: status == .authorized) - } - } - guard contentsGranted else { return false } - - let controlGranted = await withCheckedContinuation { continuation in - browser.requestControlAuthorization { status in - CameraTetheringLogger.log("AutoDetect 控制授权: \(String(describing: status))") - continuation.resume(returning: status == .authorized) - } - } - if !controlGranted { - log("控制授权未通过,仍尝试连接") - } - return true - } - - private func logDeviceSummary(_ device: ICDevice, prefix: String) { - if let camera = device as? ICCameraDevice { - log( - "\(prefix): \(camera.name ?? "?") vid=0x\(String(Int(camera.usbVendorID), radix: 16)) " + - "transport=\(camera.transportType ?? "?")" - ) - } else { - log("\(prefix): \(device.name ?? "?") type=\(device.type) transport=\(device.transportType ?? "?")") - } - } - - private func log(_ message: String) { - CameraTetheringLogger.log("AutoDetect \(message)") - } -} - -extension USBDeviceBrowserController: ICDeviceBrowserDelegate { - @objc(deviceBrowser:didAddDevice:moreComing:) - func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) { - guard !isStopped else { return } - log("didAdd 回调 name=\(device.name ?? "?") moreComing=\(moreComing)") - handleDiscoveredDevice(device, source: "didAdd") - } - - @objc(deviceBrowser:didRemoveDevice:moreGoing:) - func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) { - guard !isStopped else { return } - log("didRemove 回调 name=\(device.name ?? "?") moreGoing=\(moreGoing)") - if let uuid = device.uuidString { - notifiedUUIDs.remove(uuid) - } - onCameraRemoved?(device) - } - - func deviceBrowserDidSuspendOperations(_ browser: ICDeviceBrowser) { - log("Browser 操作已挂起(App 进入后台)") - } - - func deviceBrowserDidResumeOperations(_ browser: ICDeviceBrowser) { - guard !isStopped else { return } - log("Browser 操作已恢复(App 回到前台)") - Task { await restart() } - } -} diff --git a/suixinkan/Core/CameraTransfer/CameraTransfer.md b/suixinkan/Core/CameraTransfer/CameraTransfer.md deleted file mode 100644 index e1638a8..0000000 --- a/suixinkan/Core/CameraTransfer/CameraTransfer.md +++ /dev/null @@ -1,30 +0,0 @@ -# CameraTransfer - -## 职责 - -编排相机照片从下载到上传的通用流程,通过 `CameraAssetUploadSink` 与业务层解耦。 - -## 核心组件 - -| 组件 | 说明 | -| --- | --- | -| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 | -| `CameraTransferTask` | 单条传输任务及状态 | -| `CameraAssetUploadSink` | 上传协议,由业务模块实现 | -| `CameraDownloadStorage.Scope` | 账号 + 相册维度的本地文件隔离作用域 | - -## 业务流程 - -1. 相机服务回调 `onNewAsset`。 -2. Pipeline 下载文件到当前 scope 的 `originals` 目录并更新任务状态。 -3. 若照片是在开启自动上传时由 `onNewAsset` 新发现,加入自动上传资格队列并调用 `CameraAssetUploadSink.upload`。 -4. 上传成功后任务状态变为 `uploaded`。 - -`syncExistingPhotos()` 仅同步相机历史照片到本地任务列表,不授予自动上传资格。拍后传输模式下已下载的 `downloaded` 任务,切换到边拍边传后仍保持待上传,只有业务层显式调用 `uploadAssets(withIDs:)` 或 `retryUpload(...)` 时才会上传。 - -本地文件路径按 `Documents/CameraDownloads///originals/` 存放原图,缩略图由业务层生成到同 scope 的 `thumbnails/`。持久化只记录相对 Documents 的 scoped 路径,不保留未上线旧目录兼容。 - -## 与业务层关系 - -- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。 -- Pipeline 不依赖任何 HTTP 或 OSS 具体实现。 diff --git a/suixinkan/Core/CameraTethering/Models/CameraConnectionState.swift b/suixinkan/Features/TravelAlbum/Models/CameraConnectionState.swift similarity index 87% rename from suixinkan/Core/CameraTethering/Models/CameraConnectionState.swift rename to suixinkan/Features/TravelAlbum/Models/CameraConnectionState.swift index 6215be8..4649064 100644 --- a/suixinkan/Core/CameraTethering/Models/CameraConnectionState.swift +++ b/suixinkan/Features/TravelAlbum/Models/CameraConnectionState.swift @@ -1,5 +1,6 @@ import Foundation +/// 有线传图页展示的相机连接状态(UI 层使用,OTG 连接能力已移除)。 enum CameraConnectionState: Equatable { case disconnected case searching diff --git a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift b/suixinkan/Features/TravelAlbum/Models/CameraTransferModels.swift similarity index 90% rename from suixinkan/Core/CameraTransfer/CameraTransferTask.swift rename to suixinkan/Features/TravelAlbum/Models/CameraTransferModels.swift index 2d576bb..7f80352 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferTask.swift +++ b/suixinkan/Features/TravelAlbum/Models/CameraTransferModels.swift @@ -1,13 +1,11 @@ // -// CameraTransferTask.swift +// CameraTransferModels.swift // suixinkan // -// Created by Codex on 2026/6/29. -// import Foundation -/// 相机传输任务状态。 +/// 有线传图任务状态。 enum CameraTransferStatus: String, Codable, Equatable { case pending case downloading @@ -17,19 +15,20 @@ enum CameraTransferStatus: String, Codable, Equatable { 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`。 + /// 拍摄时间,格式 `yyyy-MM-dd HH:mm:ss`。 var capturedAt: String? var status: CameraTransferStatus var progress: Int var errorMessage: String? + /// 初始化传图任务。 init( id: UUID = UUID(), assetID: String, diff --git a/suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift b/suixinkan/Features/TravelAlbum/Services/CameraAssetUploadSink.swift similarity index 68% rename from suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift rename to suixinkan/Features/TravelAlbum/Services/CameraAssetUploadSink.swift index 5760208..e7ff4dd 100644 --- a/suixinkan/Core/CameraTransfer/CameraAssetUploadSink.swift +++ b/suixinkan/Features/TravelAlbum/Services/CameraAssetUploadSink.swift @@ -2,12 +2,10 @@ // CameraAssetUploadSink.swift // suixinkan // -// Created by Codex on 2026/6/29. -// import Foundation -/// 相机文件下载完成后的上传登记入口,业务模块各自实现(如旅拍相册 OSS + 后端登记)。 +/// 本地照片上传登记入口,由旅拍相册模块实现 OSS 上传与后端登记。 @MainActor protocol CameraAssetUploadSink: AnyObject { /// 上传本地文件并返回远端 URL。 diff --git a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift b/suixinkan/Features/TravelAlbum/Services/CameraDownloadStorage.swift similarity index 83% rename from suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift rename to suixinkan/Features/TravelAlbum/Services/CameraDownloadStorage.swift index 7aa8b64..61a68f6 100644 --- a/suixinkan/Core/CameraTethering/Storage/CameraDownloadStorage.swift +++ b/suixinkan/Features/TravelAlbum/Services/CameraDownloadStorage.swift @@ -2,18 +2,16 @@ // CameraDownloadStorage.swift // suixinkan // -// Created by Codex on 2026/6/29. -// import Foundation import ImageIO import UniformTypeIdentifiers -/// 相机下载文件本地存储工具,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。 +/// 有线传图本地文件存储,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。 enum CameraDownloadStorage { static let cameraDownloadsFolderName = "CameraDownloads" - /// 相机下载文件的业务隔离作用域。 + /// 有线传图文件的业务隔离作用域。 struct Scope: Equatable { let accountKey: String let albumID: Int @@ -38,21 +36,13 @@ enum CameraDownloadStorage { 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 var temporaryDownloadsDirectory: URL { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent(cameraDownloadsFolderName, isDirectory: true) - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - return directory - } - /// 当前账号与相册的下载根目录。 static func scopeDirectory(for scope: Scope) -> URL { let directory = downloadsDirectory @@ -76,7 +66,7 @@ enum CameraDownloadStorage { return directory } - /// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/user_101/6603/originals/1730_DSC.JPG`。 + /// 将绝对路径转为相对 Documents 的路径。 static func relativePath(for url: URL) -> String { let standardizedURL = url.standardizedFileURL let documentsPath = documentsDirectory.standardizedFileURL.path @@ -109,13 +99,6 @@ enum CameraDownloadStorage { return documentsDirectory.appendingPathComponent(storedPath) } - /// 判断文件是否位于当前账号与相册的原图目录内。 - static func isInOriginalsDirectory(_ url: URL, scope: Scope) -> Bool { - let originalsPath = originalsDirectory(for: scope).standardizedFileURL.path - let filePath = url.standardizedFileURL.path - return filePath.hasPrefix(originalsPath + "/") || filePath.hasPrefix("/private" + originalsPath + "/") - } - /// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。 static func uniqueLocalURL(for filename: String, scope: Scope) -> URL { let timestamp = Int(Date().timeIntervalSince1970) @@ -123,13 +106,6 @@ enum CameraDownloadStorage { return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)") } - /// 生成相机底层临时下载 URL。 - static func uniqueTemporaryURL(for filename: String) -> URL { - let timestamp = Int(Date().timeIntervalSince1970) - let safeName = sanitizeFilename(filename) - return temporaryDownloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)") - } - /// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。 static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL { let safeName = sanitizeFilename(assetID) @@ -145,7 +121,7 @@ enum CameraDownloadStorage { } } - /// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。 + /// 将文件名转为 iOS 文件系统可安全使用的 ASCII 名称。 static func sanitizeFilename(_ filename: String) -> String { let trimmed = filename .trimmingCharacters(in: .whitespacesAndNewlines) @@ -199,7 +175,7 @@ enum CameraDownloadStorage { } } -/// 相机本地照片缩略图生成器,负责为列表生成小尺寸预览图。 +/// 本地照片缩略图生成器,负责为列表生成小尺寸预览图。 enum CameraThumbnailGenerator { private static let maxPixelSize: CGFloat = 160 diff --git a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift b/suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift similarity index 52% rename from suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift rename to suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift index 82d7a96..ec00f09 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift @@ -1,19 +1,16 @@ // -// CameraTransferPipeline.swift +// WiredCameraTransferPipeline.swift // suixinkan // -// Created by Codex on 2026/6/29. -// import Foundation -/// 相机传输编排器,负责监听新照片、下载到本地并按需调用上传 Sink。 +/// 有线传图上传编排器,负责管理本地照片的上传任务(不含 USB/OTG 相机连接)。 @MainActor -final class CameraTransferPipeline { +final class WiredCameraTransferPipeline { var onTasksUpdated: (([CameraTransferTask]) -> Void)? private(set) var tasks: [CameraTransferTask] = [] - private let cameraService: any CameraServiceProtocol private var uploadSink: (any CameraAssetUploadSink)? private var uploadEnabled = true private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0) @@ -22,47 +19,30 @@ final class CameraTransferPipeline { private var pendingDeferredNotifyTask: Task? private var lastDeferredNotifyDate = Date.distantPast private var lastNotifiedProgressByAssetID: [String: Int] = [:] - private var inFlightAssetIDs: Set = [] private let maxRetries = 3 private let deferredNotifyInterval: TimeInterval = 0.15 private let maxConcurrentUploads = 3 private var isUIAttached = false - /// 用户已从列表删除、不应再同步或展示的 asset ID。 + /// 用户已从列表删除、不应再展示的 asset ID。 private(set) var excludedAssetIDs: Set = [] - /// 初始化传输管道,并注入相机服务。 - init(cameraService: any CameraServiceProtocol) { - self.cameraService = cameraService - } - - /// 绑定 UI 回调;再次进入 OTG 页时重新 attach。 - func attachToUI( - onConnectionStateChange: @escaping (CameraConnectionState) -> Void, - onTasksUpdated: @escaping ([CameraTransferTask]) -> Void - ) { + /// 绑定 UI 回调。 + func attachToUI(onTasksUpdated: @escaping ([CameraTransferTask]) -> Void) { isUIAttached = true self.onTasksUpdated = onTasksUpdated - cameraService.onConnectionStateChange = onConnectionStateChange - cameraService.onNewAsset = { [weak self] asset in - Task { @MainActor in - await self?.handleNewAsset(asset, skipIfExists: false) - } - } } - /// 页面离开:取消 UI 回调与进行中的上传通知,保持相机连接。 + /// 页面离开:取消 UI 回调与进行中的上传通知。 func detachFromUI() { guard isUIAttached else { return } isUIAttached = false pendingDeferredNotifyTask?.cancel() pendingDeferredNotifyTask = nil activeUploadAssetIDs.removeAll() - inFlightAssetIDs.removeAll() onTasksUpdated = nil - cameraService.detachFromUI() } - /// 配置上传 Sink、自动上传开关与可选下载作用域。 + /// 配置上传 Sink、自动上传开关与下载作用域。 func configure( uploadSink: (any CameraAssetUploadSink)?, uploadEnabled: Bool, @@ -75,40 +55,6 @@ final class CameraTransferPipeline { } } - /// 启动相机连接;已连接时同步已有照片。 - func connect() async { - await cameraService.connect() - if cameraService.connectionState.isConnected { - await syncExistingPhotos() - } - } - - /// 完整释放相机资源(App 终止或测试 teardown)。 - func shutdown() async { - detachFromUI() - await cameraService.shutdown() - } - - /// 断开相机连接(兼容旧调用,语义等同 shutdown)。 - func disconnect() async { - await shutdown() - } - - /// 重新搜索 USB 相机。 - func restartCameraDiscovery() async { - if let autoDetect = cameraService as? AutoDetectCameraService { - await autoDetect.restartDiscovery() - return - } - await cameraService.shutdown() - await cameraService.connect() - } - - /// 当前相机连接状态。 - var connectionState: CameraConnectionState { - cameraService.connectionState - } - /// 重试所有失败的上传任务。 func retryFailedUploads() async { guard uploadEnabled, uploadSink != nil else { return } @@ -121,7 +67,7 @@ final class CameraTransferPipeline { await uploadAssets(withIDs: failedAssetIDs) } - /// 手动上传指定 asset 列表(拍完再传模式)。 + /// 手动上传指定 asset 列表。 func uploadAssets(withIDs assetIDs: [String]) async { guard uploadSink != nil else { return } var seenAssetIDs: Set = [] @@ -142,25 +88,6 @@ final class CameraTransferPipeline { } } - 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) } - - if tasks[index].localURL == nil { - await downloadAsset(assetID: 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) - } - } - /// 重传单张照片;若管道中无任务则根据本地相对路径恢复。 func retryUpload(assetID: String, filename: String, localPath: String) async { guard uploadSink != nil else { return } @@ -195,162 +122,39 @@ final class CameraTransferPipeline { await uploadTask(assetID: assetID) } - /// 同步相机内已有照片到任务列表(不自动上传)。 - func syncExistingPhotos() async { - guard cameraService.connectionState.isConnected else { return } - do { - let assets = try await cameraService.listAssets() - for asset in assets { - await handleNewAsset(asset, skipIfExists: true) - } - } catch { - CameraTetheringLogger.log("syncExistingPhotos 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer) - } - } - - /// 清空所有传输记录及本地文件。 - func clearAllTransfers() { - for task in tasks { - guard let localURL = task.localURL, - FileManager.default.fileExists(atPath: localURL.path) - else { continue } - try? FileManager.default.removeItem(at: localURL) - } - tasks.removeAll() - autoUploadEligibleAssetIDs.removeAll() - notify() - } - /// 按 assetID 查找任务。 func task(forAssetID assetID: String) -> CameraTransferTask? { tasks.first { $0.assetID == assetID } } - /// 同步用户已删除的 asset ID,防止 sync/notify 再次带回。 + /// 同步用户已删除的 asset ID,防止再次带回。 func setExcludedAssetIDs(_ ids: Set) { excludedAssetIDs = ids } - /// 从管道移除 asset,并阻止后续自动同步。 + /// 从管道移除 asset。 func removeAsset(assetID: String) { guard !assetID.isEmpty else { return } excludedAssetIDs.insert(assetID) tasks.removeAll { $0.assetID == assetID } autoUploadEligibleAssetIDs.remove(assetID) activeUploadAssetIDs.remove(assetID) - inFlightAssetIDs.remove(assetID) lastNotifiedProgressByAssetID.removeValue(forKey: assetID) } - private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async { - if excludedAssetIDs.contains(asset.id) { return } - if skipIfExists, - let existing = tasks.first(where: { $0.assetID == asset.id }), - existing.status == .uploaded || existing.status == .downloaded { - return - } - if inFlightAssetIDs.contains(asset.id) { return } - if tasks.contains(where: { $0.assetID == asset.id && $0.status == .downloading }) { return } + 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 } - let isKnownAsset = tasks.contains { $0.assetID == asset.id } - let shouldAutoUpload = !skipIfExists - && uploadEnabled - && uploadSink != nil - && (!isKnownAsset || autoUploadEligibleAssetIDs.contains(asset.id)) - - inFlightAssetIDs.insert(asset.id) - defer { inFlightAssetIDs.remove(asset.id) } - - let assetID = asset.id - if shouldAutoUpload { - autoUploadEligibleAssetIDs.insert(assetID) - } - if let existingIndex = tasks.firstIndex(where: { $0.assetID == assetID }) { - tasks[existingIndex].status = .downloading - tasks[existingIndex].errorMessage = nil - } else { - let task = CameraTransferTask( - assetID: asset.id, - filename: asset.filename, - capturedAt: CameraTransferTask.capturedAtString(from: asset.creationDate), - status: .downloading - ) - tasks.insert(task, at: 0) - } - notify() - - await downloadAsset(assetID: assetID, asset: asset) - - if shouldAutoUpload, - tasks.first(where: { $0.assetID == assetID })?.status == .downloaded { - processUploadQueue() - } - } - - /// 下载指定 asset 到本地;始终按 assetID 定位任务,避免连拍时 index 失效。 - private func downloadAsset(assetID: String, asset: CameraAsset? = nil) async { - guard tasks.contains(where: { $0.assetID == assetID }) else { return } - - do { - let resolvedAsset: CameraAsset - if let asset { - resolvedAsset = asset - } else if let cached = try? await cameraService.listAssets().first(where: { $0.id == assetID }) { - resolvedAsset = cached - } else { - throw CameraServiceError.assetNotFound - } - - let localURL = try await cameraService.downloadAsset(resolvedAsset) - let destination: URL - - if CameraDownloadStorage.isInOriginalsDirectory(localURL, scope: downloadScope) { - destination = localURL - } else { - destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename, scope: downloadScope) - if FileManager.default.fileExists(atPath: destination.path) { - try FileManager.default.removeItem(at: destination) - } - try FileManager.default.moveItem(at: localURL, to: destination) - } - - guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } - tasks[index].localPath = CameraDownloadStorage.relativePath(for: destination) - if tasks[index].capturedAt?.isEmpty != false { - tasks[index].capturedAt = CameraTransferTask.capturedAtString(from: resolvedAsset.creationDate) - } - tasks[index].status = .downloaded - tasks[index].progress = 0 - notify() - } catch { - guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } - tasks[index].status = .failed - tasks[index].errorMessage = error.localizedDescription - notify() - } - } - - /// 并发上传边拍边传新照片;拍后传输留下的待上传任务只能由用户显式上传。 - private func processUploadQueue() { - guard uploadSink != nil else { return } - - while activeUploadAssetIDs.count < maxConcurrentUploads, - let task = tasks.first(where: { - $0.status == .downloaded - && !activeUploadAssetIDs.contains($0.assetID) - && autoUploadEligibleAssetIDs.contains($0.assetID) - }) { - startConcurrentUpload(assetID: task.assetID) - } - } - - private func startConcurrentUpload(assetID: String) { - guard !activeUploadAssetIDs.contains(assetID) else { return } activeUploadAssetIDs.insert(assetID) - Task { @MainActor in - await self.uploadTask(assetID: assetID) - self.activeUploadAssetIDs.remove(assetID) - self.processUploadQueue() + 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) } } @@ -409,7 +213,7 @@ final class CameraTransferPipeline { } } - private func notify(_ mode: CameraTransferNotifyMode = .immediate) { + private func notify(_ mode: WiredTransferNotifyMode = .immediate) { switch mode { case .immediate: pendingDeferredNotifyTask?.cancel() @@ -435,7 +239,7 @@ final class CameraTransferPipeline { } } -private enum CameraTransferNotifyMode { +private enum WiredTransferNotifyMode { case immediate case deferred } diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 8dc4618..46531e3 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -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///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 提示弹窗) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index e60854f..e3a83a5 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -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 } /// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。 diff --git a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift index e7a16c8..8dfa7a2 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift @@ -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 = ["全部", "已上传", "失败"] diff --git a/suixinkanTests/CameraTethering/CameraBrandDetectionTests.swift b/suixinkanTests/CameraTethering/CameraBrandDetectionTests.swift deleted file mode 100644 index c7da282..0000000 --- a/suixinkanTests/CameraTethering/CameraBrandDetectionTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// CameraBrandDetectionTests.swift -// suixinkanTests -// - -import ImageCaptureCore -import XCTest -@testable import suixinkan - -/// USB Vendor ID 品牌识别测试。 -final class CameraBrandDetectionTests: XCTestCase { - func testSonyVendorID() { - XCTAssertEqual(CameraUSBVendorID.sony, 0x054C) - } - - func testCanonVendorID() { - XCTAssertEqual(CameraUSBVendorID.canon, 0x04A9) - } -} diff --git a/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift b/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift deleted file mode 100644 index e7c984b..0000000 --- a/suixinkanTests/CameraTethering/CameraDownloadStorageTests.swift +++ /dev/null @@ -1,84 +0,0 @@ -// -// CameraDownloadStorageTests.swift -// suixinkanTests -// - -import XCTest -@testable import suixinkan - -/// CameraDownloadStorage 相对路径持久化测试。 -final class CameraDownloadStorageTests: XCTestCase { - /// 测试 scoped 原图相对路径前缀。 - func testRelativePathUsesScopedOriginalsPrefix() { - let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - - let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG") - XCTAssertEqual( - CameraDownloadStorage.relativePath(for: fileURL), - "CameraDownloads/user_101/6603/originals/1730_test.JPG" - ) - } - - /// 测试相对路径解析。 - func testResolveLocalURLFromRelativePath() { - let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG" - let resolved = CameraDownloadStorage.resolveLocalURL(from: stored) - let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored) - XCTAssertEqual(resolved, expected) - } - - /// 测试账号或相册不同不会生成相同目录。 - func testScopedDirectoriesDoNotCollideAcrossAccountOrAlbum() { - let accountScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) - let otherAccountScope = CameraDownloadStorage.Scope(accountKey: "user_102", albumID: 6603) - let otherAlbumScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604) - defer { - CameraDownloadStorage.removeScopeDirectory(for: accountScope) - CameraDownloadStorage.removeScopeDirectory(for: otherAccountScope) - CameraDownloadStorage.removeScopeDirectory(for: otherAlbumScope) - } - - XCTAssertNotEqual( - CameraDownloadStorage.originalsDirectory(for: accountScope).path, - CameraDownloadStorage.originalsDirectory(for: otherAccountScope).path - ) - XCTAssertNotEqual( - CameraDownloadStorage.originalsDirectory(for: accountScope).path, - CameraDownloadStorage.originalsDirectory(for: otherAlbumScope).path - ) - } - - /// 测试 scoped 目录清理只影响目标相册。 - func testRemoveScopeDirectoryOnlyDeletesTargetScope() throws { - let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) - let otherScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604) - defer { - CameraDownloadStorage.removeScopeDirectory(for: scope) - CameraDownloadStorage.removeScopeDirectory(for: otherScope) - } - - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "target.jpg", scope: scope) - let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "other.jpg", scope: otherScope) - try Data("target".utf8).write(to: fileURL) - try Data("other".utf8).write(to: otherFileURL) - - CameraDownloadStorage.removeScopeDirectory(for: scope) - - XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) - XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path)) - } - - /// 测试预览 URL 字符串生成。 - func testPreviewURLStringFromRelativePath() throws { - let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg", scope: scope) - try Data("jpeg".utf8).write(to: fileURL) - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - - let stored = CameraDownloadStorage.relativePath(for: fileURL) - let preview = CameraDownloadStorage.previewURLString(from: stored) - XCTAssertFalse(preview.isEmpty) - XCTAssertTrue(preview.hasPrefix("file://")) - } -} diff --git a/suixinkanTests/CameraTethering/CameraServiceFactoryTests.swift b/suixinkanTests/CameraTethering/CameraServiceFactoryTests.swift deleted file mode 100644 index 705d28a..0000000 --- a/suixinkanTests/CameraTethering/CameraServiceFactoryTests.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// CameraServiceFactoryTests.swift -// suixinkanTests -// - -import XCTest -@testable import suixinkan - -/// CameraServiceFactory 测试。 -@MainActor -final class CameraServiceFactoryTests: XCTestCase { - func testMakeSonyReturnsSonyService() { - let service = CameraServiceFactory.make(brand: .sony) - XCTAssertTrue(service is SonyCameraService) - XCTAssertEqual(service.brand, .sony) - } - - func testMakeCanonReturnsCanonService() { - let service = CameraServiceFactory.make(brand: .canon) - XCTAssertTrue(service is CanonCameraService) - XCTAssertEqual(service.brand, .canon) - } - - func testMakeAutoDetectReturnsAutoDetectService() { - let service = CameraServiceFactory.makeAutoDetect() - XCTAssertTrue(service is AutoDetectCameraService) - } - - func testMakeAutoDetectReturnsSharedInstance() { - let first = CameraServiceFactory.makeAutoDetect() - let second = CameraServiceFactory.makeAutoDetect() - XCTAssertTrue(first === second) - } -} diff --git a/suixinkanTests/CameraTethering/CameraTetheringSessionTests.swift b/suixinkanTests/CameraTethering/CameraTetheringSessionTests.swift deleted file mode 100644 index 39339aa..0000000 --- a/suixinkanTests/CameraTethering/CameraTetheringSessionTests.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// CameraTetheringSessionTests.swift -// suixinkanTests -// - -import XCTest -@testable import suixinkan - -/// CameraTetheringSession 与 detach/shutdown 语义测试。 -@MainActor -final class CameraTetheringSessionTests: XCTestCase { - func testSharedServiceIsAutoDetectInstance() { - XCTAssertTrue(CameraTetheringSession.sharedService is AutoDetectCameraService) - } - - func testDetachFromUIClearsCallbacksWithoutChangingConnectionState() { - let service = AutoDetectCameraService() - service.onConnectionStateChange = { _ in } - service.onNewAsset = { _ in } - service.detachFromUI() - XCTAssertNil(service.onConnectionStateChange) - XCTAssertNil(service.onNewAsset) - } -} diff --git a/suixinkanTests/CameraTethering/CanonPTPCommandsTests.swift b/suixinkanTests/CameraTethering/CanonPTPCommandsTests.swift deleted file mode 100644 index 40ad8e8..0000000 --- a/suixinkanTests/CameraTethering/CanonPTPCommandsTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// CanonPTPCommandsTests.swift -// suixinkanTests -// - -import XCTest -@testable import suixinkan - -/// Canon PTP 命令与事件解析测试。 -final class CanonPTPCommandsTests: XCTestCase { - /// 测试 SetRemoteMode 命令包 opcode。 - func testBuildSetRemoteModeCommand() { - let data = PTPHelper.buildCommand( - code: CanonPTPCommand.eosSetRemoteMode, - transactionID: 1, - parameters: [1] - ) - - XCTAssertEqual(data.count, 16) - XCTAssertEqual(data[6], 0x14) - XCTAssertEqual(data[7], 0x91) - } - - /// 测试 ObjectAddedEx (0xC181) 事件解析。 - func testParseObjectAddedExEvent() { - var blob = Data() - let recordSize: UInt32 = 8 + 40 - blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx.littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x1234).littleEndian) { Data($0) }) // handle - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) }) // storage - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) }) // ofc - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // flags - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // parent - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved - blob.append(contentsOf: withUnsafeBytes(of: UInt32(1024).littleEndian) { Data($0) }) // size - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved - let filename = "IMG_0001.JPG" - blob.append(filename.data(using: .ascii)!) - blob.append(0) - - let events = CanonEosEventParser.parse(blob) - XCTAssertEqual(events.count, 1) - guard case .objectAdded(let added) = events[0] else { - return XCTFail("expected objectAdded") - } - XCTAssertEqual(added.objectHandle, 0x1234) - XCTAssertEqual(added.filename, "IMG_0001.JPG") - XCTAssertEqual(added.size, 1024) - } - - /// 测试 ObjectAddedEx2 (0xC1A7) 大文件 size 解析。 - func testParseObjectAddedEx2Event() { - var blob = Data() - let recordSize: UInt32 = 8 + 48 - blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx2.littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x5678).littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(512).littleEndian) { Data($0) }) // sizeLow - blob.append(contentsOf: withUnsafeBytes(of: UInt32(1).littleEndian) { Data($0) }) // sizeHigh - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) - blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) - blob.append("LARGE.JPG".data(using: .ascii)!) - blob.append(0) - - let events = CanonEosEventParser.parse(blob) - XCTAssertEqual(events.count, 1) - guard case .objectAdded(let added) = events[0] else { - return XCTFail("expected objectAdded") - } - XCTAssertEqual(added.objectHandle, 0x5678) - XCTAssertEqual(added.size, 512 | (1 << 32)) - } - - /// 测试支持的文件名扩展名过滤。 - func testSupportedFilenameExtensions() { - XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.JPG")) - XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.cr2")) - XCTAssertFalse(CanonPTPHelper.isSupportedFilename("photo.tif")) - } -} diff --git a/suixinkanTests/CameraTethering/USBDeviceBrowserControllerTests.swift b/suixinkanTests/CameraTethering/USBDeviceBrowserControllerTests.swift deleted file mode 100644 index d1f6e83..0000000 --- a/suixinkanTests/CameraTethering/USBDeviceBrowserControllerTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// USBDeviceBrowserControllerTests.swift -// suixinkanTests -// - -import XCTest -@testable import suixinkan - -/// USB 设备浏览器 teardown 行为测试。 -final class USBDeviceBrowserControllerTests: XCTestCase { - /// 测试 stop 后重置授权缓存与 stopped 标记,便于再次进入页面时重新申请权限。 - func testStopResetsAuthorizationAndStoppedFlag() { - let controller = USBDeviceBrowserController() - controller.stop() - - XCTAssertTrue(controller.isStoppedForTesting) - XCTAssertFalse(controller.isAuthorizationGrantedForTesting) - } - - /// 测试断开标记可被再次进入时的 re-enumerate 逻辑读取。 - func testReconnectHintMarksAndExpires() { - USBCameraReconnectHint.markDisconnected() - XCTAssertTrue(USBCameraReconnectHint.shouldReEnumerate) - } -} diff --git a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift deleted file mode 100644 index 3a46b29..0000000 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ /dev/null @@ -1,374 +0,0 @@ -// -// CameraTransferPipelineTests.swift -// suixinkanTests -// -// Created by Codex on 2026/6/29. -// - -import XCTest -@testable import suixinkan - -@MainActor -/// 相机传输管道测试,使用 Mock 相机与上传 Sink。 -final class CameraTransferPipelineTests: XCTestCase { - /// 测试新照片下载后触发上传 Sink,并持久化相对路径。 - func testNewAssetTriggersUploadSink() async { - let camera = MockCameraService() - let sink = MockUploadSink() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("new_asset") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) - - var latestTasks: [CameraTransferTask] = [] - attachPipeline(pipeline, onTasksUpdated: { latestTasks = $0 }) - - let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024) - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG") - try? Data(repeating: 0xFF, count: 8).write(to: tempURL) - - camera.onNewAsset?(asset) - try? await Task.sleep(nanoseconds: 300_000_000) - - XCTAssertEqual(sink.uploadCount, 1) - let savedTask = latestTasks.first { $0.assetID == asset.id } - XCTAssertEqual( - savedTask?.localPath?.hasPrefix("CameraDownloads/test_account/\(scope.albumID)/originals/"), - true - ) - XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false) - if let localURL = savedTask?.localURL { - try? FileManager.default.removeItem(at: localURL) - } - try? FileManager.default.removeItem(at: tempURL) - } - - /// 测试重传可从相对路径恢复管道任务并上传。 - func testRetryUploadRehydratesFromRelativePath() async throws { - let camera = MockCameraService() - let sink = MockUploadSink() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("retry") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope) - - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG", scope: scope) - try Data(repeating: 0xAB, count: 16).write(to: fileURL) - defer { try? FileManager.default.removeItem(at: fileURL) } - - let relativePath = CameraDownloadStorage.relativePath(for: fileURL) - await pipeline.retryUpload( - assetID: "asset_retry_001", - filename: "retry_pipeline.JPG", - localPath: relativePath - ) - - XCTAssertEqual(sink.uploadCount, 1) - XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded) - } - - /// 测试手动上传多张照片时按并发上限上传,而不是串行一张张上传。 - func testManualUploadAssetsUsesConcurrentLimit() async throws { - let camera = MockCameraService() - let sink = MockSlowUploadSink() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("manual") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) - camera.listedAssets = (1 ... 6).map { index in - CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024) - } - - await pipeline.syncExistingPhotos() - await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id)) - - XCTAssertEqual(sink.uploadCount, 6) - XCTAssertGreaterThan(sink.maxActiveUploads, 1) - XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3) - } - - /// 测试拍后传输下载的旧照片,切到边拍边传后不会被新照片触发自动上传。 - func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws { - let camera = MockCameraService() - let sink = MockUploadSink() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("mode_switch") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope) - attachPipeline(pipeline) - - camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024)) - try await Task.sleep(nanoseconds: 400_000_000) - - XCTAssertEqual(sink.uploadedFileNames, []) - XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded) - - pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) - camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024)) - try await Task.sleep(nanoseconds: 500_000_000) - - XCTAssertEqual(sink.uploadedFileNames, ["LIVE_001.JPG"]) - XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded) - XCTAssertEqual(pipeline.task(forAssetID: "live_001")?.status, .uploaded) - } - - /// 测试边拍边传模式下刷新历史照片只同步到本地,不授予自动上传资格。 - func testSyncExistingPhotosDoesNotAutoUploadInLiveCaptureMode() async throws { - let camera = MockCameraService() - let sink = MockUploadSink() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("sync_existing") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) - camera.listedAssets = [ - CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024) - ] - - await pipeline.syncExistingPhotos() - try await Task.sleep(nanoseconds: 200_000_000) - - XCTAssertEqual(sink.uploadedFileNames, []) - XCTAssertEqual(pipeline.task(forAssetID: "history_001")?.status, .downloaded) - } - - /// 测试边拍边传连拍时,多张照片会按并发上限同时上传。 - func testBurstCaptureUploadsAllAssetsWhileFirstUploadInProgress() async throws { - let camera = MockBurstCameraService() - let sink = MockSlowUploadSink() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("burst") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) - attachPipeline(pipeline) - - for index in 1 ... 10 { - let filename = "DSC_\(index).JPG" - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - try Data(repeating: UInt8(index), count: 8).write(to: tempURL) - camera.downloadURLs[filename] = tempURL - camera.onNewAsset?( - CameraAsset(id: "ptp_\(index)", filename: filename, fileSize: 1024) - ) - } - - try await Task.sleep(nanoseconds: 2_000_000_000) - - XCTAssertEqual(sink.uploadCount, 10) - XCTAssertGreaterThan(sink.maxActiveUploads, 1) - XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3) - for index in 1 ... 10 { - XCTAssertEqual(pipeline.task(forAssetID: "ptp_\(index)")?.status, .uploaded) - } - } - - /// 测试高频上传进度会被合并通知,避免连拍时频繁刷新 SwiftUI 列表。 - func testUploadProgressNotificationsAreThrottled() async throws { - let camera = MockCameraService() - let sink = MockChattyUploadSink() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("chatty") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) - - var updateCount = 0 - attachPipeline(pipeline, onTasksUpdated: { _ in updateCount += 1 }) - - camera.onNewAsset?(CameraAsset(id: "ptp_chatty", filename: "chatty.JPG", fileSize: 1024)) - try await Task.sleep(nanoseconds: 800_000_000) - - XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded) - XCTAssertLessThan(updateCount, 18) - } - - /// 测试 shutdown 会调用相机服务完整释放。 - func testShutdownCallsCameraService() async { - let camera = DisconnectTrackingMockCameraService() - let pipeline = CameraTransferPipeline(cameraService: camera) - - await pipeline.shutdown() - - XCTAssertEqual(camera.shutdownCount, 1) - } - - /// 测试 detachFromUI 仅清回调,不 shutdown 相机。 - func testDetachFromUIDoesNotShutdownCameraService() async { - let camera = DisconnectTrackingMockCameraService() - let pipeline = CameraTransferPipeline(cameraService: camera) - attachPipeline(pipeline) - - pipeline.detachFromUI() - - XCTAssertEqual(camera.detachCount, 1) - XCTAssertEqual(camera.shutdownCount, 0) - } - - /// 测试删除后管道不会在新片 notify 时再次带回已删 asset。 - func testRemovedAssetStaysExcludedWhenNewAssetArrives() async throws { - let camera = MockCameraService() - let pipeline = CameraTransferPipeline(cameraService: camera) - let scope = makeScope("removed_asset") - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - pipeline.configure(uploadSink: nil, uploadEnabled: false, downloadScope: scope) - attachPipeline(pipeline) - - let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD.JPG") - let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW.JPG") - try Data(repeating: 0x01, count: 8).write(to: oldURL) - try Data(repeating: 0x02, count: 8).write(to: newURL) - defer { - try? FileManager.default.removeItem(at: oldURL) - try? FileManager.default.removeItem(at: newURL) - } - - camera.onNewAsset?(CameraAsset(id: "ptp_old", filename: "OLD.JPG", fileSize: 8)) - try await Task.sleep(nanoseconds: 300_000_000) - pipeline.removeAsset(assetID: "ptp_old") - XCTAssertNil(pipeline.task(forAssetID: "ptp_old")) - - camera.onNewAsset?(CameraAsset(id: "ptp_new", filename: "NEW.JPG", fileSize: 8)) - try await Task.sleep(nanoseconds: 300_000_000) - - XCTAssertNil(pipeline.task(forAssetID: "ptp_old")) - XCTAssertNotNil(pipeline.task(forAssetID: "ptp_new")) - XCTAssertTrue(pipeline.excludedAssetIDs.contains("ptp_old")) - } - - private func attachPipeline( - _ pipeline: CameraTransferPipeline, - onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in } - ) { - pipeline.attachToUI(onConnectionStateChange: { _ in }, onTasksUpdated: onTasksUpdated) - } - - private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope { - let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in - (partial + Int(scalar.value)) % 10_000 - } - return CameraDownloadStorage.Scope(accountKey: "test_account", albumID: albumID) - } -} - -@MainActor -private final class DisconnectTrackingMockCameraService: CameraServiceProtocol { - let brand: CameraBrand = .sony - var connectionState: CameraConnectionState = .disconnected - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - private(set) var detachCount = 0 - private(set) var shutdownCount = 0 - - func connect() async {} - func detachFromUI() { detachCount += 1 } - func shutdown() async { shutdownCount += 1 } - func listAssets() async throws -> [CameraAsset] { [] } - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) - } -} - -@MainActor -private final class MockCameraService: CameraServiceProtocol { - let brand: CameraBrand = .sony - var connectionState: CameraConnectionState = .connected(deviceName: "Mock") - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - var listedAssets: [CameraAsset] = [] - - func connect() async {} - func detachFromUI() {} - func shutdown() async {} - - func listAssets() async throws -> [CameraAsset] { - listedAssets - } - - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) - if !FileManager.default.fileExists(atPath: url.path) { - try Data(repeating: 0xFF, count: 8).write(to: url) - } - return url - } -} - -@MainActor -private final class MockBurstCameraService: CameraServiceProtocol { - let brand: CameraBrand = .sony - var connectionState: CameraConnectionState = .connected(deviceName: "Mock") - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - var downloadURLs: [String: URL] = [:] - - func connect() async {} - func detachFromUI() {} - func shutdown() async {} - - func listAssets() async throws -> [CameraAsset] { [] } - - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - try await Task.sleep(nanoseconds: 30_000_000) - if let url = downloadURLs[asset.filename] { - return url - } - let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) - if !FileManager.default.fileExists(atPath: url.path) { - try Data(repeating: 0xFF, count: 8).write(to: url) - } - return url - } -} - -@MainActor -private final class MockSlowUploadSink: CameraAssetUploadSink { - var uploadCount = 0 - var activeUploads = 0 - var maxActiveUploads = 0 - - func upload( - localURL: URL, - fileName: String, - fileType: Int, - progress: @escaping @Sendable (Int) -> Void - ) async throws -> String { - uploadCount += 1 - activeUploads += 1 - maxActiveUploads = max(maxActiveUploads, activeUploads) - try await Task.sleep(nanoseconds: 150_000_000) - activeUploads -= 1 - progress(100) - return "https://cdn/mock/\(fileName)" - } -} - -@MainActor -private final class MockUploadSink: CameraAssetUploadSink { - var uploadCount = 0 - var uploadedFileNames: [String] = [] - - func upload( - localURL: URL, - fileName: String, - fileType: Int, - progress: @escaping @Sendable (Int) -> Void - ) async throws -> String { - uploadCount += 1 - uploadedFileNames.append(fileName) - progress(100) - return "https://cdn/mock/\(fileName)" - } -} - -@MainActor -private final class MockChattyUploadSink: CameraAssetUploadSink { - func upload( - localURL: URL, - fileName: String, - fileType: Int, - progress: @escaping @Sendable (Int) -> Void - ) async throws -> String { - for value in 0 ... 100 { - progress(value) - } - return "https://cdn/mock/\(fileName)" - } -} diff --git a/suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift b/suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift deleted file mode 100644 index 5ae6327..0000000 --- a/suixinkanTests/CameraTransfer/SonyPTPCommandsTests.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// SonyPTPCommandsTests.swift -// suixinkanTests -// -// Created by Codex on 2026/6/29. -// - -import XCTest -@testable import suixinkan - -/// Sony PTP 命令构建与解析测试。 -final class SonyPTPCommandsTests: XCTestCase { - /// 测试 OpenSession 命令包长度与 opcode。 - func testBuildOpenSessionCommand() { - let data = SonyPTPHelper.buildCommand( - code: SonyPTPCommand.openSession, - transactionID: 1, - parameters: [1] - ) - - XCTAssertEqual(data.count, 16) - XCTAssertEqual(data[6], 0x02) - XCTAssertEqual(data[7], 0x10) - } - - /// 测试 SDIO Connect 命令 opcode。 - func testBuildSonyConnectCommand() { - let data = SonyPTPHelper.buildCommand( - code: SonyPTPCommand.sdioConnect, - transactionID: 42, - parameters: [1, 0, 0] - ) - - XCTAssertEqual(data.count, 24) - XCTAssertEqual(data[6], 0x01) - XCTAssertEqual(data[7], 0x92) - } - - /// 测试 GetObject 压缩大小解析。 - func testParseObjectCompressedSize() { - var data = Data(repeating: 0, count: 52) - data[8] = 0x00 - data[9] = 0x10 - data[10] = 0x00 - data[11] = 0x00 - - XCTAssertEqual(SonyPTPHelper.parseObjectCompressedSize(data), 0x1000) - } - - /// 测试 SDIE ObjectAdded 事件解析。 - func testParseObjectAddedEvent() throws { - var event = Data() - event.append(contentsOf: [0x10, 0x00, 0x00, 0x00]) - event.append(contentsOf: [0x04, 0x00]) - event.append(contentsOf: [0x01, 0xC2]) - event.append(contentsOf: [0xFF, 0xFF, 0xFF, 0xFF]) - event.append(contentsOf: [0x01, 0xC0, 0xFF, 0xFF]) - - let container = try SonyPTPHelper.parseContainer(event) - XCTAssertEqual(container.code, SonyPTPCommand.sdieObjectAdded) - XCTAssertEqual(container.params.first, SonyPTPCommand.shotObjectHandle) - } -} diff --git a/suixinkanTests/TravelAlbum/CameraDownloadStorageTests.swift b/suixinkanTests/TravelAlbum/CameraDownloadStorageTests.swift new file mode 100644 index 0000000..fb7f181 --- /dev/null +++ b/suixinkanTests/TravelAlbum/CameraDownloadStorageTests.swift @@ -0,0 +1,76 @@ +// +// CameraDownloadStorageTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// CameraDownloadStorage 相对路径持久化测试。 +final class CameraDownloadStorageTests: XCTestCase { + /// 测试 scoped 原图相对路径前缀。 + func testRelativePathUsesScopedOriginalsPrefix() { + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + + let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG") + XCTAssertEqual( + CameraDownloadStorage.relativePath(for: fileURL), + "CameraDownloads/user_101/6603/originals/1730_test.JPG" + ) + } + + /// 测试相对路径解析。 + func testResolveLocalURLFromRelativePath() { + let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG" + let resolved = CameraDownloadStorage.resolveLocalURL(from: stored) + let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored) + XCTAssertEqual(resolved, expected) + } + + /// 测试 scoped 目录路径。 + func testScopeDirectoryPath() { + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + + let directory = CameraDownloadStorage.scopeDirectory(for: scope) + XCTAssertTrue(directory.path.hasSuffix("CameraDownloads/user_101/6603")) + } + + /// 测试唯一原图 URL 生成。 + func testUniqueLocalURLUsesScopedOriginalsDirectory() { + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + + let url = CameraDownloadStorage.uniqueLocalURL(for: "DSC.JPG", scope: scope) + XCTAssertTrue(url.path.contains("/CameraDownloads/user_101/6603/originals/")) + XCTAssertTrue(url.lastPathComponent.hasSuffix("_DSC.JPG")) + } + + /// 测试删除 scoped 目录。 + func testRemoveScopeDirectory() throws { + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "remove_test.JPG", scope: scope) + try Data(repeating: 0xFF, count: 8).write(to: fileURL) + + let directory = CameraDownloadStorage.scopeDirectory(for: scope) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path)) + + CameraDownloadStorage.removeScopeDirectory(for: scope) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path)) + } + + /// 测试预览 URL 字符串。 + func testPreviewURLStringFromExistingFile() throws { + let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603) + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.JPG", scope: scope) + try Data(repeating: 0xFF, count: 8).write(to: fileURL) + + let stored = CameraDownloadStorage.relativePath(for: fileURL) + let preview = CameraDownloadStorage.previewURLString(from: stored) + XCTAssertFalse(preview.isEmpty) + XCTAssertTrue(preview.hasPrefix("file://")) + } +} diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 976afdb..4160aff 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -12,102 +12,15 @@ import UIKit @MainActor /// 有线传图 ViewModel 测试。 final class WiredCameraTransferViewModelTests: XCTestCase { - /// 测试相机管道回调的首张新照片应出现在列表中。 - func testFirstNewPipelineTaskAppearsInPhotos() async throws { - let camera = MockWiredCameraService() + /// 测试 start 被取消时会 detach UI 回调。 + func testStartCancellationDetachesFromUI() async { let context = WiredTransferContext( albumId: 6603, albumName: "测试相册", phone: "", orderNumber: "" ) - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) - let account = AccountContext() - account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) - - await viewModel.start( - api: MockTravelAlbumAPIForWiredTransfer(), - ossService: WiredTransferMockOSSUploadService(), - scenicID: 1, - accountContext: account - ) - - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_NEW.JPG") - try Data(repeating: 0xFF, count: 16).write(to: tempURL) - defer { try? FileManager.default.removeItem(at: tempURL) } - - let asset = CameraAsset(id: "ptp_new_001", filename: "DSC_NEW.JPG", fileSize: 16) - camera.downloadURL = tempURL - camera.onNewAsset?(asset) - try await Task.sleep(nanoseconds: 500_000_000) - - XCTAssertEqual(viewModel.photos.count, 1) - XCTAssertEqual(viewModel.photos.first?.id, "ptp_new_001") - XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG") - } - - /// 测试删除待上传照片后,边拍边传新片不会把已删照片带回列表。 - func testDeletedPendingPhotoDoesNotReappearAfterLiveCapture() async throws { - let camera = MockWiredCameraService() - let context = WiredTransferContext( - albumId: 7701, - albumName: "删除回归测试", - phone: "", - orderNumber: "" - ) - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) - let account = AccountContext() - account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) - - await viewModel.start( - api: MockTravelAlbumAPIForWiredTransfer(), - ossService: WiredTransferMockOSSUploadService(), - scenicID: 1, - accountContext: account - ) - - let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD_DELETE.JPG") - try Data(repeating: 0x11, count: 16).write(to: oldURL) - defer { try? FileManager.default.removeItem(at: oldURL) } - - camera.downloadURL = oldURL - camera.onNewAsset?(CameraAsset(id: "ptp_delete_me", filename: "OLD_DELETE.JPG", fileSize: 16)) - try await Task.sleep(nanoseconds: 500_000_000) - XCTAssertEqual(viewModel.photos.count, 1) - - viewModel.deletePhoto(id: "ptp_delete_me") - XCTAssertTrue(viewModel.photos.isEmpty) - - viewModel.selectTransferMode( - WiredCameraTransferViewModel.modeLiveCapture, - api: MockTravelAlbumAPIForWiredTransfer(), - ossService: WiredTransferMockOSSUploadService(), - scenicID: 1 - ) - - let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW_LIVE.JPG") - try Data(repeating: 0x22, count: 16).write(to: newURL) - defer { try? FileManager.default.removeItem(at: newURL) } - - camera.downloadURL = newURL - camera.onNewAsset?(CameraAsset(id: "ptp_live_new", filename: "NEW_LIVE.JPG", fileSize: 16)) - try await Task.sleep(nanoseconds: 500_000_000) - - XCTAssertEqual(viewModel.photos.count, 1) - XCTAssertEqual(viewModel.photos.first?.id, "ptp_live_new") - XCTAssertFalse(viewModel.photos.contains { $0.id == "ptp_delete_me" }) - } - - /// 测试 start 被取消时 detach UI 回调,但不 shutdown 相机连接。 - func testStartCancellationDetachesWithoutShutdown() async { - let camera = DisconnectTrackingWiredCameraService() - let context = WiredTransferContext( - albumId: 6603, - albumName: "测试相册", - phone: "", - orderNumber: "" - ) - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) + let viewModel = WiredCameraTransferViewModel(context: context) let account = AccountContext() account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) @@ -120,54 +33,11 @@ final class WiredCameraTransferViewModelTests: XCTestCase { ) } - for _ in 0 ..< 100 { - if camera.connectCount > 0 { break } - try? await Task.sleep(nanoseconds: 10_000_000) - } - + try? await Task.sleep(nanoseconds: 100_000_000) startTask.cancel() - try? await Task.sleep(nanoseconds: 300_000_000) + try? await Task.sleep(nanoseconds: 200_000_000) - XCTAssertGreaterThanOrEqual(camera.detachCount, 1) - XCTAssertEqual(camera.shutdownCount, 0) - } - - /// 测试绑定到其他相册的照片不会出现在当前相册列表。 - func testPhotoBoundToOtherAlbumIsFilteredOut() async throws { - let bindingStore = WiredTransferPhotoStore( - accountPrefixProvider: { "user_101" }, - userIDProvider: { "101" } - ) - bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 99) - defer { bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 1) } - - let camera = MockWiredCameraService() - let context = WiredTransferContext( - albumId: 1, - albumName: "当前相册", - phone: "", - orderNumber: "" - ) - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) - let account = AccountContext() - account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"), accountType: "user") - - await viewModel.start( - api: MockTravelAlbumAPIForWiredTransfer(), - ossService: WiredTransferMockOSSUploadService(), - scenicID: 1, - accountContext: account - ) - - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_OTHER.JPG") - try Data(repeating: 0xAA, count: 8).write(to: tempURL) - defer { try? FileManager.default.removeItem(at: tempURL) } - - camera.downloadURL = tempURL - camera.onNewAsset?(CameraAsset(id: "ptp_other_album", filename: "DSC_OTHER.JPG", fileSize: 8)) - try await Task.sleep(nanoseconds: 500_000_000) - - XCTAssertTrue(viewModel.photos.isEmpty) + XCTAssertEqual(viewModel.connectionState, .disconnected) } /// 测试批量上传按钮三态流转。 @@ -209,8 +79,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { let today = formatter.string(from: Date()) let viewModel = WiredCameraTransferViewModel( - context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""), - cameraService: MockWiredCameraService() + context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "") ) viewModel.replacePhotos([ makePendingPhoto(id: "today", capturedAt: today), @@ -226,111 +95,8 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertNil(viewModel.errorMessage) } - /// 测试上传进度更新时保留 capturedAt,避免列表分组抖动。 - func testPipelineProgressUpdatePreservesCapturedAt() async throws { - let camera = MockWiredCameraService() - let context = WiredTransferContext( - albumId: 1, - albumName: "测试相册", - phone: "", - orderNumber: "" - ) - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) - let account = AccountContext() - account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) - - await viewModel.start( - api: MockTravelAlbumAPIForWiredTransfer(), - ossService: WiredTransferMockOSSUploadService(), - scenicID: 1, - accountContext: account - ) - let originalCapturedAt = "2026-05-20 12:05:18" - viewModel.replacePhotos([ - WiredTransferPhotoItem( - id: "ptp_progress", - fileName: "DSC_PROGRESS.JPG", - thumbnailURL: "", - previewURL: "", - capturedAt: originalCapturedAt, - fileSizeText: "2 MB", - status: .uploading, - progress: 10 - ), - ]) - - let scope = downloadScope(account: account, albumID: context.albumId) - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG", scope: scope) - try Data(repeating: 0xAB, count: 16).write(to: fileURL) - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - - camera.downloadURL = fileURL - camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16)) - try await Task.sleep(nanoseconds: 300_000_000) - - XCTAssertEqual(viewModel.photos.first?.capturedAt, originalCapturedAt) - } - - /// 测试较新拍摄时间的照片排在列表前面。 - func testNewerCapturedPhotoAppearsFirst() async throws { - let camera = MockWiredCameraService() - let context = WiredTransferContext( - albumId: 6604, - albumName: "测试相册", - phone: "", - orderNumber: "" - ) - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) - let account = AccountContext() - account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) - - await viewModel.start( - api: MockTravelAlbumAPIForWiredTransfer(), - ossService: WiredTransferMockOSSUploadService(), - scenicID: 1, - accountContext: account - ) - - let fileURL = FileManager.default.temporaryDirectory - let olderURL = fileURL.appendingPathComponent("OLDER.JPG") - let newerURL = fileURL.appendingPathComponent("NEWER.JPG") - try Data(repeating: 0xAA, count: 8).write(to: olderURL) - try Data(repeating: 0xBB, count: 8).write(to: newerURL) - defer { - try? FileManager.default.removeItem(at: olderURL) - try? FileManager.default.removeItem(at: newerURL) - } - - camera.downloadURL = olderURL - camera.onNewAsset?( - CameraAsset( - id: "ptp_old", - filename: "OLDER.JPG", - fileSize: 8, - creationDate: Date(timeIntervalSince1970: 1_700_000_000) - ) - ) - try await Task.sleep(nanoseconds: 400_000_000) - - camera.downloadURL = newerURL - camera.onNewAsset?( - CameraAsset( - id: "ptp_new", - filename: "NEWER.JPG", - fileSize: 8, - creationDate: Date(timeIntervalSince1970: 1_800_000_000) - ) - ) - try await Task.sleep(nanoseconds: 400_000_000) - - XCTAssertEqual(viewModel.photos.count, 2) - XCTAssertEqual(viewModel.photos[0].id, "ptp_new") - XCTAssertEqual(viewModel.photos[1].id, "ptp_old") - } - /// 测试拍后传输模式下重传会从持久化记录恢复上传。 func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws { - let camera = MockWiredCameraService() let context = WiredTransferContext( albumId: 1, albumName: "测试相册", @@ -368,7 +134,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { ) store.save(albumID: 1, records: [record]) - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) + let viewModel = WiredCameraTransferViewModel(context: context) viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer let api = MockTravelAlbumAPIForWiredTransfer() @@ -418,8 +184,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { store.save(albumID: albumID, records: records) let viewModel = WiredCameraTransferViewModel( - context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: ""), - cameraService: MockWiredCameraService() + context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: "") ) let oss = WiredTransferMockOSSUploadService() oss.uploadDelayNanoseconds = 150_000_000 @@ -447,32 +212,36 @@ final class WiredCameraTransferViewModelTests: XCTestCase { /// 测试切换传输模式后会按账号缓存选项。 func testSelectTransferModePersistsOption() async { let account = makeTransferModeTestAccount() - let context = WiredTransferContext(albumId: 7701, albumName: "模式缓存", phone: "", orderNumber: "") - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService()) - let api = MockTravelAlbumAPIForWiredTransfer() - let oss = WiredTransferMockOSSUploadService() + let store = makeTransferModeTestStore(account: account) + let viewModel = WiredCameraTransferViewModel( + context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "") + ) + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) - await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account) viewModel.selectTransferMode( WiredCameraTransferViewModel.modePostShootTransfer, - api: api, - ossService: oss, + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), scenicID: 1 ) - let store = makeTransferModeTestStore(account: account) XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer) } - /// 测试再次进入有线传图页时会恢复上次选择的传输模式。 + /// 测试进入页面时会恢复缓存的传输模式。 func testStartRestoresCachedTransferModeOption() async { let account = makeTransferModeTestAccount() let store = makeTransferModeTestStore(account: account) store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer) - let context = WiredTransferContext(albumId: 7702, albumName: "模式恢复", phone: "", orderNumber: "") - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService()) - + let viewModel = WiredCameraTransferViewModel( + context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "") + ) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), ossService: WiredTransferMockOSSUploadService(), @@ -483,37 +252,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer) } - /// 测试下载完成后生成本地缩略图,并优先用于列表展示。 - func testDownloadedPhotoGeneratesThumbnailForList() async throws { - let camera = MockWiredCameraService() - let albumID = 6601 - let context = WiredTransferContext(albumId: albumID, albumName: "缩略图相册", phone: "", orderNumber: "") - let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera) - let account = AccountContext() - account.applyLogin(profile: AccountProfile(userId: "thumb-user", displayName: "测试")) - - await viewModel.start( - api: MockTravelAlbumAPIForWiredTransfer(), - ossService: WiredTransferMockOSSUploadService(), - scenicID: 1, - accountContext: account - ) - - let scope = downloadScope(account: account, albumID: albumID) - let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG", scope: scope) - try makeJPEGData().write(to: imageURL) - defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } - - camera.downloadURL = imageURL - camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128)) - try await Task.sleep(nanoseconds: 900_000_000) - - let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" }) - XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/\(scope.accountDirectoryName)/\(albumID)/thumbnails/")) - XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/")) - XCTAssertFalse(photo.previewURL.contains("/thumbnails/")) - } - /// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。 func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws { let albumID = 6602 @@ -547,8 +285,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { store.save(albumID: albumID, records: [record]) let viewModel = WiredCameraTransferViewModel( - context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: ""), - cameraService: MockWiredCameraService() + context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: "") ) await viewModel.start( api: MockTravelAlbumAPIForWiredTransfer(), @@ -578,10 +315,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase { CameraDownloadStorage.removeScopeDirectory(for: otherScope) } - let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_target.JPG", scope: scope) - let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_other.JPG", scope: otherScope) - try Data(repeating: 0x01, count: 8).write(to: fileURL) - try Data(repeating: 0x02, count: 8).write(to: otherFileURL) + let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: scope) + let otherURL = CameraDownloadStorage.uniqueLocalURL(for: "other_photo.JPG", scope: otherScope) + try makeJPEGData().write(to: storeURL) + try makeJPEGData().write(to: otherURL) let store = WiredTransferPhotoStore( accountPrefixProvider: { account.accountCachePrefix ?? "guest_" }, @@ -589,40 +326,29 @@ final class WiredCameraTransferViewModelTests: XCTestCase { ) store.save(albumID: albumID, records: [ makePhotoRecord( - id: "clear_target", - fileName: "clear_target.JPG", - localPath: CameraDownloadStorage.relativePath(for: fileURL), + id: "store_photo", + fileName: "store_photo.JPG", + localPath: CameraDownloadStorage.relativePath(for: storeURL), albumID: albumID, userID: userID ) ]) - store.save(albumID: otherAlbumID, records: [ - makePhotoRecord( - id: "clear_other", - fileName: "clear_other.JPG", - localPath: CameraDownloadStorage.relativePath(for: otherFileURL), - albumID: otherAlbumID, - userID: userID - ) - ]) + store.clearAlbum(albumID: albumID) - let deletedIDs = store.clearAlbum(albumID: albumID) - - XCTAssertEqual(deletedIDs, Set(["clear_target"])) - XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath)) - XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path)) XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath)) } - /// 测试同一用户 ID 在不同账号类型下的有线传图记录与物理目录隔离。 + /// 测试同手机号不同账号类型的本地记录与物理目录隔离。 func testSameUserIDDifferentAccountTypesAreIsolated() throws { - let userID = "multi-account-user" - let albumID = 8803 + let userID = "same-user-id" + let albumID = 9901 + let storeAccount = AccountContext() storeAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "门店"), accountType: "store") let photographerAccount = AccountContext() photographerAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "摄影师"), accountType: "photographer") + let storeScope = downloadScope(account: storeAccount, albumID: albumID) let photographerScope = downloadScope(account: photographerAccount, albumID: albumID) defer { @@ -632,10 +358,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase { let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: storeScope) let photographerURL = CameraDownloadStorage.uniqueLocalURL(for: "photographer_photo.JPG", scope: photographerScope) - try Data(repeating: 0x03, count: 8).write(to: storeURL) - try Data(repeating: 0x04, count: 8).write(to: photographerURL) + try makeJPEGData().write(to: storeURL) + try makeJPEGData().write(to: photographerURL) - let store = WiredTransferPhotoStore( + let storeStore = WiredTransferPhotoStore( accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" }, userIDProvider: { userID } ) @@ -643,7 +369,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { accountPrefixProvider: { photographerAccount.accountCachePrefix ?? "guest_" }, userIDProvider: { userID } ) - store.save(albumID: albumID, records: [ + storeStore.save(albumID: albumID, records: [ makePhotoRecord( id: "store_photo", fileName: "store_photo.JPG", @@ -662,7 +388,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { ) ]) - XCTAssertEqual(store.load(albumID: albumID).map(\.id), ["store_photo"]) + XCTAssertEqual(storeStore.load(albumID: albumID).map(\.id), ["store_photo"]) XCTAssertEqual(photographerStore.load(albumID: albumID).map(\.id), ["photographer_photo"]) XCTAssertNotEqual( CameraDownloadStorage.scopeDirectory(for: storeScope).path, @@ -672,8 +398,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase { private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel { let viewModel = WiredCameraTransferViewModel( - context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""), - cameraService: MockWiredCameraService() + context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "") ) viewModel.replacePhotos([ makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"), @@ -741,63 +466,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase { } } -@MainActor -private final class DisconnectTrackingWiredCameraService: CameraServiceProtocol { - let brand: CameraBrand = .sony - var connectionState: CameraConnectionState = .disconnected - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - private(set) var connectCount = 0 - private(set) var detachCount = 0 - private(set) var shutdownCount = 0 - - func connect() async { - connectCount += 1 - connectionState = .connected(deviceName: "Mock") - onConnectionStateChange?(.connected(deviceName: "Mock")) - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 50_000_000) - } - } - - func detachFromUI() { detachCount += 1 } - func shutdown() async { shutdownCount += 1 } - - func listAssets() async throws -> [CameraAsset] { [] } - - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) - } -} - -@MainActor -private final class MockWiredCameraService: CameraServiceProtocol { - let brand: CameraBrand = .sony - var connectionState: CameraConnectionState = .connected(deviceName: "Mock") - var onConnectionStateChange: ((CameraConnectionState) -> Void)? - var onNewAsset: ((CameraAsset) -> Void)? - var downloadURL: URL? - - func connect() async {} - func detachFromUI() {} - func shutdown() async {} - - func listAssets() async throws -> [CameraAsset] { - [] - } - - func downloadAsset(_ asset: CameraAsset) async throws -> URL { - if let downloadURL { - return downloadURL - } - let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename) - if !FileManager.default.fileExists(atPath: url.path) { - try Data(repeating: 0xFF, count: 8).write(to: url) - } - return url - } -} - @MainActor private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing { func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }