From 90d135b5acfaf18655c52b9bbe8e9aa9fdd669f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 1 Jul 2026 15:45:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20OTG=20=E7=9B=B8=E6=9C=BA?= =?UTF-8?q?=E6=9C=89=E7=BA=BF=E4=BC=A0=E5=9B=BE=EF=BC=9A=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20Sony/Canon=20=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB=EF=BC=8C?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20detach=20=E4=BF=9D=E6=8C=81=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E4=BB=A5=E4=BE=BF=E5=86=8D=E6=AC=A1=E8=BF=9B=E5=85=A5?= =?UTF-8?q?=E5=A4=8D=E8=BF=9E=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 8 +- suixinkan/App/AppDelegate.swift | 9 + .../AutoDetectCameraService.swift | 213 +++++++ .../Core/CameraTethering/CameraTethering.md | 63 +- .../CameraTetheringSession.swift | 17 + suixinkan/Core/CameraTethering/Canon/Canon.md | 39 ++ .../Canon/CanonCameraService.swift | 181 ++++++ .../Canon/CanonEosEventParser.swift | 188 ++++++ .../CanonImageCaptureDeviceManager.swift | 597 ++++++++++++++++++ .../Canon/CanonPTPCommands.swift | 177 ++++++ .../Factory/CameraServiceFactory.swift | 9 +- .../Models/CameraUSBVendorID.swift | 25 + .../Protocol/CameraServiceProtocol.swift | 12 +- .../Sony/ImageCaptureDeviceManager.swift | 198 ++++-- .../Sony/SonyCameraService.swift | 59 +- .../Sony/SonyPTPCommands.swift | 223 ++----- .../Utilities/CameraDownloadDelegate.swift | 37 ++ .../Utilities/CameraTetheringLogger.swift | 16 +- .../CameraTethering/Utilities/PTPCommon.swift | 231 +++++++ .../Utilities/SharedUSBCameraBrowserHub.swift | 6 + .../USBDeviceBrowserController.swift | 393 ++++++++++++ .../CameraTransferPipeline.swift | 56 +- suixinkan/Features/TravelAlbum/TravelAlbum.md | 2 +- .../ViewModels/TravelAlbumViewModels.swift | 85 ++- .../Views/WiredCameraTransferView.swift | 10 +- .../WiredTransferControlCard.swift | 12 +- suixinkan/Info.plist | 2 +- .../CameraBrandDetectionTests.swift | 19 + .../CameraServiceFactoryTests.swift | 34 + .../CameraTetheringSessionTests.swift | 24 + .../CanonPTPCommandsTests.swift | 85 +++ .../USBDeviceBrowserControllerTests.swift | 25 + .../CameraTransferPipelineTests.swift | 59 +- .../WiredCameraTransferViewModelTests.swift | 66 +- 34 files changed, 2870 insertions(+), 310 deletions(-) create mode 100644 suixinkan/Core/CameraTethering/AutoDetectCameraService.swift create mode 100644 suixinkan/Core/CameraTethering/CameraTetheringSession.swift create mode 100644 suixinkan/Core/CameraTethering/Canon/Canon.md create mode 100644 suixinkan/Core/CameraTethering/Canon/CanonCameraService.swift create mode 100644 suixinkan/Core/CameraTethering/Canon/CanonEosEventParser.swift create mode 100644 suixinkan/Core/CameraTethering/Canon/CanonImageCaptureDeviceManager.swift create mode 100644 suixinkan/Core/CameraTethering/Canon/CanonPTPCommands.swift create mode 100644 suixinkan/Core/CameraTethering/Models/CameraUSBVendorID.swift create mode 100644 suixinkan/Core/CameraTethering/Utilities/CameraDownloadDelegate.swift create mode 100644 suixinkan/Core/CameraTethering/Utilities/PTPCommon.swift create mode 100644 suixinkan/Core/CameraTethering/Utilities/SharedUSBCameraBrowserHub.swift create mode 100644 suixinkan/Core/CameraTethering/Utilities/USBDeviceBrowserController.swift create mode 100644 suixinkanTests/CameraTethering/CameraBrandDetectionTests.swift create mode 100644 suixinkanTests/CameraTethering/CameraServiceFactoryTests.swift create mode 100644 suixinkanTests/CameraTethering/CameraTetheringSessionTests.swift create mode 100644 suixinkanTests/CameraTethering/CanonPTPCommandsTests.swift create mode 100644 suixinkanTests/CameraTethering/USBDeviceBrowserControllerTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index f20a244..45a585d 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -64,11 +64,15 @@ }; A00000022FE9000000000002 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanTests; sourceTree = ""; }; B00000022FEF000000000002 /* suixinkanUITests */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = suixinkanUITests; sourceTree = ""; }; @@ -324,14 +328,10 @@ 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 64ef336..39fca7c 100644 --- a/suixinkan/App/AppDelegate.swift +++ b/suixinkan/App/AppDelegate.swift @@ -38,4 +38,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate { completionHandler(.newData) } } + + func applicationWillTerminate(_ application: UIApplication) { + let semaphore = DispatchSemaphore(value: 0) + Task { @MainActor in + await CameraTetheringSession.shutdown() + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 2) + } } diff --git a/suixinkan/Core/CameraTethering/AutoDetectCameraService.swift b/suixinkan/Core/CameraTethering/AutoDetectCameraService.swift new file mode 100644 index 0000000..c6cb34a --- /dev/null +++ b/suixinkan/Core/CameraTethering/AutoDetectCameraService.swift @@ -0,0 +1,213 @@ +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 index bcde8fe..72ef78d 100644 --- a/suixinkan/Core/CameraTethering/CameraTethering.md +++ b/suixinkan/Core/CameraTethering/CameraTethering.md @@ -2,26 +2,73 @@ ## 职责 -提供 USB OTG 相机有线连接与 Sony PTP 边拍边传能力,不包含业务 API 或 OSS 上传。 +提供 USB OTG 相机有线连接与 Sony/Canon PTP 边拍边传能力,不包含业务 API 或 OSS 上传。 ## 核心组件 | 组件 | 说明 | | --- | --- | | `CameraServiceProtocol` | 相机服务抽象,上层只依赖此协议 | -| `SonyCameraService` | Sony 相机实现,封装 PTP 下载与连接状态 | -| `ImageCaptureDeviceManager` | ImageCaptureCore 设备浏览、会话与 PTP 事件 | -| `SonyPTPCommands` | Sony SDIO/PTP 命令封装 | +| `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. `SonyCameraService.connect()` 启动 USB 设备搜索。 -2. 授权通过后打开 ICC 会话并执行 Sony SDIO 握手。 -3. 拍摄事件触发 PTP 下载,底层先写入临时目录,再由传输管道按账号与相册移入业务目录。 -4. 通过 `onNewAsset` 回调通知上层有新照片。 +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 new file mode 100644 index 0000000..09b33b8 --- /dev/null +++ b/suixinkan/Core/CameraTethering/CameraTetheringSession.swift @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..0d90fe0 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Canon/Canon.md @@ -0,0 +1,39 @@ +# 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 new file mode 100644 index 0000000..398b167 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Canon/CanonCameraService.swift @@ -0,0 +1,181 @@ +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 new file mode 100644 index 0000000..1c938a4 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Canon/CanonEosEventParser.swift @@ -0,0 +1,188 @@ +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 new file mode 100644 index 0000000..599e0c2 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Canon/CanonImageCaptureDeviceManager.swift @@ -0,0 +1,597 @@ +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 new file mode 100644 index 0000000..037f319 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Canon/CanonPTPCommands.swift @@ -0,0 +1,177 @@ +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 index 28300d7..25b163b 100644 --- a/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift +++ b/suixinkan/Core/CameraTethering/Factory/CameraServiceFactory.swift @@ -5,8 +5,15 @@ enum CameraServiceFactory { switch brand { case .sony: SonyCameraService() - case .canon, .nikon: + case .canon: + CanonCameraService() + case .nikon: preconditionFailure("\(brand.displayName) 相机尚未实现") } } + + /// 自动识别 USB 相机品牌;返回进程级共享实例以支持页面 detach / 再进入复连。 + static func makeAutoDetect() -> any CameraServiceProtocol { + CameraTetheringSession.sharedService + } } diff --git a/suixinkan/Core/CameraTethering/Models/CameraUSBVendorID.swift b/suixinkan/Core/CameraTethering/Models/CameraUSBVendorID.swift new file mode 100644 index 0000000..040a701 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Models/CameraUSBVendorID.swift @@ -0,0 +1,25 @@ +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 index 869a838..1286867 100644 --- a/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift +++ b/suixinkan/Core/CameraTethering/Protocol/CameraServiceProtocol.swift @@ -27,7 +27,17 @@ protocol CameraServiceProtocol: AnyObject { var onNewAsset: ((CameraAsset) -> Void)? { get set } func connect() async - func disconnect() 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 index dc1df2d..e2d7c4e 100644 --- a/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift +++ b/suixinkan/Core/CameraTethering/Sony/ImageCaptureDeviceManager.swift @@ -2,39 +2,6 @@ import CoreGraphics import Foundation import 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)) - } -} - final class ImageCaptureDeviceManager: NSObject { var onConnectionStateChange: ((CameraConnectionState) -> Void)? var onNewItems: (([ICCameraItem]) -> Void)? @@ -62,8 +29,27 @@ final class ImageCaptureDeviceManager: NSObject { 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 } + 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 { @@ -90,7 +76,7 @@ final class ImageCaptureDeviceManager: NSObject { browser.start() } - func stopBrowsing() { + func stopBrowsing() async { CameraTetheringLogger.log("停止搜索并断开") propertyCheckTask?.cancel() shotDownloadQueue.removeAll() @@ -100,28 +86,94 @@ final class ImageCaptureDeviceManager: NSObject { propertyCheckTask = nil stopShotBufferPolling() stopPolling() - disconnect() - deviceBrowser?.stop() + await disconnect() + if !isExternalAttach { + deviceBrowser?.stop() + } deviceBrowser = nil + isExternalAttach = false isCatalogReady = false baselineNotified = false updateState(.disconnected) } - func disconnect() { + 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 - propertyCheckTask = nil stopShotBufferPolling() stopPolling() - if let camera = cameraDevice { - camera.ptpEventHandler = { _ in } - camera.requestCloseSession() - camera.delegate = nil + 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 @@ -134,6 +186,7 @@ final class ImageCaptureDeviceManager: NSObject { isDrainingShotQueue = false isEvaluatingShotBuffer = false propertyCheckCoalesceScheduled = false + isExternalAttach = false } func localURL(forAssetID assetID: String) -> URL? { @@ -286,11 +339,13 @@ final class ImageCaptureDeviceManager: NSObject { self?.handlePTPEvent(eventData) } - Task { + 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() @@ -299,6 +354,7 @@ final class ImageCaptureDeviceManager: NSObject { } 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) @@ -331,6 +387,7 @@ final class ImageCaptureDeviceManager: NSObject { } private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval = 0.12) { + guard !isDisconnecting else { return } if isEvaluatingShotBuffer { propertyCheckCoalesceScheduled = true return @@ -344,6 +401,7 @@ final class ImageCaptureDeviceManager: NSObject { } private func runPropertyBasedShotCheck(reason: String) async { + guard !isDisconnecting else { return } if isEvaluatingShotBuffer { propertyCheckCoalesceScheduled = true return @@ -351,19 +409,23 @@ final class ImageCaptureDeviceManager: NSObject { isEvaluatingShotBuffer = true defer { isEvaluatingShotBuffer = false - if propertyCheckCoalesceScheduled { + if propertyCheckCoalesceScheduled, !isDisconnecting { propertyCheckCoalesceScheduled = false - Task { [weak self] in + 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 isRemoteSessionReady, let camera = cameraDevice else { return } + guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return } var localTransactionID = transactionID var properties = await SonyPTPHelper.fetchExtDeviceProperties( @@ -423,7 +485,7 @@ final class ImageCaptureDeviceManager: NSObject { /// Probe 缓冲区内对象;若 filename+size 与上次不同则入队下载。 @discardableResult private func attemptFetchPendingShot(reason: String) async -> Bool { - guard isRemoteSessionReady, let camera = cameraDevice else { return false } + guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return false } var localTransactionID = transactionID guard let probe = await SonyPTPHelper.probeShotObject( @@ -452,11 +514,11 @@ final class ImageCaptureDeviceManager: NSObject { } private func refreshShotBufferPollInterval() { - guard isRemoteSessionReady else { return } + 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.isRemoteSessionReady else { return } + guard let self, !self.isDisconnecting, self.isRemoteSessionReady else { return } Task { await self.evaluateShotBufferFromProperties(reason: "D215Poll") } } } @@ -468,6 +530,7 @@ final class ImageCaptureDeviceManager: NSObject { /// 连拍时多个 PTP 事件会连续到达;入队串行处理,避免「下载进行中直接丢弃」。 private func enqueueShotDownload(handle: UInt32, reason: String) { + guard !isDisconnecting else { return } shotDownloadQueue.append((handle: handle, reason: reason)) CameraTetheringLogger.log( "\(reason): 入队 PTP 下载 (队列=\(shotDownloadQueue.count))", @@ -478,11 +541,13 @@ final class ImageCaptureDeviceManager: NSObject { } private func startShotDownloadDrainIfNeeded() { + guard !isDisconnecting else { return } guard !isDrainingShotQueue else { return } guard !shotDownloadQueue.isEmpty else { return } isDrainingShotQueue = true - Task { [weak self] in + shotDownloadDrainTask?.cancel() + shotDownloadDrainTask = Task { [weak self] in await self?.drainShotDownloadQueue() } } @@ -490,13 +555,16 @@ final class ImageCaptureDeviceManager: NSObject { private func drainShotDownloadQueue() async { defer { isDrainingShotQueue = false - refreshShotBufferPollInterval() - if !shotDownloadQueue.isEmpty { - startShotDownloadDrainIfNeeded() + shotDownloadDrainTask = nil + if !isDisconnecting { + refreshShotBufferPollInterval() + if !shotDownloadQueue.isEmpty { + startShotDownloadDrainIfNeeded() + } } } - while !shotDownloadQueue.isEmpty { + while !shotDownloadQueue.isEmpty, !isDisconnecting { let item = shotDownloadQueue.removeFirst() _ = await downloadPTPObject( handle: item.handle, @@ -507,13 +575,14 @@ final class ImageCaptureDeviceManager: NSObject { 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 isRemoteSessionReady, let camera = cameraDevice else { break } + guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { break } var localTransactionID = transactionID guard let probe = await SonyPTPHelper.probeShotObject( @@ -548,7 +617,7 @@ final class ImageCaptureDeviceManager: NSObject { maxAttempts: Int = 3, tryDirectGetObject: Bool = false ) async -> Bool { - guard isRemoteSessionReady, let camera = cameraDevice else { + guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { CameraTetheringLogger.log("\(reason): Sony 远程会话尚未就绪", logger: CameraTetheringLogger.ptp) return false } @@ -564,6 +633,7 @@ final class ImageCaptureDeviceManager: NSObject { 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) } @@ -676,6 +746,7 @@ final class ImageCaptureDeviceManager: NSObject { } 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 ?? "?")") @@ -704,10 +775,12 @@ extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate { } } + @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 { - disconnect() + Task { await disconnect() } updateState(.disconnected) } } @@ -715,9 +788,10 @@ extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate { extension ImageCaptureDeviceManager: ICDeviceDelegate { func didRemove(_ device: ICDevice) { + guard !isDisconnecting else { return } CameraTetheringLogger.log("didRemove: \(device.name ?? "?")") if device.uuidString == cameraDevice?.uuidString { - disconnect() + Task { await disconnect() } updateState(.disconnected) } } @@ -742,11 +816,17 @@ extension ImageCaptureDeviceManager: ICDeviceDelegate { 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) } } diff --git a/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift b/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift index f7028b0..62c4bad 100644 --- a/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift +++ b/suixinkan/Core/CameraTethering/Sony/SonyCameraService.swift @@ -12,6 +12,7 @@ final class SonyCameraService: CameraServiceProtocol { private let deviceManager = ImageCaptureDeviceManager() private var knownAssetIDs: Set = [] private var baselineEstablished = false + private var pendingAssetsWhileDetached: [CameraAsset] = [] init() { deviceManager.onConnectionStateChange = { [weak self] state in @@ -64,11 +65,39 @@ final class SonyCameraService: CameraServiceProtocol { } } - func disconnect() async { - CameraTetheringLogger.log("SonyCameraService.disconnect()") - deviceManager.stopBrowsing() + /// 由 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) } @@ -100,13 +129,7 @@ final class SonyCameraService: CameraServiceProtocol { } private func handlePTPAsset(_ asset: CameraAsset) { - guard !knownAssetIDs.contains(asset.id) else { return } - knownAssetIDs.insert(asset.id) - CameraTetheringLogger.log( - "[PTP] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)", - logger: CameraTetheringLogger.transfer - ) - onNewAsset?(asset) + deliverNewAsset(asset, source: "PTP") } /// 连接后首次目录就绪:已有照片记入基线;拍摄于枚举期间的新照片立即传输。 @@ -153,10 +176,20 @@ final class SonyCameraService: CameraServiceProtocol { private func emitAssetIfNew(file: ICCameraFile, source: String) { let asset = deviceManager.makeAsset(from: file) - guard !knownAssetIDs.contains(asset.id) else { return } + 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) - onNewAsset?(asset) + 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 index 65a6eb7..ae14ccf 100644 --- a/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift +++ b/suixinkan/Core/CameraTethering/Sony/SonyPTPCommands.swift @@ -1,26 +1,6 @@ import Foundation import ImageCaptureCore -enum PTPContainerType: UInt16 { - case command = 0x0001 - case data = 0x0002 - case response = 0x0003 - case event = 0x0004 -} - -struct PTPContainer { - let length: UInt32 - let type: UInt16 - let code: UInt16 - let transactionID: UInt32 - let params: [UInt32] -} - -enum PTPParseError: Error { - case tooShort - case invalidLength -} - enum SonyPTPCommand { static let getDeviceInfo: UInt16 = 0x1001 static let openSession: UInt16 = 0x1002 @@ -42,7 +22,7 @@ enum SonyPTPCommand { static let stillImageTransSize: UInt16 = 0xD268 static let shootingFileInfo: UInt16 = 0xD215 static let shotObjectHandle: UInt32 = 0xFFFF_C001 - static let ptpResponseOk: UInt16 = 0x2001 + static let ptpResponseOk: UInt16 = PTPResponseCode.ok.rawValue static let saveDestinationHostPC: UInt16 = 1 static let saveDestinationHostAndCamera: UInt16 = 2 static let shotBufferReadyThreshold: Int64 = 0x8000 @@ -66,90 +46,35 @@ enum SonyPTPHelper { 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 + PTPHelper.buildCommand(code: code, transactionID: transactionID, parameters: parameters) } 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 - ) + try PTPHelper.parseContainer(data) } static func parseEventCode(from eventData: Data) -> UInt16? { - guard let container = try? parseContainer(eventData) else { return nil } - return container.code + PTPHelper.parseEventCode(from: eventData) } static func parseObjectCompressedSize(_ data: Data) -> UInt32? { - guard data.count >= 12 else { return nil } - return readUInt32LE(data, offset: 8) + PTPHelper.parseObjectCompressedSize(data) } 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) + PTPHelper.parseObjectInfoFilename(data) } 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 } + PTPHelper.parsePTPString(data, offset: start) + } - var codeUnits: [UInt16] = [] - codeUnits.reserveCapacity(charCount) - for index in 0.. UInt16 { + PTPHelper.readUInt16LE(data, offset: offset) + } - let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count) - return (string, offset) + static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 { + PTPHelper.readUInt32LE(data, offset: offset) } /// ImageCaptureCore 已建立 PTP 会话,此处执行 Sony SDIO 握手并进入 PC Remote 模式。 @@ -212,6 +137,30 @@ enum SonyPTPHelper { 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, @@ -266,22 +215,17 @@ enum SonyPTPHelper { outData: nil, label: "GetObject(0x\(String(format: "%08X", handle)))" ) - guard objectResult.success, isLikelyImageData(objectResult.inData) else { return nil } + guard objectResult.success, PTPHelper.isLikelyImageData(objectResult.inData) else { return nil } return (filename, objectResult.inData) } static func isLikelyImageData(_ data: Data) -> 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 + PTPHelper.isLikelyImageData(data) } static func isResponseOK(_ response: Data) -> Bool { - guard !response.isEmpty else { return true } - guard let parsed = try? parseContainer(response) else { return false } - return parsed.code == SonyPTPCommand.ptpResponseOk + PTPHelper.isResponseOK(response) } static func fetchExtDeviceProperties( @@ -448,7 +392,7 @@ enum SonyPTPHelper { label: String ) async -> Bool { var payload = Data() - appendUInt32LE(value, to: &payload) + PTPHelper.appendUInt32LE(value, to: &payload) return await sendCommand( on: camera, @@ -468,7 +412,7 @@ enum SonyPTPHelper { label: String ) async -> Bool { var payload = Data() - appendUInt16LE(value, to: &payload) + PTPHelper.appendUInt16LE(value, to: &payload) return await sendCommand( on: camera, @@ -480,12 +424,6 @@ enum SonyPTPHelper { ).success } - private struct PTPCommandResult { - let success: Bool - let response: Data - let inData: Data - } - private static func sendCommand( on camera: ICCameraDevice, code: UInt16, @@ -494,78 +432,13 @@ enum SonyPTPHelper { outData: Data?, label: String ) async -> PTPCommandResult { - let currentID = transactionID - transactionID += 1 - - let commandData = buildCommand( + await PTPHelper.sendCommand( + on: camera, code: code, - transactionID: currentID, - parameters: parameters + transactionID: &transactionID, + parameters: parameters, + outData: outData, + label: label ) - - 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 != SonyPTPCommand.ptpResponseOk { - 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)) - } - ) - } - } - - private static func appendUInt16LE(_ value: UInt16, to data: inout Data) { - var littleEndian = value.littleEndian - withUnsafeBytes(of: &littleEndian) { raw in - data.append(contentsOf: raw) - } - } - - private 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/CameraDownloadDelegate.swift b/suixinkan/Core/CameraTethering/Utilities/CameraDownloadDelegate.swift new file mode 100644 index 0000000..41a2328 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Utilities/CameraDownloadDelegate.swift @@ -0,0 +1,37 @@ +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 index f766b43..04ffe5f 100644 --- a/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift +++ b/suixinkan/Core/CameraTethering/Utilities/CameraTetheringLogger.swift @@ -10,14 +10,26 @@ 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 日志。 + /// 写入指定分类的 info 日志,消息前自动附加 `[OTG]` 前缀。 static func log(_ message: String, logger: Logger = camera) { - logger.info("\(message, privacy: .public)") + 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 new file mode 100644 index 0000000..d97026c --- /dev/null +++ b/suixinkan/Core/CameraTethering/Utilities/PTPCommon.swift @@ -0,0 +1,231 @@ +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 new file mode 100644 index 0000000..722ce4a --- /dev/null +++ b/suixinkan/Core/CameraTethering/Utilities/SharedUSBCameraBrowserHub.swift @@ -0,0 +1,6 @@ +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 new file mode 100644 index 0000000..08be115 --- /dev/null +++ b/suixinkan/Core/CameraTethering/Utilities/USBDeviceBrowserController.swift @@ -0,0 +1,393 @@ +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/CameraTransferPipeline.swift b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift index e8ae30a..9935db5 100644 --- a/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift +++ b/suixinkan/Core/CameraTransfer/CameraTransferPipeline.swift @@ -26,10 +26,21 @@ final class CameraTransferPipeline { private let maxRetries = 3 private let deferredNotifyInterval: TimeInterval = 0.15 private let maxConcurrentUploads = 3 + private var isUIAttached = false /// 初始化传输管道,并注入相机服务。 init(cameraService: any CameraServiceProtocol) { self.cameraService = cameraService + } + + /// 绑定 UI 回调;再次进入 OTG 页时重新 attach。 + func attachToUI( + onConnectionStateChange: @escaping (CameraConnectionState) -> Void, + 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) @@ -37,6 +48,18 @@ final class CameraTransferPipeline { } } + /// 页面离开:取消 UI 回调与进行中的上传通知,保持相机连接。 + func detachFromUI() { + guard isUIAttached else { return } + isUIAttached = false + pendingDeferredNotifyTask?.cancel() + pendingDeferredNotifyTask = nil + activeUploadAssetIDs.removeAll() + inFlightAssetIDs.removeAll() + onTasksUpdated = nil + cameraService.detachFromUI() + } + /// 配置上传 Sink、自动上传开关与可选下载作用域。 func configure( uploadSink: (any CameraAssetUploadSink)?, @@ -50,14 +73,33 @@ 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 cameraService.disconnect() + await shutdown() + } + + /// 重新搜索 USB 相机。 + func restartCameraDiscovery() async { + if let autoDetect = cameraService as? AutoDetectCameraService { + await autoDetect.restartDiscovery() + return + } + await cameraService.shutdown() + await cameraService.connect() } /// 当前相机连接状态。 @@ -65,14 +107,6 @@ final class CameraTransferPipeline { cameraService.connectionState } - /// 设置连接状态变化回调。 - var onConnectionStateChange: ((CameraConnectionState) -> Void)? { - get { nil } - set { - cameraService.onConnectionStateChange = newValue - } - } - /// 重试所有失败的上传任务。 func retryFailedUploads() async { guard uploadEnabled, uploadSink != nil else { return } diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index c591d4c..8dc4618 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -69,7 +69,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层 ## 业务流程 1. 创建任务:`POST .../travel-album/create` -2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`) +2. 进入有线传图:Sony/Canon USB 有线传图(`Core/CameraTethering`,自动识别品牌) 3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile` 4. 素材登记:`POST .../travel-album/upload-material` 5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads///originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录 diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index ff0af56..f0842d4 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -435,6 +435,7 @@ final class WiredCameraTransferViewModel: ObservableObject { @Published var showCameraDisconnectedAlert = false @Published var showHelpDoc = false @Published var isRefreshingCameraFiles = false + @Published var isSearchingCamera = false @Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() @Published var connectionState: CameraConnectionState = .disconnected @Published var errorMessage: String? @@ -450,6 +451,9 @@ final class WiredCameraTransferViewModel: ObservableObject { private var lastProgressPersistDates: [String: Date] = [:] private var wasConnected = false private var disconnectAlertTask: Task? + private var isTearingDownCamera = false + /// 用户主动离开页面时为 true,避免误弹「相机已断开」警告。 + private var isLeavingPage = false /// 初始化有线传图 ViewModel。 init( @@ -458,17 +462,6 @@ final class WiredCameraTransferViewModel: ObservableObject { ) { self.context = context self.pipeline = CameraTransferPipeline(cameraService: cameraService) - - pipeline.onConnectionStateChange = { [weak self] state in - Task { @MainActor in - self?.handleConnectionStateChange(state) - } - } - pipeline.onTasksUpdated = { [weak self] tasks in - Task { @MainActor in - await self?.applyPipelineTasks(tasks) - } - } } /// 配置上传 Sink 并启动相机连接。 @@ -478,6 +471,18 @@ final class WiredCameraTransferViewModel: ObservableObject { scenicID: Int, 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) + } + } + ) + let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_" let currentDownloadScope = CameraDownloadStorage.Scope( accountKey: accountCachePrefix, @@ -505,15 +510,38 @@ final class WiredCameraTransferViewModel: ObservableObject { pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope) refreshDeviceStorageInfo() loadPersistedPhotos() - await pipeline.connect() - connectionState = pipeline.connectionState - wasConnected = connectionState.isConnected + + 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 + } + detachFromUI() } - /// 断开相机连接。 + /// 页面离开:仅 detach UI 回调,保持相机连接。 func stop() async { + detachFromUI() + } + + /// 页面离开:取消 UI 回调,不关闭相机会话。 + func detachFromUI() { + guard !isTearingDownCamera else { return } + isTearingDownCamera = true + isLeavingPage = true + defer { isTearingDownCamera = false } + disconnectAlertTask?.cancel() - await pipeline.disconnect() + showCameraDisconnectedAlert = false + pipeline.detachFromUI() } /// 刷新设备存储信息。 @@ -571,12 +599,25 @@ final class WiredCameraTransferViewModel: ObservableObject { errorMessage = nil } - /// 刷新相机文件列表。 + /// 刷新相机文件列表;未连接时重新搜索 USB 相机。 func refreshCameraFiles() async { - guard connectionState.isConnected, !isRefreshingCameraFiles else { return } - isRefreshingCameraFiles = true - defer { isRefreshingCameraFiles = false } - await pipeline.syncExistingPhotos() + if connectionState.isConnected { + guard !isRefreshingCameraFiles else { return } + isRefreshingCameraFiles = true + defer { isRefreshingCameraFiles = false } + await pipeline.syncExistingPhotos() + return + } + await restartCameraSearch() + } + + /// 重新搜索 USB 相机。 + func restartCameraSearch() async { + guard !connectionState.isConnected, !isSearchingCamera else { return } + isSearchingCamera = true + defer { isSearchingCamera = false } + await pipeline.restartCameraDiscovery() + connectionState = pipeline.connectionState } /// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。 @@ -912,7 +953,7 @@ final class WiredCameraTransferViewModel: ObservableObject { } private func handleConnectionStateChange(_ state: CameraConnectionState) { - if wasConnected, !state.isConnected { + if wasConnected, !state.isConnected, !isLeavingPage { triggerCameraDisconnectedAlert() } wasConnected = state.isConnected diff --git a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift index ca2a9e1..e7a16c8 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift @@ -16,6 +16,7 @@ struct WiredCameraTransferView: View { @Environment(\.ossUploadService) private var ossUploadService @EnvironmentObject private var toastCenter: ToastCenter @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase @StateObject private var viewModel: WiredCameraTransferViewModel @State private var previewSources: [String]? @@ -26,7 +27,7 @@ struct WiredCameraTransferView: View { _viewModel = StateObject( wrappedValue: WiredCameraTransferViewModel( context: context, - cameraService: CameraServiceFactory.make(brand: .sony) + cameraService: CameraServiceFactory.makeAutoDetect() ) ) } @@ -50,6 +51,7 @@ struct WiredCameraTransferView: View { deviceStorageInfo: viewModel.deviceStorageInfo, isCameraConnected: viewModel.isCameraConnected, isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles, + isSearchingCamera: viewModel.isSearchingCamera, cameraDeviceName: viewModel.cameraDeviceName, transferModeOption: viewModel.transferModeOption, tabTitles: tabTitles, @@ -103,7 +105,11 @@ struct WiredCameraTransferView: View { ) } .onDisappear { - Task { await viewModel.stop() } + viewModel.detachFromUI() + } + .onChange(of: scenePhase) { phase in + guard phase == .active, !viewModel.isCameraConnected else { return } + Task { await viewModel.restartCameraSearch() } } .onChange(of: viewModel.errorMessage) { message in guard let message, !message.isEmpty else { return } diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift index 01723f8..763fe9d 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferControlCard.swift @@ -11,6 +11,7 @@ struct WiredTransferControlCard: View { let deviceStorageInfo: DeviceStorageSnapshot let isCameraConnected: Bool let isRefreshingCameraFiles: Bool + let isSearchingCamera: Bool let cameraDeviceName: String let transferModeOption: String let tabTitles: [String] @@ -130,19 +131,20 @@ struct WiredTransferControlCard: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } .buttonStyle(.plain) - .disabled(!isCameraConnected || isRefreshingCameraFiles) - .opacity(isCameraConnected && !isRefreshingCameraFiles ? 1 : 0.85) + .disabled(isRefreshingCameraFiles || isSearchingCamera) + .opacity(isRefreshingCameraFiles || isSearchingCamera ? 0.85 : 1) } private var refreshButtonTitle: String { + if isSearchingCamera { return "搜索中" } if isRefreshingCameraFiles { return "读取中" } if isCameraConnected { return "刷新" } - return "等待连接" + return "重新搜索" } private var refreshButtonColor: Color { - if !isCameraConnected { return WiredTransferDesign.danger } - if isRefreshingCameraFiles { return WiredTransferDesign.text999 } + if isSearchingCamera || isRefreshingCameraFiles { return WiredTransferDesign.text999 } + if !isCameraConnected { return AppDesign.primary } return AppDesign.primary } diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 9e94e4b..6d6d154 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -21,7 +21,7 @@ LSRequiresIPhoneOS NSCameraUsageDescription - 用于扫码核销订单和直播推流预览 + 用于扫码核销、直播推流预览,以及通过 USB 连接外接相机进行有线传图 NSLocationWhenInUseUsageDescription 用于景区选择、打卡点选点和位置上报时获取当前位置 NSMicrophoneUsageDescription diff --git a/suixinkanTests/CameraTethering/CameraBrandDetectionTests.swift b/suixinkanTests/CameraTethering/CameraBrandDetectionTests.swift new file mode 100644 index 0000000..c7da282 --- /dev/null +++ b/suixinkanTests/CameraTethering/CameraBrandDetectionTests.swift @@ -0,0 +1,19 @@ +// +// 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/CameraServiceFactoryTests.swift b/suixinkanTests/CameraTethering/CameraServiceFactoryTests.swift new file mode 100644 index 0000000..705d28a --- /dev/null +++ b/suixinkanTests/CameraTethering/CameraServiceFactoryTests.swift @@ -0,0 +1,34 @@ +// +// 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 new file mode 100644 index 0000000..39339aa --- /dev/null +++ b/suixinkanTests/CameraTethering/CameraTetheringSessionTests.swift @@ -0,0 +1,24 @@ +// +// 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 new file mode 100644 index 0000000..40ad8e8 --- /dev/null +++ b/suixinkanTests/CameraTethering/CanonPTPCommandsTests.swift @@ -0,0 +1,85 @@ +// +// 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 new file mode 100644 index 0000000..d1f6e83 --- /dev/null +++ b/suixinkanTests/CameraTethering/USBDeviceBrowserControllerTests.swift @@ -0,0 +1,25 @@ +// +// 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 index 3c72f4f..552e6ea 100644 --- a/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift +++ b/suixinkanTests/CameraTransfer/CameraTransferPipelineTests.swift @@ -21,7 +21,7 @@ final class CameraTransferPipelineTests: XCTestCase { pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) var latestTasks: [CameraTransferTask] = [] - pipeline.onTasksUpdated = { latestTasks = $0 } + 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") @@ -95,6 +95,7 @@ final class CameraTransferPipelineTests: XCTestCase { 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) @@ -138,6 +139,7 @@ final class CameraTransferPipelineTests: XCTestCase { 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" @@ -169,7 +171,7 @@ final class CameraTransferPipelineTests: XCTestCase { pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) var updateCount = 0 - pipeline.onTasksUpdated = { _ in updateCount += 1 } + 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) @@ -178,6 +180,35 @@ final class CameraTransferPipelineTests: XCTestCase { 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) + } + + 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 @@ -186,6 +217,24 @@ final class CameraTransferPipelineTests: XCTestCase { } } +@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 @@ -195,7 +244,8 @@ private final class MockCameraService: CameraServiceProtocol { var listedAssets: [CameraAsset] = [] func connect() async {} - func disconnect() async {} + func detachFromUI() {} + func shutdown() async {} func listAssets() async throws -> [CameraAsset] { listedAssets @@ -219,7 +269,8 @@ private final class MockBurstCameraService: CameraServiceProtocol { var downloadURLs: [String: URL] = [:] func connect() async {} - func disconnect() async {} + func detachFromUI() {} + func shutdown() async {} func listAssets() async throws -> [CameraAsset] { [] } diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index a735eb6..0f7de58 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -46,6 +46,40 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG") } + /// 测试 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 account = AccountContext() + account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试")) + + let startTask = Task { + await viewModel.start( + api: MockTravelAlbumAPIForWiredTransfer(), + ossService: WiredTransferMockOSSUploadService(), + scenicID: 1, + accountContext: account + ) + } + + for _ in 0 ..< 100 { + if camera.connectCount > 0 { break } + try? await Task.sleep(nanoseconds: 10_000_000) + } + + startTask.cancel() + try? await Task.sleep(nanoseconds: 300_000_000) + + XCTAssertGreaterThanOrEqual(camera.detachCount, 1) + XCTAssertEqual(camera.shutdownCount, 0) + } + /// 测试绑定到其他相册的照片不会出现在当前相册列表。 func testPhotoBoundToOtherAlbumIsFilteredOut() async throws { let bindingStore = WiredTransferPhotoStore( @@ -655,6 +689,35 @@ 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 @@ -664,7 +727,8 @@ private final class MockWiredCameraService: CameraServiceProtocol { var downloadURL: URL? func connect() async {} - func disconnect() async {} + func detachFromUI() {} + func shutdown() async {} func listAssets() async throws -> [CameraAsset] { []