From be9c844bd337f10af1856fc6388e078a8959214a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 6 Jul 2026 11:22:07 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=86=20otg=5Fswift=20OTG=20=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E8=BF=81=E5=85=A5=20Core/CameraOTG=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=97=85=E6=8B=8D=E6=9C=89=E7=BA=BF=E4=BC=A0?= =?UTF-8?q?=E5=9B=BE=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=AF=BC=E5=85=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 4 + suixinkan/App/AppDelegate.swift | 4 +- .../Brands/Canon/CanonCameraDriver.swift | 59 ++ .../Brands/Canon/CanonPTPCommands.swift | 82 +++ .../Brands/Canon/CanonPTPHelper.swift | 369 ++++++++++ .../Canon/CanonRemoteCaptureService.swift | 448 ++++++++++++ .../Brands/Nikon/NikonCameraDriver.swift | 58 ++ .../NikonCatalogLiveCaptureService.swift | 199 ++++++ .../Brands/Sony/SonyCameraDriver.swift | 61 ++ .../Brands/Sony/SonyPTPCommands.swift | 51 ++ .../CameraOTG/Brands/Sony/SonyPTPHelper.swift | 422 ++++++++++++ .../Sony/SonyRemoteCaptureService.swift | 331 +++++++++ .../Core/CameraOTG/Bridge/OTGPhotoSink.swift | 43 ++ suixinkan/Core/CameraOTG/CameraOTG.md | 55 ++ .../CameraOTG/Core/CameraDeviceInfo.swift | 16 + .../Core/CameraOTG/Core/CameraDriver.swift | 32 + .../Core/CameraOTG/Core/CameraError.swift | 29 + .../Core/CameraOTG/Core/CameraFactory.swift | 39 ++ .../Core/CameraOTG/Core/CameraPlatform.swift | 20 + .../CameraOTG/Core/ConnectionManager.swift | 646 ++++++++++++++++++ .../Core/CameraOTG/Core/ConnectionState.swift | 58 ++ .../Core/ICCameraDevice+DebugLog.swift | 107 +++ .../Core/ICCameraFileDownloader.swift | 46 ++ .../Core/ICCameraItem+CameraObjects.swift | 139 ++++ .../Core/ICCameraMetadataLoader.swift | 133 ++++ .../Core/ICCameraThumbnailLoader.swift | 40 ++ suixinkan/Core/CameraOTG/Core/OTGLog.swift | 112 +++ .../CameraOTG/Core/PlatformDetector.swift | 111 +++ .../Core/CameraOTG/PTP/PTPUtilities.swift | 318 +++++++++ .../Storage/OTGPhotoFileWriter.swift | 100 +++ .../Models/CameraConnectionState.swift | 2 +- .../Models/SonyMTPHelpContent.swift | 41 ++ ...onnectionState+CameraConnectionState.swift | 20 + .../Services/OTGLiveTransferCoordinator.swift | 129 ++++ .../Services/TravelAlbumOTGPhotoSink.swift | 96 +++ .../WiredCameraTransferPipeline.swift | 90 +++ suixinkan/Features/TravelAlbum/TravelAlbum.md | 45 +- .../CameraHistoryImportViewModel.swift | 220 ++++++ .../ViewModels/TravelAlbumViewModels.swift | 136 +++- .../CameraHistoryImportPhotoCell.swift | 61 ++ .../CameraHistoryImportSectionHeader.swift | 28 + .../CameraHistoryImportView.swift | 140 ++++ .../CameraHistoryImport/SonyMTPHelpView.swift | 40 ++ .../Views/WiredCameraTransferView.swift | 11 +- .../WiredTransferBottomBar.swift | 16 + .../CameraOTG/OTGPhotoStorageTests.swift | 82 +++ .../CameraOTG/PTPUtilitiesTests.swift | 76 +++ .../CameraOTG/PlatformDetectorTests.swift | 95 +++ .../CameraHistoryImportViewModelTests.swift | 224 ++++++ .../WiredCameraTransferViewModelTests.swift | 57 ++ 50 files changed, 5721 insertions(+), 20 deletions(-) create mode 100644 suixinkan/Core/CameraOTG/Brands/Canon/CanonCameraDriver.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPCommands.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPHelper.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Canon/CanonRemoteCaptureService.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Nikon/NikonCameraDriver.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Sony/SonyCameraDriver.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPCommands.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPHelper.swift create mode 100644 suixinkan/Core/CameraOTG/Brands/Sony/SonyRemoteCaptureService.swift create mode 100644 suixinkan/Core/CameraOTG/Bridge/OTGPhotoSink.swift create mode 100644 suixinkan/Core/CameraOTG/CameraOTG.md create mode 100644 suixinkan/Core/CameraOTG/Core/CameraDeviceInfo.swift create mode 100644 suixinkan/Core/CameraOTG/Core/CameraDriver.swift create mode 100644 suixinkan/Core/CameraOTG/Core/CameraError.swift create mode 100644 suixinkan/Core/CameraOTG/Core/CameraFactory.swift create mode 100644 suixinkan/Core/CameraOTG/Core/CameraPlatform.swift create mode 100644 suixinkan/Core/CameraOTG/Core/ConnectionManager.swift create mode 100644 suixinkan/Core/CameraOTG/Core/ConnectionState.swift create mode 100644 suixinkan/Core/CameraOTG/Core/ICCameraDevice+DebugLog.swift create mode 100644 suixinkan/Core/CameraOTG/Core/ICCameraFileDownloader.swift create mode 100644 suixinkan/Core/CameraOTG/Core/ICCameraItem+CameraObjects.swift create mode 100644 suixinkan/Core/CameraOTG/Core/ICCameraMetadataLoader.swift create mode 100644 suixinkan/Core/CameraOTG/Core/ICCameraThumbnailLoader.swift create mode 100644 suixinkan/Core/CameraOTG/Core/OTGLog.swift create mode 100644 suixinkan/Core/CameraOTG/Core/PlatformDetector.swift create mode 100644 suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift create mode 100644 suixinkan/Core/CameraOTG/Storage/OTGPhotoFileWriter.swift create mode 100644 suixinkan/Features/TravelAlbum/Models/SonyMTPHelpContent.swift create mode 100644 suixinkan/Features/TravelAlbum/Services/ConnectionState+CameraConnectionState.swift create mode 100644 suixinkan/Features/TravelAlbum/Services/OTGLiveTransferCoordinator.swift create mode 100644 suixinkan/Features/TravelAlbum/Services/TravelAlbumOTGPhotoSink.swift create mode 100644 suixinkan/Features/TravelAlbum/ViewModels/CameraHistoryImportViewModel.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportPhotoCell.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportSectionHeader.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportView.swift create mode 100644 suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/SonyMTPHelpView.swift create mode 100644 suixinkanTests/CameraOTG/OTGPhotoStorageTests.swift create mode 100644 suixinkanTests/CameraOTG/PTPUtilitiesTests.swift create mode 100644 suixinkanTests/CameraOTG/PlatformDetectorTests.swift create mode 100644 suixinkanTests/TravelAlbum/CameraHistoryImportViewModelTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 2252dc6..6c8c554 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 11A5FC66EE3769DAC202A397 /* Pods_suixinkanTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */; }; + 93A5A1DF2FF612AE0014C2B9 /* ImageCaptureCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 93A5A1DE2FF612AE0014C2B9 /* ImageCaptureCore.framework */; }; 93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0D2FE8E50000B9E2B1 /* Kingfisher */; }; 93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED0E2FE8E50000B9E2B1 /* AlibabaCloudOSS */; }; 93DAED112FE9100000B9E2B1 /* Lottie in Frameworks */ = {isa = PBXBuildFile; productRef = 93DAED102FE9100000B9E2B1 /* Lottie */; }; @@ -36,6 +37,7 @@ 454D6E9EC1585878877CF5FD /* Pods_suixinkan.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_suixinkan.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 93946BFAF78A845957AF2518 /* Pods-suixinkan.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkan.debug.xcconfig"; path = "Target Support Files/Pods-suixinkan/Pods-suixinkan.debug.xcconfig"; sourceTree = ""; }; 939AC7962FE3F832004B22E4 /* suixinkan.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = suixinkan.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 93A5A1DE2FF612AE0014C2B9 /* ImageCaptureCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ImageCaptureCore.framework; path = System/Library/Frameworks/ImageCaptureCore.framework; sourceTree = SDKROOT; }; A00000012FE9000000000001 /* suixinkanTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B00000012FEF000000000001 /* suixinkanUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = suixinkanUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D763A7B62050979A1482C545 /* Pods-suixinkanTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-suixinkanTests.debug.xcconfig"; path = "Target Support Files/Pods-suixinkanTests/Pods-suixinkanTests.debug.xcconfig"; sourceTree = ""; }; @@ -79,6 +81,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 93A5A1DF2FF612AE0014C2B9 /* ImageCaptureCore.framework in Frameworks */, 93DAED0B2FE8E50000B9E2B1 /* Kingfisher in Frameworks */, 93DAED0C2FE8E50000B9E2B1 /* AlibabaCloudOSS in Frameworks */, 93DAED112FE9100000B9E2B1 /* Lottie in Frameworks */, @@ -140,6 +143,7 @@ F6ABC23A28D8DF8126F325ED /* Frameworks */ = { isa = PBXGroup; children = ( + 93A5A1DE2FF612AE0014C2B9 /* ImageCaptureCore.framework */, 454D6E9EC1585878877CF5FD /* Pods_suixinkan.framework */, EDE192531FA9E2FD908B9EC1 /* Pods_suixinkanTests.framework */, ); diff --git a/suixinkan/App/AppDelegate.swift b/suixinkan/App/AppDelegate.swift index 47f2f43..de776ec 100644 --- a/suixinkan/App/AppDelegate.swift +++ b/suixinkan/App/AppDelegate.swift @@ -87,5 +87,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { } } - func applicationWillTerminate(_ application: UIApplication) {} + func applicationWillTerminate(_ application: UIApplication) { + ConnectionManager.shared.disconnect() + } } diff --git a/suixinkan/Core/CameraOTG/Brands/Canon/CanonCameraDriver.swift b/suixinkan/Core/CameraOTG/Brands/Canon/CanonCameraDriver.swift new file mode 100644 index 0000000..9a811ef --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Canon/CanonCameraDriver.swift @@ -0,0 +1,59 @@ +// +// CanonCameraDriver.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +import ImageCaptureCore + +/// 佳能相机 Driver:ImageCaptureCore 枚举 SD 卡历史照片并下载。 +/// 边拍边传由 `CanonRemoteCaptureService` + EOS GetEvent 负责,与 catalog 扫描分离。 +final class CanonCameraDriver: CameraDriver { + private let device: ICCameraDevice + let platform: CameraPlatform = .canon + let deviceInfo: CameraDeviceInfo + + init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) { + self.device = device + self.deviceInfo = deviceInfo + OTGLog.info(.canon, "initialized, device=\(deviceInfo.name)") + } + + func connect() async throws { + OTGLog.debug(.canon, "connect called") + } + + func disconnect() { + OTGLog.info(.canon, "driver released, device=\(deviceInfo.name)") + } + + func listObjects() async throws -> [CameraObject] { + let mediaFilesCount = device.mediaFiles?.count ?? 0 + let contentsCount = device.contents?.count ?? 0 + let files = ICCameraItemScanner.collectImageFiles(from: device) + OTGLog.info(.canon, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)") + + let results = await ICCameraItemScanner.cameraObjects(from: files) + .sorted { $0.capturedAt > $1.capturedAt } + + OTGLog.info(.canon, "listObjects: image count=\(results.count)") + return results + } + + func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? { + await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize) + } + + func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL { + guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else { + OTGLog.error(.canon, "downloadObject file not found: \(object.filename)") + throw CameraError.deviceNotFound + } + + let url = try await ICCameraFileDownloader.download(file, to: directory) + OTGLog.info(.canon, "downloadObject saved: \(url.lastPathComponent)") + return url + } +} diff --git a/suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPCommands.swift b/suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPCommands.swift new file mode 100644 index 0000000..abc30f8 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPCommands.swift @@ -0,0 +1,82 @@ +// +// CanonPTPCommands.swift +// otg_swift +// + +import Foundation + +/// 佳能 EOS 扩展 PTP 命令码与 GetEvent 传图事件码。 +enum CanonPTPCommand { + /// 标准 GetObjectInfo + static let getObjectInfo: UInt16 = PTPStandardCommand.getObjectInfo + /// 标准 GetObject + static let getObject: UInt16 = PTPStandardCommand.getObject + /// EOS GetObject (0x9104) + static let eosGetObject: UInt16 = 0x9104 + /// EOS GetObjectInfoEx (0x9109) + static let eosGetObjectInfoEx: UInt16 = 0x9109 + /// EOS SetRemoteMode (0x9114) + static let setRemoteMode: UInt16 = 0x9114 + /// EOS SetEventMode (0x9115) + static let setEventMode: UInt16 = 0x9115 + /// EOS GetEvent (0x9116) + static let getEvent: UInt16 = 0x9116 + /// EOS TransferComplete (0x9117) + static let transferComplete: UInt16 = 0x9117 + /// EOS PCHDDCapacity (0x911A) + static let pchddCapacity: UInt16 = 0x911A + /// EOS KeepDeviceOn (0x911D) + static let keepDeviceOn: UInt16 = 0x911D + /// 标准 ObjectAdded 事件 + static let objectAdded: UInt16 = PTPStandardCommand.objectAdded + /// 标准 RequestObjectTransfer 事件 + static let requestObjectTransfer: UInt16 = PTPStandardCommand.requestObjectTransfer + /// EOS ObjectAddedEx (0xC181) + static let eosObjectAddedEx: UInt16 = 0xC181 + /// EOS ObjectInfoChangedEx (0xC187) + static let eosObjectInfoChangedEx: UInt16 = 0xC187 + /// EOS ObjectContentChanged (0xC188) + static let eosObjectContentChanged: UInt16 = 0xC188 + /// EOS RequestObjectTransfer (0xC186) + static let eosRequestObjectTransfer: UInt16 = 0xC186 + /// EOS RequestObjectTransferDT (0xC190) + static let eosRequestObjectTransferDT: UInt16 = 0xC190 + /// EOS RequestObjectTransferTS (0xC1A2) + static let eosRequestObjectTransferTS: UInt16 = 0xC1A2 + /// EOS RequestObjectTransferEx (0xC1A7),R8 等新机型快门后传图事件 + static let eosRequestObjectTransferEx: UInt16 = 0xC1A7 + /// EOS DevicePropChanged (0xC189) + static let eosDevicePropChanged: UInt16 = 0xC189 + /// EOS PropValueDescChanged (0xC18A) + static let eosPropValueDescChanged: UInt16 = 0xC18A + /// SetRemoteMode 参数:启用 + static let remoteModeEnabled: UInt32 = 1 + /// SetEventMode 参数:启用 + static let eventModeEnabled: UInt32 = 1 +} + +/// libgphoto2 `ptp-pack.c` 中 Canon EOS 事件记录字段偏移(相对单条 record 起点)。 +enum CanonEOSEventOffset { + static let recordCode = 0x04 + static let objectHandle = 0x08 + static let transferObjectSize = 0x14 + static let transferFilename = 0x1C + static let addedStorageID = 0x0C + static let addedObjectSize = 0x1C + static let addedFilename = 0x28 +} + +/// GetEvent 解析出的单条佳能 EOS 事件。 +struct CanonEOSEvent { + let code: UInt32 + let storageID: UInt32? + let objectHandle: UInt32? + let filename: String? + let objectSize: UInt32? +} + +/// Canon 远程会话初始化结果。 +struct CanonRemoteSessionResult { + let success: Bool + let nextTransactionID: UInt32 +} diff --git a/suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPHelper.swift b/suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPHelper.swift new file mode 100644 index 0000000..b3eadb5 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Canon/CanonPTPHelper.swift @@ -0,0 +1,369 @@ +// +// CanonPTPHelper.swift +// otg_swift +// + +import Foundation +import ImageCaptureCore + +/// 佳能 EOS PTP:远程会话、GetEvent 解析、Probe 与 GetObject 下载。 +enum CanonPTPHelper { + + /// 会话打开且 catalog 就绪后进入 Canon 远程拍摄模式。 + static func initializeRemoteSession( + on camera: ICCameraDevice, + startingTransactionID: UInt32 + ) async -> CanonRemoteSessionResult { + var transactionID = startingTransactionID + + OTGLog.info(.canon, "Canon EOS remote session initializing") + + let remoteMode = await sendCommand( + on: camera, + code: CanonPTPCommand.setRemoteMode, + transactionID: &transactionID, + parameters: [CanonPTPCommand.remoteModeEnabled], + label: "EOS SetRemoteMode" + ) + guard remoteMode.success else { + OTGLog.error(.canon, "SetRemoteMode failed") + return CanonRemoteSessionResult(success: false, nextTransactionID: transactionID) + } + + let eventMode = await sendCommand( + on: camera, + code: CanonPTPCommand.setEventMode, + transactionID: &transactionID, + parameters: [CanonPTPCommand.eventModeEnabled], + label: "EOS SetEventMode" + ) + guard eventMode.success else { + OTGLog.error(.canon, "SetEventMode failed") + return CanonRemoteSessionResult(success: false, nextTransactionID: transactionID) + } + + _ = await sendCommand( + on: camera, + code: CanonPTPCommand.pchddCapacity, + transactionID: &transactionID, + parameters: [0x0FFF_FFF8, 0x1000, 1], + label: "EOS PCHDDCapacity" + ) + + _ = await sendCommand( + on: camera, + code: CanonPTPCommand.keepDeviceOn, + transactionID: &transactionID, + parameters: [1], + label: "EOS KeepDeviceOn" + ) + + OTGLog.info(.canon, "Canon EOS remote session ready, transactionID=\(transactionID)") + return CanonRemoteSessionResult(success: true, nextTransactionID: transactionID) + } + + /// 轮询 GetEvent 并解析 EOS 事件列表。 + static func pollEvents( + on camera: ICCameraDevice, + transactionID: inout UInt32 + ) async -> [CanonEOSEvent] { + let result = await sendCommand( + on: camera, + code: CanonPTPCommand.getEvent, + transactionID: &transactionID, + parameters: [], + label: "EOS GetEvent" + ) + guard result.success, !result.inData.isEmpty else { return [] } + + let events = parseEOSEvents(result.inData) + if events.isEmpty, result.inData.count > 8 { + let hex = result.inData.prefix(64).map { String(format: "%02X", $0) }.joined(separator: " ") + OTGLog.debug(.canon, "GetEvent inData=\(result.inData.count)B, no events, raw=\(hex)") + } + for event in events where isTransferEvent(event) { + OTGLog.info( + .canon, + "transfer event 0x\(String(format: "%04X", event.code)) handle=0x\(String(format: "%08X", event.objectHandle ?? 0)) file=\(event.filename ?? "?") size=\(event.objectSize ?? 0)" + ) + } + return events + } + + /// 解析 GetEvent 返回的 EOS 事件(布局参考 libgphoto2 `ptp_unpack_EOS_events`)。 + static func parseEOSEvents(_ data: Data) -> [CanonEOSEvent] { + var events: [CanonEOSEvent] = [] + var offset = 0 + + while offset + 8 <= data.count { + let recordLen = Int(PTPHelper.readUInt32LE(data, offset: offset)) + let code = PTPHelper.readUInt32LE(data, offset: offset + CanonEOSEventOffset.recordCode) + + if recordLen == 8, code == 0 { break } + guard recordLen >= 8, offset + recordLen <= data.count else { break } + + events.append( + parseSingleEOSEvent( + data: data, + recordOffset: offset, + recordLen: recordLen, + code: code + ) + ) + + offset += recordLen + } + + return events + } + + /// 是否为应触发下载的传图事件。 + static func isTransferEvent(_ event: CanonEOSEvent) -> Bool { + guard let handle = event.objectHandle, handle != 0 else { return false } + switch event.code { + case UInt32(CanonPTPCommand.eosRequestObjectTransfer), + UInt32(CanonPTPCommand.eosRequestObjectTransferTS), + UInt32(CanonPTPCommand.eosRequestObjectTransferDT), + UInt32(CanonPTPCommand.eosRequestObjectTransferEx), + UInt32(CanonPTPCommand.eosObjectAddedEx), + UInt32(CanonPTPCommand.eosObjectInfoChangedEx), + UInt32(CanonPTPCommand.eosObjectContentChanged), + UInt32(CanonPTPCommand.objectAdded), + UInt32(CanonPTPCommand.requestObjectTransfer): + return true + default: + return false + } + } + + /// 读取对象元数据(文件名、压缩大小)。 + static func probeObject( + handle: UInt32, + storageID: UInt32?, + on camera: ICCameraDevice, + transactionID: inout UInt32 + ) async -> (filename: String, size: UInt32)? { + if let storageID, storageID != 0 { + let infoResult = await sendCommand( + on: camera, + code: CanonPTPCommand.eosGetObjectInfoEx, + transactionID: &transactionID, + parameters: [storageID, handle, 0], + label: "EOS GetObjectInfoEx(0x\(String(format: "%08X", handle)))" + ) + if infoResult.success, + let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 { + let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData) ?? defaultFilename() + return (filename, size) + } + } + + let infoResult = await sendCommand( + on: camera, + code: CanonPTPCommand.getObjectInfo, + transactionID: &transactionID, + parameters: [handle], + label: "GetObjectInfo(0x\(String(format: "%08X", handle)))" + ) + guard infoResult.success, + let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 else { + return nil + } + + let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData) ?? defaultFilename() + return (filename, size) + } + + /// 下载对象二进制;先试标准 GetObject,再试 EOS GetObject。 + static func downloadObject( + handle: UInt32, + storageID: UInt32?, + preferredFilename: String?, + on camera: ICCameraDevice, + transactionID: inout UInt32 + ) async -> (filename: String, data: Data)? { + var filename = preferredFilename ?? defaultFilename() + + if preferredFilename == nil, + let probe = await probeObject( + handle: handle, + storageID: storageID, + on: camera, + transactionID: &transactionID + ) { + filename = probe.filename + } + + let standardResult = await sendCommand( + on: camera, + code: CanonPTPCommand.getObject, + transactionID: &transactionID, + parameters: [handle], + label: "GetObject(0x\(String(format: "%08X", handle)))" + ) + if standardResult.success, PTPHelper.isLikelyImageData(standardResult.inData) { + return (filename, standardResult.inData) + } + + if let storageID, storageID != 0 { + let eosResult = await sendCommand( + on: camera, + code: CanonPTPCommand.eosGetObject, + transactionID: &transactionID, + parameters: [storageID, handle, 0], + label: "EOS GetObject(0x\(String(format: "%08X", handle)))" + ) + if eosResult.success, PTPHelper.isLikelyImageData(eosResult.inData) { + return (filename, eosResult.inData) + } + } + + return nil + } + + /// 传图完成后 ACK,避免后续事件卡住。 + static func acknowledgeTransfer( + handle: UInt32, + on camera: ICCameraDevice, + transactionID: inout UInt32 + ) async { + _ = await sendCommand( + on: camera, + code: CanonPTPCommand.transferComplete, + transactionID: &transactionID, + parameters: [handle], + label: "EOS TransferComplete(0x\(String(format: "%08X", handle)))" + ) + } + + /// 从被动 PTP Event Container 提取 object handle(ObjectAdded 等)。 + static func parsePTPEventHandle(_ eventData: Data) -> UInt32? { + guard let container = try? PTPHelper.parseContainer(eventData) else { return nil } + switch container.code { + case CanonPTPCommand.objectAdded, CanonPTPCommand.requestObjectTransfer: + return container.params.first + default: + return nil + } + } + + static func objectSignature(filename: String, size: UInt32) -> String { + PTPHelper.objectSignature(filename: filename, size: size) + } + + // MARK: - Private + + private static func parseSingleEOSEvent( + data: Data, + recordOffset: Int, + recordLen: Int, + code: UInt32 + ) -> CanonEOSEvent { + func u32(at absoluteOffset: Int) -> UInt32? { + guard absoluteOffset + 4 <= recordOffset + recordLen, absoluteOffset + 4 <= data.count else { return nil } + return PTPHelper.readUInt32LE(data, offset: absoluteOffset) + } + + func string(at absoluteOffset: Int) -> String? { + guard absoluteOffset < recordOffset + recordLen else { return nil } + guard let parsed = PTPHelper.parsePTPString(data, offset: absoluteOffset) else { return nil } + let sanitized = PTPHelper.sanitizeFilename(parsed.string) + return sanitized.isEmpty ? nil : sanitized + } + + switch code { + case UInt32(CanonPTPCommand.eosRequestObjectTransfer), + UInt32(CanonPTPCommand.eosRequestObjectTransferTS), + UInt32(CanonPTPCommand.eosRequestObjectTransferDT), + UInt32(CanonPTPCommand.eosRequestObjectTransferEx): + guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else { + return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil) + } + return CanonEOSEvent( + code: code, + storageID: nil, + objectHandle: handle, + filename: string(at: recordOffset + CanonEOSEventOffset.transferFilename), + objectSize: u32(at: recordOffset + CanonEOSEventOffset.transferObjectSize) + ) + + case UInt32(CanonPTPCommand.eosObjectAddedEx), + UInt32(CanonPTPCommand.eosObjectInfoChangedEx): + guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else { + return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil) + } + return CanonEOSEvent( + code: code, + storageID: u32(at: recordOffset + CanonEOSEventOffset.addedStorageID), + objectHandle: handle, + filename: string(at: recordOffset + CanonEOSEventOffset.addedFilename), + objectSize: u32(at: recordOffset + CanonEOSEventOffset.addedObjectSize) + ) + + case UInt32(CanonPTPCommand.eosObjectContentChanged): + guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else { + return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil) + } + return CanonEOSEvent(code: code, storageID: nil, objectHandle: handle, filename: nil, objectSize: nil) + + case UInt32(CanonPTPCommand.objectAdded), + UInt32(CanonPTPCommand.requestObjectTransfer): + guard let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle), handle != 0 else { + return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil) + } + return CanonEOSEvent(code: code, storageID: nil, objectHandle: handle, filename: nil, objectSize: nil) + + default: + if isNonTransferNoiseCode(code) { + return CanonEOSEvent(code: code, storageID: nil, objectHandle: nil, filename: nil, objectSize: nil) + } + let handle = u32(at: recordOffset + CanonEOSEventOffset.objectHandle) + if handle != nil { + OTGLog.debug( + .canon, + "EOS event 0x\(String(format: "%04X", code)) len=\(recordLen) handle=0x\(String(format: "%08X", handle ?? 0))" + ) + } + return CanonEOSEvent( + code: code, + storageID: u32(at: recordOffset + CanonEOSEventOffset.addedStorageID), + objectHandle: handle, + filename: string(at: recordOffset + CanonEOSEventOffset.addedFilename) + ?? string(at: recordOffset + CanonEOSEventOffset.transferFilename), + objectSize: u32(at: recordOffset + CanonEOSEventOffset.addedObjectSize) + ?? u32(at: recordOffset + CanonEOSEventOffset.transferObjectSize) + ) + } + } + + private static func isNonTransferNoiseCode(_ code: UInt32) -> Bool { + switch code { + case UInt32(CanonPTPCommand.eosDevicePropChanged), + UInt32(CanonPTPCommand.eosPropValueDescChanged), + 0xC18B, 0xC185, 0xC19D, 0xC1F6, 0xC1A4: + return true + default: + return false + } + } + + private static func defaultFilename() -> String { + PTPHelper.sanitizeFilename("IMG_\(Int(Date().timeIntervalSince1970)).JPG") + } + + private static func sendCommand( + on camera: ICCameraDevice, + code: UInt16, + transactionID: inout UInt32, + parameters: [UInt32], + label: String + ) async -> PTPHelper.CommandResult { + await PTPHelper.sendCommand( + on: camera, + code: code, + transactionID: &transactionID, + parameters: parameters, + label: label + ) + } +} diff --git a/suixinkan/Core/CameraOTG/Brands/Canon/CanonRemoteCaptureService.swift b/suixinkan/Core/CameraOTG/Brands/Canon/CanonRemoteCaptureService.swift new file mode 100644 index 0000000..e262efe --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Canon/CanonRemoteCaptureService.swift @@ -0,0 +1,448 @@ +// +// CanonRemoteCaptureService.swift +// otg_swift +// + +import Foundation +import ImageCaptureCore + +/// 佳能 EOS 边拍边传:GetEvent 轮询 + PTP 下载 + TransferComplete + 写入相册。 +final class CanonRemoteCaptureService { + + private weak var camera: ICCameraDevice? + private var transactionID: UInt32 = 1 + private(set) var isReady = false + + private weak var photoSink: OTGPhotoSink? + /// 下载成功时回调,供 ConnectionManager 转发给 ViewModel。 + var onShotSaved: ((SavedOTGPhoto) -> Void)? + + private var eventPollTimer: Timer? + private var catalogPollTimer: Timer? + private var isDownloading = false + private var isDrainingQueue = false + private var isPollingGetEvent = false + + private var lastDownloadedSignature: String? + private var knownDownloadedHandles: Set = [] + private var knownCatalogIDs: Set = [] + private var catalogBaselineEstablished = false + private var savedShotCount = 0 + + private var downloadQueue: [PendingDownload] = [] + + private struct PendingDownload { + let handle: UInt32 + let storageID: UInt32? + let filename: String? + let objectSize: UInt32? + let reason: String + } + + private let eventPollInterval: TimeInterval = 0.2 + private let catalogPollInterval: TimeInterval = 1.5 + private var pollTickCount = 0 + private var catalogScanCount = 0 + + + // MARK: - Lifecycle + + /// catalog 就绪且远程会话初始化成功后启动。 + func start( + camera: ICCameraDevice, + startingTransactionID: UInt32, + photoSink: OTGPhotoSink? + ) { + stop() + + self.camera = camera + self.transactionID = startingTransactionID + self.photoSink = photoSink + self.isReady = true + self.lastDownloadedSignature = nil + self.knownDownloadedHandles = [] + self.knownCatalogIDs = [] + self.catalogBaselineEstablished = false + self.savedShotCount = 0 + self.downloadQueue = [] + self.pollTickCount = 0 + self.isPollingGetEvent = false + + establishCatalogBaseline(from: camera) + + OTGLog.info(.canon, "Remote capture started, transactionID=\(startingTransactionID)") + startEventPolling() + startCatalogPolling() + } + + func stop() { + isReady = false + if Thread.isMainThread { + stopEventPolling() + stopCatalogPolling() + } else { + DispatchQueue.main.sync { + stopEventPolling() + stopCatalogPolling() + } + } + isDownloading = false + isDrainingQueue = false + isPollingGetEvent = false + downloadQueue = [] + knownCatalogIDs = [] + catalogBaselineEstablished = false + photoSink = nil + camera = nil + } + + // MARK: - Catalog Fallback + + /// ImageCaptureCore 在 catalog 就绪后新增的文件(仅保存到 SD 卡时 GetEvent 可能无传图事件)。 + func handleCatalogItemsAdded(_ items: [ICCameraItem]) { + guard isReady, catalogBaselineEstablished else { return } + + for item in items { + guard let file = item as? ICCameraFile else { continue } + Task { await downloadCatalogFileIfNew(file, reason: "didAdd") } + } + } + + private func establishCatalogBaseline(from camera: ICCameraDevice) { + var known = Set() + var newDuringConnect = 0 + + for file in ICCameraItemScanner.collectImageFiles(from: camera) { + if file.wasAddedAfterContentCatalogCompleted { + newDuringConnect += 1 + Task { await downloadCatalogFileIfNew(file, reason: "baseline-new") } + } else { + known.insert(ICCameraItemScanner.cameraObjectID(for: file)) + } + } + + knownCatalogIDs = known + catalogBaselineEstablished = true + OTGLog.info(.canon, "Catalog baseline: known=\(known.count), newDuringConnect=\(newDuringConnect)") + } + + private func scanCatalogForNewFiles() async { + guard isReady, catalogBaselineEstablished, let camera else { return } + + let files = ICCameraItemScanner.collectImageFiles(from: camera) + catalogScanCount += 1 + if catalogScanCount == 1 || catalogScanCount % 10 == 0 { + OTGLog.debug(.canon, "Catalog scan #\(catalogScanCount): files=\(files.count), known=\(knownCatalogIDs.count)") + } + + for file in files { + await downloadCatalogFileIfNew(file, reason: "catalog-poll") + } + } + + private func downloadCatalogFileIfNew(_ file: ICCameraFile, reason: String) async { + guard isReady else { return } + + let objectID = ICCameraItemScanner.cameraObjectID(for: file) + guard !knownCatalogIDs.contains(objectID) else { return } + knownCatalogIDs.insert(objectID) + + if isDownloading { + knownCatalogIDs.remove(objectID) + return + } + + isDownloading = true + defer { isDownloading = false } + + let filename = file.name ?? UUID().uuidString + if objectID == lastDownloadedSignature { + return + } + + OTGLog.info(.canon, "\(reason): downloading \(filename) id=\(objectID)") + + do { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("canon-live", isDirectory: true) + let url = try await ICCameraFileDownloader.download(file, to: tempDir) + let data = try Data(contentsOf: url) + try? FileManager.default.removeItem(at: url) + + lastDownloadedSignature = objectID + + guard let saved = await saveToAlbum(data: data, filename: filename, reason: reason) else { + knownCatalogIDs.remove(objectID) + return + } + + savedShotCount += 1 + OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)") + onShotSaved?(saved) + } catch { + knownCatalogIDs.remove(objectID) + OTGLog.error(.canon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)") + } + } + + // MARK: - PTP Events + + /// 处理 ImageCaptureCore 被动 PTP 事件(ObjectAdded 等)。 + func handlePTPEvent(_ eventData: Data) { + guard isReady else { return } + + let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ") + OTGLog.info(.canon, "PTP event received: \(hex) (\(eventData.count)B)") + + if let handle = CanonPTPHelper.parsePTPEventHandle(eventData), handle != 0 { + enqueueDownload( + handle: handle, + storageID: nil, + filename: nil, + objectSize: nil, + reason: "PTPEvent" + ) + } + } + + // MARK: - GetEvent Polling + + private func startEventPolling() { + let schedule = { [weak self] in + guard let self else { return } + self.stopEventPolling() + OTGLog.info(.canon, "GetEvent polling started (interval=\(self.eventPollInterval)s)") + + let timer = Timer(timeInterval: self.eventPollInterval, repeats: true) { [weak self] _ in + guard let self, self.isReady else { return } + Task { await self.pollCanonEvents() } + } + RunLoop.main.add(timer, forMode: .common) + self.eventPollTimer = timer + } + + if Thread.isMainThread { + schedule() + } else { + DispatchQueue.main.async(execute: schedule) + } + } + + private func stopEventPolling() { + eventPollTimer?.invalidate() + eventPollTimer = nil + } + + private func startCatalogPolling() { + let schedule = { [weak self] in + guard let self else { return } + self.stopCatalogPolling() + OTGLog.info(.canon, "Catalog polling started (interval=\(self.catalogPollInterval)s)") + + let timer = Timer(timeInterval: self.catalogPollInterval, repeats: true) { [weak self] _ in + guard let self, self.isReady else { return } + Task { await self.scanCatalogForNewFiles() } + } + RunLoop.main.add(timer, forMode: .common) + self.catalogPollTimer = timer + } + + if Thread.isMainThread { + schedule() + } else { + DispatchQueue.main.async(execute: schedule) + } + } + + private func stopCatalogPolling() { + catalogPollTimer?.invalidate() + catalogPollTimer = nil + } + + private func pollCanonEvents() async { + guard isReady, let camera else { return } + guard !isDownloading else { return } + guard !isPollingGetEvent else { return } + + isPollingGetEvent = true + defer { isPollingGetEvent = false } + + pollTickCount += 1 + + var localTransactionID = transactionID + let events = await CanonPTPHelper.pollEvents(on: camera, transactionID: &localTransactionID) + transactionID = localTransactionID + + if pollTickCount == 1 || pollTickCount % 25 == 0 { + OTGLog.debug(.canon, "GetEvent poll #\(pollTickCount), parsedEvents=\(events.count)") + } + + for event in events where CanonPTPHelper.isTransferEvent(event) { + guard let handle = event.objectHandle else { continue } + enqueueDownload( + handle: handle, + storageID: event.storageID, + filename: event.filename, + objectSize: event.objectSize, + reason: "GetEvent-0x\(String(format: "%04X", event.code))" + ) + } + } + + // MARK: - Download Queue + + private func enqueueDownload( + handle: UInt32, + storageID: UInt32?, + filename: String?, + objectSize: UInt32?, + reason: String + ) { + if knownDownloadedHandles.contains(handle) { return } + + downloadQueue.append( + PendingDownload( + handle: handle, + storageID: storageID, + filename: filename, + objectSize: objectSize, + reason: reason + ) + ) + OTGLog.info( + .canon, + "\(reason): enqueued handle=0x\(String(format: "%08X", handle)) file=\(filename ?? "?") queue=\(downloadQueue.count)" + ) + startDrainIfNeeded() + } + + private func startDrainIfNeeded() { + guard !isDrainingQueue, !downloadQueue.isEmpty else { return } + isDrainingQueue = true + Task { await drainDownloadQueue() } + } + + private func drainDownloadQueue() async { + defer { + isDrainingQueue = false + if !downloadQueue.isEmpty { + startDrainIfNeeded() + } + } + + while !downloadQueue.isEmpty { + let item = downloadQueue.removeFirst() + _ = await downloadAndSave(item: item, maxAttempts: 6) + try? await Task.sleep(nanoseconds: 100_000_000) + } + } + + @discardableResult + private func downloadAndSave(item: PendingDownload, maxAttempts: Int) async -> Bool { + guard isReady, let camera else { return false } + + if knownDownloadedHandles.contains(item.handle) { return false } + + if isDownloading { + downloadQueue.insert(item, at: 0) + return false + } + + isDownloading = true + defer { isDownloading = false } + + var localTransactionID = transactionID + + for attempt in 1...maxAttempts { + if attempt > 1 { + try? await Task.sleep(nanoseconds: UInt64(attempt) * 300_000_000) + } + + var filename = item.filename + var resolvedSize = item.objectSize ?? 0 + + if filename == nil || resolvedSize == 0 { + if let probe = await CanonPTPHelper.probeObject( + handle: item.handle, + storageID: item.storageID, + on: camera, + transactionID: &localTransactionID + ) { + if filename == nil { filename = probe.filename } + if resolvedSize == 0 { resolvedSize = probe.size } + } else if filename == nil, resolvedSize == 0 { + if attempt == maxAttempts { + OTGLog.warning(.canon, "\(item.reason): object not readable handle=0x\(String(format: "%08X", item.handle))") + } + continue + } + } + + let resolvedFilename = filename ?? PTPHelper.sanitizeFilename("IMG_\(item.handle).JPG") + let signature = resolvedSize > 0 + ? CanonPTPHelper.objectSignature(filename: resolvedFilename, size: resolvedSize) + : "\(resolvedFilename)_handle_\(item.handle)" + + if signature == lastDownloadedSignature { + knownDownloadedHandles.insert(item.handle) + return false + } + + guard let result = await CanonPTPHelper.downloadObject( + handle: item.handle, + storageID: item.storageID, + preferredFilename: filename, + on: camera, + transactionID: &localTransactionID + ) else { + if attempt == maxAttempts { + OTGLog.error(.canon, "\(item.reason): download failed handle=0x\(String(format: "%08X", item.handle))") + } + continue + } + + transactionID = localTransactionID + + await CanonPTPHelper.acknowledgeTransfer( + handle: item.handle, + on: camera, + transactionID: &localTransactionID + ) + transactionID = localTransactionID + + lastDownloadedSignature = signature + knownDownloadedHandles.insert(item.handle) + + guard let saved = await saveToAlbum(data: result.data, filename: result.filename, reason: item.reason) else { + return false + } + + savedShotCount += 1 + OTGLog.info(.canon, "Live shot saved (#\(savedShotCount)): \(result.filename), bytes=\(result.data.count)") + onShotSaved?(saved) + return true + } + + return false + } + + private func saveToAlbum(data: Data, filename: String, reason: String) async -> SavedOTGPhoto? { + guard let photoSink else { + OTGLog.warning(.canon, "\(reason): live transfer sink not configured, skip save") + return nil + } + + let objectID = ICCameraItemScanner.cameraObjectID(filename: filename, fileSize: Int64(data.count)) + do { + return try await photoSink.saveLiveShot( + data: data, + filename: filename, + capturedAt: Date(), + cameraObjectID: objectID + ) + } catch { + OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)") + return nil + } + } +} diff --git a/suixinkan/Core/CameraOTG/Brands/Nikon/NikonCameraDriver.swift b/suixinkan/Core/CameraOTG/Brands/Nikon/NikonCameraDriver.swift new file mode 100644 index 0000000..acbea02 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Nikon/NikonCameraDriver.swift @@ -0,0 +1,58 @@ +// +// NikonCameraDriver.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +import ImageCaptureCore + +/// 尼康相机 Driver:ImageCaptureCore 枚举 SD 卡历史照片并下载(含 NEF/JPEG 等)。 +final class NikonCameraDriver: CameraDriver { + private let device: ICCameraDevice + let platform: CameraPlatform = .nikon + let deviceInfo: CameraDeviceInfo + + init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) { + self.device = device + self.deviceInfo = deviceInfo + OTGLog.info(.nikon, "initialized, device=\(deviceInfo.name)") + } + + func connect() async throws { + OTGLog.debug(.nikon, "connect called") + } + + func disconnect() { + OTGLog.info(.nikon, "driver released, device=\(deviceInfo.name)") + } + + func listObjects() async throws -> [CameraObject] { + let mediaFilesCount = device.mediaFiles?.count ?? 0 + let contentsCount = device.contents?.count ?? 0 + let files = ICCameraItemScanner.collectImageFiles(from: device) + OTGLog.info(.nikon, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)") + + let results = await ICCameraItemScanner.cameraObjects(from: files) + .sorted { $0.capturedAt > $1.capturedAt } + + OTGLog.info(.nikon, "listObjects: image count=\(results.count)") + return results + } + + func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? { + await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize) + } + + func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL { + guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else { + OTGLog.error(.nikon, "downloadObject file not found: \(object.filename)") + throw CameraError.deviceNotFound + } + + let url = try await ICCameraFileDownloader.download(file, to: directory) + OTGLog.info(.nikon, "downloadObject saved: \(url.lastPathComponent)") + return url + } +} diff --git a/suixinkan/Core/CameraOTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift b/suixinkan/Core/CameraOTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift new file mode 100644 index 0000000..7de34f4 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift @@ -0,0 +1,199 @@ +// +// NikonCatalogLiveCaptureService.swift +// otg_swift +// + +import Foundation +import ImageCaptureCore + +/// 尼康边拍边传:监听 ImageCaptureCore catalog 新增文件并下载写入相册。 +/// Z6 系列等在快门后会触发 `didAdd`,无需 Nikon 专用 PTP 协议。 +final class NikonCatalogLiveCaptureService { + + private weak var camera: ICCameraDevice? + private(set) var isReady = false + + private weak var photoSink: OTGPhotoSink? + var onShotSaved: ((SavedOTGPhoto) -> Void)? + + private var catalogPollTimer: Timer? + private var isDownloading = false + + private var lastDownloadedSignature: String? + private var knownCatalogIDs: Set = [] + private var catalogBaselineEstablished = false + private var savedShotCount = 0 + + private let catalogPollInterval: TimeInterval = 1.5 + private var catalogScanCount = 0 + + func start(camera: ICCameraDevice, photoSink: OTGPhotoSink?) { + stop() + + self.camera = camera + self.photoSink = photoSink + self.isReady = true + self.lastDownloadedSignature = nil + self.knownCatalogIDs = [] + self.catalogBaselineEstablished = false + self.savedShotCount = 0 + self.catalogScanCount = 0 + + establishCatalogBaseline(from: camera) + + OTGLog.info(.nikon, "Catalog live capture started") + startCatalogPolling() + } + + func stop() { + isReady = false + if Thread.isMainThread { + stopCatalogPolling() + } else { + DispatchQueue.main.sync { stopCatalogPolling() } + } + isDownloading = false + knownCatalogIDs = [] + catalogBaselineEstablished = false + photoSink = nil + camera = nil + } + + // MARK: - Catalog + + func handleCatalogItemsAdded(_ items: [ICCameraItem]) { + guard isReady, catalogBaselineEstablished else { return } + + for item in items { + guard let file = item as? ICCameraFile else { continue } + Task { await downloadCatalogFileIfNew(file, reason: "didAdd") } + } + } + + private func establishCatalogBaseline(from camera: ICCameraDevice) { + var known = Set() + var newDuringConnect = 0 + + for file in ICCameraItemScanner.collectImageFiles(from: camera) { + if file.wasAddedAfterContentCatalogCompleted { + newDuringConnect += 1 + Task { await downloadCatalogFileIfNew(file, reason: "baseline-new") } + } else { + known.insert(ICCameraItemScanner.cameraObjectID(for: file)) + } + } + + knownCatalogIDs = known + catalogBaselineEstablished = true + OTGLog.info(.nikon, "Catalog baseline: known=\(known.count), newDuringConnect=\(newDuringConnect)") + } + + private func scanCatalogForNewFiles() async { + guard isReady, catalogBaselineEstablished, let camera else { return } + + let files = ICCameraItemScanner.collectImageFiles(from: camera) + catalogScanCount += 1 + if catalogScanCount == 1 || catalogScanCount % 10 == 0 { + OTGLog.debug(.nikon, "Catalog scan #\(catalogScanCount): files=\(files.count), known=\(knownCatalogIDs.count)") + } + + for file in files { + await downloadCatalogFileIfNew(file, reason: "catalog-poll") + } + } + + private func downloadCatalogFileIfNew(_ file: ICCameraFile, reason: String) async { + guard isReady else { return } + + let objectID = ICCameraItemScanner.cameraObjectID(for: file) + guard !knownCatalogIDs.contains(objectID) else { return } + knownCatalogIDs.insert(objectID) + + if isDownloading { + knownCatalogIDs.remove(objectID) + return + } + + isDownloading = true + defer { isDownloading = false } + + let filename = file.name ?? UUID().uuidString + if objectID == lastDownloadedSignature { + return + } + + OTGLog.info(.nikon, "\(reason): downloading \(filename) id=\(objectID)") + + do { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("nikon-live", isDirectory: true) + let url = try await ICCameraFileDownloader.download(file, to: tempDir) + let data = try Data(contentsOf: url) + try? FileManager.default.removeItem(at: url) + + lastDownloadedSignature = objectID + + guard let saved = await saveToAlbum(data: data, filename: filename, reason: reason) else { + knownCatalogIDs.remove(objectID) + return + } + + savedShotCount += 1 + OTGLog.info(.nikon, "Live shot saved (#\(savedShotCount)): \(filename), bytes=\(data.count)") + onShotSaved?(saved) + } catch { + knownCatalogIDs.remove(objectID) + OTGLog.error(.nikon, "\(reason): catalog download failed \(filename): \(error.localizedDescription)") + } + } + + // MARK: - Polling + + private func startCatalogPolling() { + let schedule = { [weak self] in + guard let self else { return } + self.stopCatalogPolling() + OTGLog.info(.nikon, "Catalog polling started (interval=\(self.catalogPollInterval)s)") + + let timer = Timer(timeInterval: self.catalogPollInterval, repeats: true) { [weak self] _ in + guard let self, self.isReady else { return } + Task { await self.scanCatalogForNewFiles() } + } + RunLoop.main.add(timer, forMode: .common) + self.catalogPollTimer = timer + } + + if Thread.isMainThread { + schedule() + } else { + DispatchQueue.main.async(execute: schedule) + } + } + + private func stopCatalogPolling() { + catalogPollTimer?.invalidate() + catalogPollTimer = nil + } + + // MARK: - Save + + private func saveToAlbum(data: Data, filename: String, reason: String) async -> SavedOTGPhoto? { + guard let photoSink else { + OTGLog.warning(.nikon, "\(reason): live transfer sink not configured, skip save") + return nil + } + + let objectID = ICCameraItemScanner.cameraObjectID(filename: filename, fileSize: Int64(data.count)) + do { + return try await photoSink.saveLiveShot( + data: data, + filename: filename, + capturedAt: Date(), + cameraObjectID: objectID + ) + } catch { + OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)") + return nil + } + } +} diff --git a/suixinkan/Core/CameraOTG/Brands/Sony/SonyCameraDriver.swift b/suixinkan/Core/CameraOTG/Brands/Sony/SonyCameraDriver.swift new file mode 100644 index 0000000..2c5c079 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Sony/SonyCameraDriver.swift @@ -0,0 +1,61 @@ +// +// SonyCameraDriver.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +import ImageCaptureCore + +/// 索尼相机 **MTP 历史导入** 路径的 Driver 实现。 +/// PC Remote 边拍边传由 `SonyRemoteCaptureService` + PTP 负责,与本 Driver 的 catalog 扫描分离。 +/// 索尼在 PC Remote 模式下 catalog 常为空,需提示用户切换 MTP(见 AGENTS.md)。 +final class SonyCameraDriver: CameraDriver { + private let device: ICCameraDevice + let platform: CameraPlatform = .sony + let deviceInfo: CameraDeviceInfo + + init(device: ICCameraDevice, deviceInfo: CameraDeviceInfo) { + self.device = device + self.deviceInfo = deviceInfo + OTGLog.info(.sony, "initialized, device=\(deviceInfo.name)") + } + + func connect() async throws { + OTGLog.debug(.sony, "connect called") + } + + func disconnect() { + OTGLog.info(.sony, "driver released, device=\(deviceInfo.name)") + } + + /// 扫描 `mediaFiles` / `contents` 树;PC Remote 模式下可能返回空列表。 + func listObjects() async throws -> [CameraObject] { + let mediaFilesCount = device.mediaFiles?.count ?? 0 + let contentsCount = device.contents?.count ?? 0 + let files = ICCameraItemScanner.collectImageFiles(from: device) + OTGLog.info(.sony, "listObjects: mediaFiles=\(mediaFilesCount), contents=\(contentsCount), images=\(files.count)") + + let results = await ICCameraItemScanner.cameraObjects(from: files) + .sorted { $0.capturedAt > $1.capturedAt } + + OTGLog.info(.sony, "listObjects: image count=\(results.count)") + return results + } + + func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? { + await ICCameraThumbnailLoader.loadThumbnailData(for: object, from: device, maxPixelSize: maxPixelSize) + } + + func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL { + guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else { + OTGLog.error(.sony, "downloadObject file not found: \(object.filename)") + throw CameraError.deviceNotFound + } + + let url = try await ICCameraFileDownloader.download(file, to: directory) + OTGLog.info(.sony, "downloadObject saved: \(url.lastPathComponent)") + return url + } +} diff --git a/suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPCommands.swift b/suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPCommands.swift new file mode 100644 index 0000000..ef4a330 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPCommands.swift @@ -0,0 +1,51 @@ +// +// SonyPTPCommands.swift +// otg_swift +// +// Created by hanqiu on 2026/7/3. +// + +import Foundation + +/// 索尼 SDIO / SDIE 扩展 PTP 命令码、属性码与事件码常量。 +enum SonyPTPCommand { + // MARK: - SDIO 命令 + + /// SDIO Connect (0x9201),握手阶段多次调用,参数区分阶段 + static let sdioConnect: UInt16 = 0x9201 + /// GetExtDeviceInfo (0x9202),获取扩展设备能力 + static let sdioGetExtDeviceInfo: UInt16 = 0x9202 + /// SetExtDevicePropValue (0x9205),写入单个扩展属性 + static let sdioSetExtDevicePropValue: UInt16 = 0x9205 + /// GetAllExtDevicePropInfo (0x9209),读取全部或 diff 扩展属性 + static let sdioGetAllExtDevicePropInfo: UInt16 = 0x9209 + + // MARK: - 扩展设备属性 + + /// Position_Key_Setting (0xD25A),设为 PC Remote 模式 + static let positionKeySetting: UInt16 = 0xD25A + /// Still_Image_Save_Destination (0xD222),保存目标含 Host PC + static let stillImageSaveDestination: UInt16 = 0xD222 + /// Still_Image_Trans_Size (0xD268),传输尺寸(0xFFFFFFFF = 原图) + static let stillImageTransSize: UInt16 = 0xD268 + /// Shooting_File_Info (0xD215);值 > 0x8000 表示 PC Remote 缓冲区可能有新片 + static let shootingFileInfo: UInt16 = 0xD215 + + /// PC Remote 虚拟对象句柄,GetObjectInfo/GetObject 用于 Probe 与下载 + static let shotObjectHandle: UInt32 = 0xFFFF_C001 + /// 静态影像保存到 Host PC 的属性枚举值 + static let saveDestinationHostPC: UInt16 = 1 + /// D215 超过此阈值视为 shot buffer 就绪 + static let shotBufferReadyThreshold: Int64 = 0x8000 + + // MARK: - PTP Events (Sony SDIE) + + /// ObjectAdded (0xC201),虚拟对象已加入设备侧 catalog + static let sdieObjectAdded: UInt16 = 0xC201 + /// ObjectRemoved (0xC202),对象从 catalog 移除 + static let sdieObjectRemoved: UInt16 = 0xC202 + /// DevicePropChanged (0xC203);ZV-E10 快门后常见,需配合 D215/Probe + static let sdieDevicePropChanged: UInt16 = 0xC203 + /// CapturedEvent (0xC206),拍摄完成事件 + static let sdieCapturedEvent: UInt16 = 0xC206 +} diff --git a/suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPHelper.swift b/suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPHelper.swift new file mode 100644 index 0000000..fcaf5f2 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Brands/Sony/SonyPTPHelper.swift @@ -0,0 +1,422 @@ +// +// SonyPTPHelper.swift +// otg_swift +// +// Created by hanqiu on 2026/7/3. +// + +import Foundation +import ImageCaptureCore + +/// 索尼 PC Remote(SDIO)握手、属性解析与边拍边传 Probe 的 PTP 封装。 +enum SonyPTPHelper { + + /// SDIO 握手结果;`nextTransactionID` 供后续 PTP 命令递增使用。 + struct RemoteSessionResult { + let success: Bool + let nextTransactionID: UInt32 + } + + private static func sendCommand( + on camera: ICCameraDevice, + code: UInt16, + transactionID: inout UInt32, + parameters: [UInt32], + outData: Data?, + label: String + ) async -> PTPHelper.CommandResult { + await PTPHelper.sendCommand( + on: camera, + code: code, + transactionID: &transactionID, + parameters: parameters, + outData: outData, + label: label + ) + } + + private static func setDeviceProperty( + on camera: ICCameraDevice, + propertyCode: UInt16, + value: UInt16, + transactionID: inout UInt32, + label: String + ) async -> Bool { + var payload = Data() + PTPHelper.appendUInt16LE(value, to: &payload) + + return await sendCommand( + on: camera, + code: SonyPTPCommand.sdioSetExtDevicePropValue, + transactionID: &transactionID, + parameters: [UInt32(propertyCode), 1], + outData: payload, + label: label + ).success + } + + private static func setDevicePropertyUInt32( + on camera: ICCameraDevice, + propertyCode: UInt16, + value: UInt32, + transactionID: inout UInt32, + label: String + ) async -> Bool { + var payload = Data() + PTPHelper.appendUInt32LE(value, to: &payload) + + return await sendCommand( + on: camera, + code: SonyPTPCommand.sdioSetExtDevicePropValue, + transactionID: &transactionID, + parameters: [UInt32(propertyCode), 1], + outData: payload, + label: label + ).success + } + + /// 配置 PC Remote 保存目标与传输尺寸(D25A / D222 / D268)。 + private static func configureRemoteCapture( + on camera: ICCameraDevice, + transactionID: inout UInt32 + ) async -> Bool { + guard await setDeviceProperty( + on: camera, + propertyCode: SonyPTPCommand.positionKeySetting, + value: 1, + transactionID: &transactionID, + label: "Position_Key_Setting=PC Remote" + ) else { return false } + + guard await setDeviceProperty( + on: camera, + propertyCode: SonyPTPCommand.stillImageSaveDestination, + value: SonyPTPCommand.saveDestinationHostPC, + transactionID: &transactionID, + label: "Still_Image_Save_Destination=Host PC" + ) else { return false } + + return await setDevicePropertyUInt32( + on: camera, + propertyCode: SonyPTPCommand.stillImageTransSize, + value: 0xFFFF_FFFF, + transactionID: &transactionID, + label: "Still_Image_Trans_Size=Full" + ) + } + + /// 完整 SDIO 握手:Connect×3 → GetExtDeviceInfo → GetAllExtDevicePropInfo → 远程拍摄属性。 + static func initializeRemoteSession( + on camera: ICCameraDevice, + startingTransactionID: UInt32 + ) async -> RemoteSessionResult { + var transactionID = startingTransactionID + + OTGLog.info(.sony, "SDIO handshake starting (phase 1: steps 1-5)") + + // Phase 1:标准 SDIO 连接序列(与 libgphoto2 / my_otg 一致) + let steps: [(UInt16, [UInt32], String)] = [ + (SonyPTPCommand.sdioConnect, [1, 0, 0], "SDIO Connect(1)"), + (SonyPTPCommand.sdioConnect, [2, 0, 0], "SDIO Connect(2)"), + (SonyPTPCommand.sdioGetExtDeviceInfo, [0x012C, 1], "SDIO GetExtDeviceInfo"), + (SonyPTPCommand.sdioConnect, [3, 0, 0], "SDIO Connect(3)"), + (SonyPTPCommand.sdioGetAllExtDevicePropInfo, [1], "SDIO GetAllExtDevicePropInfo"), + ] + + for (code, parameters, label) in steps { + let result = await sendCommand( + on: camera, + code: code, + transactionID: &transactionID, + parameters: parameters, + outData: nil, + label: label + ) + guard result.success else { + OTGLog.error(.sony, "SDIO failed at: \(label)") + return RemoteSessionResult(success: false, nextTransactionID: transactionID) + } + } + + OTGLog.info(.sony, "SDIO phase 1 OK, nextTransactionID=\(transactionID)") + + let configured = await configureRemoteCapture(on: camera, transactionID: &transactionID) + if configured { + OTGLog.info(.sony, "SDIO handshake completed, nextTransactionID=\(transactionID)") + } else { + OTGLog.error(.sony, "SDIO handshake failed at remote capture configuration") + } + + return RemoteSessionResult(success: configured, nextTransactionID: transactionID) + } + + // MARK: - PTP Events + + static func describeEvent(_ eventData: Data) -> String { + guard let code = PTPHelper.parseEventCode(from: eventData) else { + return "unknown, hex=\(PTPHelper.hexString(eventData.prefix(16)))" + } + + let name: String + switch code { + case SonyPTPCommand.sdieObjectAdded: + name = "ObjectAdded" + case SonyPTPCommand.sdieObjectRemoved: + name = "ObjectRemoved" + case SonyPTPCommand.sdieDevicePropChanged: + name = "DevicePropChanged" + case SonyPTPCommand.sdieCapturedEvent: + name = "CapturedEvent" + default: + name = "Other" + } + + if let container = try? PTPHelper.parseContainer(eventData), !container.params.isEmpty { + let params = container.params + .map { String(format: "0x%08X", $0) } + .joined(separator: ", ") + return "\(name) (0x\(String(format: "%04X", code))), params=[\(params)]" + } + + return "\(name) (0x\(String(format: "%04X", code)))" + } + + // MARK: - Shot detection + + /// Probe 成功时返回的待下载片信息(尚未 GetObject)。 + struct PendingShot { + let filename: String + let size: UInt32 + let handle: UInt32 + } + + /// 对虚拟句柄发 GetObjectInfo,解析文件名与 CompressedSize。 + static func probeShotObject( + on camera: ICCameraDevice, + handle: UInt32 = SonyPTPCommand.shotObjectHandle, + transactionID: inout UInt32 + ) async -> PendingShot? { + let infoResult = await sendCommand( + on: camera, + code: PTPStandardCommand.getObjectInfo, + transactionID: &transactionID, + parameters: [handle], + outData: nil, + label: "ProbeObjectInfo(0x\(String(format: "%08X", handle)))" + ) + guard infoResult.success else { return nil } + + guard let size = PTPHelper.parseObjectCompressedSize(infoResult.inData), size > 0 else { + return nil + } + + let filename = PTPHelper.parseObjectInfoFilename(infoResult.inData) + ?? "DSC_\(Int(Date().timeIntervalSince1970)).JPG" + + return PendingShot(filename: filename, size: size, handle: handle) + } + + /// GetObject 下载结果。 + struct DownloadedShot { + let filename: String + let data: Data + let handle: UInt32 + } + + /// 对虚拟句柄发 GetObject,返回 JPEG/RAW 二进制;Probe 后可 `skipObjectInfo` 省略重复 GetObjectInfo。 + static func downloadShotObject( + on camera: ICCameraDevice, + handle: UInt32 = SonyPTPCommand.shotObjectHandle, + transactionID: inout UInt32, + knownFilename: String? = nil, + skipObjectInfo: Bool = false + ) async -> DownloadedShot? { + var filename = PTPHelper.sanitizeFilename( + knownFilename ?? "DSC_\(Int(Date().timeIntervalSince1970)).JPG" + ) + + if !skipObjectInfo { + let infoResult = await sendCommand( + on: camera, + code: PTPStandardCommand.getObjectInfo, + transactionID: &transactionID, + parameters: [handle], + outData: nil, + label: "GetObjectInfo(0x\(String(format: "%08X", handle)))" + ) + if infoResult.success, let parsedName = PTPHelper.parseObjectInfoFilename(infoResult.inData) { + filename = parsedName + } + } + + let objectResult = await sendCommand( + on: camera, + code: PTPStandardCommand.getObject, + transactionID: &transactionID, + parameters: [handle], + outData: nil, + label: "GetObject(0x\(String(format: "%08X", handle)))" + ) + guard objectResult.success, PTPHelper.isLikelyImageData(objectResult.inData) else { + return nil + } + + return DownloadedShot(filename: filename, data: objectResult.inData, handle: handle) + } + + /// 读取 SDIO 扩展属性;`onlyDiff=true` 仅返回自上次读取以来变化的属性。 + static func fetchExtDeviceProperties( + on camera: ICCameraDevice, + transactionID: inout UInt32, + onlyDiff: Bool = true + ) async -> [UInt16: Int64] { + let result = await sendCommand( + on: camera, + code: SonyPTPCommand.sdioGetAllExtDevicePropInfo, + transactionID: &transactionID, + parameters: [onlyDiff ? 1 : 0], + outData: nil, + label: "GetAllExtDevicePropInfo(\(onlyDiff ? "diff" : "all"))" + ) + guard result.success else { return [:] } + return parseExtDeviceProperties(result.inData) + } + + /// 解析 GetAllExtDevicePropInfo 返回的二进制属性列表。 + static func parseExtDeviceProperties(_ data: Data) -> [UInt16: Int64] { + guard data.count >= 8 else { return [:] } + + var properties: [UInt16: Int64] = [:] + var offset = 8 // 跳过响应头 + + while offset + 6 <= data.count { + let propertyCode = PTPHelper.readUInt16LE(data, offset: offset) + offset += 2 + let dataType = PTPHelper.readUInt16LE(data, offset: offset) + offset += 2 + offset += 2 // GetSet 等标志位,当前不解析 + + guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { break } + guard let currentValue = readVariableValue(dataType: dataType, data: data, offset: &offset) else { break } + properties[propertyCode] = currentValue + + guard offset < data.count else { break } + let formFlag = data[offset] + offset += 1 + // formFlag: 0=无表单, 1=Range, 2=Enumeration + guard skipFormPayload(formFlag: formFlag, dataType: dataType, data: data, offset: &offset) else { break } + } + + return properties + } + + /// D215(Shooting_File_Info)大于阈值表示缓冲区可能有完整新片。 + static func isShotBufferReady(_ shootingFileInfo: Int64?) -> Bool { + guard let shootingFileInfo else { return false } + return shootingFileInfo > SonyPTPCommand.shotBufferReadyThreshold + } + + static func shotObjectSignature(filename: String, size: UInt32) -> String { + PTPHelper.objectSignature(filename: filename, size: size) + } + + /// PTP 设备属性值的数据类型码(用于 parseExtDeviceProperties)。 + private enum PTPDataType: UInt16 { + /// 有符号 8 位整数 + case int8 = 0x0001 + /// 无符号 8 位整数 + case uint8 = 0x0002 + /// 有符号 16 位整数 + case int16 = 0x0003 + /// 无符号 16 位整数 + case uint16 = 0x0004 + /// 有符号 32 位整数 + case int32 = 0x0005 + /// 无符号 32 位整数 + case uint32 = 0x0006 + /// 有符号 64 位整数(当前仅跳过 8 字节) + case int64 = 0x0007 + /// 无符号 64 位整数(当前仅跳过 8 字节) + case uint64 = 0x0008 + /// UTF-16LE 字符串;解析时跳过不参与数值 map + case string = 0xFFFF + } + + private static func readVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Int64? { + guard let type = PTPDataType(rawValue: dataType) else { return nil } + + switch type { + case .int8: + guard offset < data.count else { return nil } + let value = Int64(Int8(bitPattern: data[offset])) + offset += 1 + return value + case .uint8: + guard offset < data.count else { return nil } + let value = Int64(data[offset]) + offset += 1 + return value + case .int16: + guard offset + 1 < data.count else { return nil } + let value = Int64(Int16(bitPattern: PTPHelper.readUInt16LE(data, offset: offset))) + offset += 2 + return value + case .uint16: + guard offset + 1 < data.count else { return nil } + let value = Int64(PTPHelper.readUInt16LE(data, offset: offset)) + offset += 2 + return value + case .int32: + guard offset + 3 < data.count else { return nil } + let value = Int64(Int32(bitPattern: PTPHelper.readUInt32LE(data, offset: offset))) + offset += 4 + return value + case .uint32: + guard offset + 3 < data.count else { return nil } + let value = Int64(PTPHelper.readUInt32LE(data, offset: offset)) + offset += 4 + return value + case .int64, .uint64: + guard offset + 7 < data.count else { return nil } + offset += 8 + return 0 + case .string: + guard offset < data.count else { return nil } + let length = Int(data[offset]) + offset += 1 + length * 2 + return 0 + } + } + + private static func skipVariableValue(dataType: UInt16, data: Data, offset: inout Int) -> Bool { + readVariableValue(dataType: dataType, data: data, offset: &offset) != nil + } + + private static func skipFormPayload(formFlag: UInt8, dataType: UInt16, data: Data, offset: inout Int) -> Bool { + switch formFlag { + case 0: + return true + case 1: + for _ in 0..<3 { + guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false } + } + return true + case 2: + guard offset + 1 < data.count else { return false } + var enumCount = Int(PTPHelper.readUInt16LE(data, offset: offset)) + offset += 2 + for _ in 0.. Void)? + + private var pollTimer: Timer? + private var propertyCheckTask: Task? + private var isEvaluatingShotBuffer = false + private var propertyCheckCoalesceScheduled = false + private var isDownloading = false + + private var lastDownloadedShotSignature: String? + private var detectedShotCount = 0 + private var savedShotCount = 0 + + private let idlePollInterval: TimeInterval = 1.0 + /// 检测到新片后加快 D215 轮询,提高连拍响应 + private let activePollInterval: TimeInterval = 0.25 + /// C203 等事件后略延迟再读属性,等待相机写入缓冲区 + private let propertyCheckDelay: TimeInterval = 0.12 + + + // MARK: - Lifecycle + + /// 在 SDIO 握手成功后启动;`photoSink` 为边拍边传落盘入口,nil 时只检测不保存。 + func start( + camera: ICCameraDevice, + startingTransactionID: UInt32, + photoSink: OTGPhotoSink? + ) { + stop() + + self.camera = camera + self.transactionID = startingTransactionID + self.photoSink = photoSink + self.isReady = true + self.lastDownloadedShotSignature = nil + self.detectedShotCount = 0 + self.savedShotCount = 0 + + OTGLog.info(.sony, "Remote capture service started, transactionID=\(startingTransactionID)") + startD215Polling() + } + + func stop() { + isReady = false + stopD215Polling() + propertyCheckTask?.cancel() + propertyCheckTask = nil + isEvaluatingShotBuffer = false + propertyCheckCoalesceScheduled = false + isDownloading = false + photoSink = nil + camera = nil + } + + // MARK: - PTP Events + + /// 处理 Sony SDIE 事件:C201/C206 强信号,C203 需配合 D215/Probe。 + func handlePTPEvent(_ eventData: Data) { + guard isReady else { return } + + guard let code = PTPHelper.parseEventCode(from: eventData) else { return } + + switch code { + case SonyPTPCommand.sdieObjectAdded: + let handle = (try? PTPHelper.parseContainer(eventData))?.params.first + ?? SonyPTPCommand.shotObjectHandle + OTGLog.info(.sony, "Shot signal: ObjectAdded handle=0x\(String(format: "%08X", handle))") + schedulePropertyBasedShotCheck(reason: "ObjectAdded") + + case SonyPTPCommand.sdieCapturedEvent: + OTGLog.info(.sony, "Shot signal: CapturedEvent") + schedulePropertyBasedShotCheck(reason: "CapturedEvent") + + case SonyPTPCommand.sdieDevicePropChanged: + OTGLog.debug(.sony, "PTP event: DevicePropChanged (0xC203)") + schedulePropertyBasedShotCheck(reason: "DevicePropChanged") + + case SonyPTPCommand.sdieObjectRemoved: + OTGLog.debug(.sony, "PTP event: ObjectRemoved (0xC202)") + + default: + OTGLog.debug(.sony, "PTP event: \(SonyPTPHelper.describeEvent(eventData))") + } + } + + // MARK: - D215 Polling + + private func startD215Polling() { + stopD215Polling() + OTGLog.info(.sony, "D215 polling started (idle=\(idlePollInterval)s)") + schedulePollTimer(interval: idlePollInterval) + } + + private func stopD215Polling() { + pollTimer?.invalidate() + pollTimer = nil + } + + private func schedulePollTimer(interval: TimeInterval) { + stopD215Polling() + pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + self?.schedulePropertyBasedShotCheck(reason: "D215Poll", delay: 0) + } + } + + private func refreshPollInterval() { + guard isReady else { return } + let interval = savedShotCount > 0 ? activePollInterval : idlePollInterval + if pollTimer?.timeInterval != interval { + schedulePollTimer(interval: interval) + } + } + + // MARK: - Property check + Probe + Download + + private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval? = nil) { + // 正在评估时合并后续请求,避免并发 PTP 风暴 + if isEvaluatingShotBuffer { + propertyCheckCoalesceScheduled = true + return + } + + propertyCheckTask?.cancel() + let wait = delay ?? propertyCheckDelay + propertyCheckTask = Task { [weak self] in + if wait > 0 { + try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) + } + guard !Task.isCancelled else { return } + await self?.runPropertyBasedShotCheck(reason: reason) + } + } + + private func runPropertyBasedShotCheck(reason: String) async { + if isEvaluatingShotBuffer { + propertyCheckCoalesceScheduled = true + return + } + + isEvaluatingShotBuffer = true + defer { + isEvaluatingShotBuffer = false + if propertyCheckCoalesceScheduled { + propertyCheckCoalesceScheduled = false + schedulePropertyBasedShotCheck(reason: "Coalesced", delay: 0.08) + } + } + + await evaluateShotBuffer(reason: reason) + } + + /// 读 D215 → 必要时 Probe → GetObject 下载入库。 + private func evaluateShotBuffer(reason: String) async { + guard isReady, let camera else { return } + + var localTransactionID = transactionID + var properties = await SonyPTPHelper.fetchExtDeviceProperties( + on: camera, + transactionID: &localTransactionID, + onlyDiff: true + ) + transactionID = localTransactionID + + var shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo] + + if shootingFileInfo == nil, shouldFallbackFullPropertyFetch(for: reason) { + properties = await SonyPTPHelper.fetchExtDeviceProperties( + on: camera, + transactionID: &localTransactionID, + onlyDiff: false + ) + transactionID = localTransactionID + shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo] + OTGLog.debug(.sony, "\(reason): diff had no D215, fetched full property set") + } + + if let shootingFileInfo { + OTGLog.debug( + .sony, + "\(reason): D215=0x\(String(format: "%04X", shootingFileInfo))" + ) + } + + if SonyPTPHelper.isShotBufferReady(shootingFileInfo) { + _ = await attemptFetchPendingShot(reason: "\(reason)-D215") + refreshPollInterval() + return + } + + // ZV-E10 等机型快门可能只发 C203 且 D215 未及时更新,仍尝试 Probe + if shouldProbeWithoutD215(for: reason) { + _ = await attemptFetchPendingShot(reason: "\(reason)-Probe") + } + + refreshPollInterval() + } + + /// Probe → 去重 → GetObject → 写入相册目录与 Core Data。 + @discardableResult + private func attemptFetchPendingShot(reason: String) async -> Bool { + guard isReady, let camera else { return false } + + var localTransactionID = transactionID + guard let probe = await SonyPTPHelper.probeShotObject( + on: camera, + transactionID: &localTransactionID + ) else { + return false + } + transactionID = localTransactionID + + let signature = SonyPTPHelper.shotObjectSignature( + filename: probe.filename, + size: probe.size + ) + + if signature == lastDownloadedShotSignature { + OTGLog.debug(.sony, "\(reason): already downloaded signature=\(signature)") + return false + } + + detectedShotCount += 1 + OTGLog.info( + .sony, + "New shot detected (#\(detectedShotCount)): \(probe.filename), size=\(probe.size), reason=\(reason)" + ) + + guard await downloadAndSave(probe: probe, reason: reason) else { + return false + } + + lastDownloadedShotSignature = signature + savedShotCount += 1 + return true + } + + /// 串行下载,失败时最多重试 3 次(连拍缓冲区可能尚未写完)。 + private func downloadAndSave(probe: SonyPTPHelper.PendingShot, reason: String) async -> Bool { + guard isReady, let camera else { return false } + + guard photoSink != nil else { + OTGLog.warning(.sony, "\(reason): live transfer sink not configured, skip save") + return false + } + + if isDownloading { + OTGLog.debug(.sony, "\(reason): download already in progress, skip") + return false + } + + isDownloading = true + defer { isDownloading = false } + + var localTransactionID = transactionID + + for attempt in 1...3 { + if attempt > 1 { + try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000) + } + + guard let downloaded = await SonyPTPHelper.downloadShotObject( + on: camera, + handle: probe.handle, + transactionID: &localTransactionID, + knownFilename: probe.filename, + skipObjectInfo: true + ) else { + if attempt == 3 { + OTGLog.error(.sony, "\(reason): GetObject failed after \(attempt) attempts") + } + continue + } + transactionID = localTransactionID + + guard let photoSink else { return false } + let objectID = ICCameraItemScanner.cameraObjectID( + filename: downloaded.filename, + fileSize: Int64(downloaded.data.count) + ) + do { + let saved = try await photoSink.saveLiveShot( + data: downloaded.data, + filename: downloaded.filename, + capturedAt: Date(), + cameraObjectID: objectID + ) + OTGLog.info( + .sony, + "Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count)" + ) + onShotSaved?(saved) + return true + } catch { + OTGLog.error(.sony, "\(reason): save failed: \(error.localizedDescription)") + return false + } + } + + return false + } + + private func shouldFallbackFullPropertyFetch(for reason: String) -> Bool { + reason.contains("DevicePropChanged") + || reason.hasPrefix("D215Poll") + || reason.hasPrefix("Coalesced") + } + + private func shouldProbeWithoutD215(for reason: String) -> Bool { + reason.contains("DevicePropChanged") + || reason.hasPrefix("D215Poll") + || reason.hasPrefix("Coalesced") + || reason.contains("ObjectAdded") + || reason.contains("CapturedEvent") + } +} diff --git a/suixinkan/Core/CameraOTG/Bridge/OTGPhotoSink.swift b/suixinkan/Core/CameraOTG/Bridge/OTGPhotoSink.swift new file mode 100644 index 0000000..b5ffb42 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Bridge/OTGPhotoSink.swift @@ -0,0 +1,43 @@ +import Foundation + +/// 边拍边传与历史导入落盘后的照片信息。 +struct SavedOTGPhoto: Equatable { + let assetID: String + let relativeLocalPath: String + let filename: String + let capturedAt: Date +} + +/// 边拍边传目标相册上下文,由旅拍有线传图页在 start 前注入 ConnectionManager。 +struct OTGLiveTransferContext: Equatable { + let albumId: Int + let accountKey: String + weak var photoSink: OTGPhotoSink? + + static func == (lhs: OTGLiveTransferContext, rhs: OTGLiveTransferContext) -> Bool { + lhs.albumId == rhs.albumId && lhs.accountKey == rhs.accountKey + } +} + +/// OTG 照片落盘协议:品牌 LiveCapture 与历史导入均通过此接口写入业务存储。 +@MainActor +protocol OTGPhotoSink: AnyObject { + /// 保存边拍边传下载的图像数据。 + func saveLiveShot( + data: Data, + filename: String, + capturedAt: Date, + cameraObjectID: String + ) async throws -> SavedOTGPhoto + + /// 保存历史导入已下载到本地的文件。 + func saveDownloadedFile( + at url: URL, + filename: String, + capturedAt: Date, + cameraObjectID: String + ) async throws -> SavedOTGPhoto + + /// 当前相册已导入的相机对象 ID,用于历史导入去重。 + func existingCameraObjectIDs() async -> Set +} diff --git a/suixinkan/Core/CameraOTG/CameraOTG.md b/suixinkan/Core/CameraOTG/CameraOTG.md new file mode 100644 index 0000000..fd89eb4 --- /dev/null +++ b/suixinkan/Core/CameraOTG/CameraOTG.md @@ -0,0 +1,55 @@ +# CameraOTG + +## 模块职责 + +提供 USB OTG 相机有线连接能力,支持 Sony / Canon / Nikon 三品牌: + +- USB 设备发现、授权、Session 管理与设备缓存重连 +- 边拍边传(Sony PTP、Canon EOS GetEvent、Nikon catalog 轮询) +- 相机历史照片枚举与下载(供旅拍相册历史导入页使用) + +不包含 OSS 上传与后端 API;上传由 `Features/TravelAlbum` 的 `WiredCameraTransferPipeline` 负责。 + +## 核心类型 + +| 类型 | 职责 | +| --- | --- | +| `ConnectionManager` | 单例,ImageCaptureCore 设备发现与会话编排 | +| `ConnectionState` | 连接状态机 | +| `CameraDriver` | 品牌无关的枚举/下载协议 | +| `CameraFactory` / `PlatformDetector` | 品牌识别与 Driver 创建 | +| `OTGPhotoSink` | 照片落盘协议,由旅拍模块实现 | +| `OTGPhotoFileWriter` | scoped 本地文件写入 | + +## 品牌实现 + +| 品牌 | 边拍边传 | 历史导入 | +| --- | --- | --- | +| Sony | `SonyRemoteCaptureService` + SDIO PTP | `SonyCameraDriver`(需 MTP 模式) | +| Canon | `CanonRemoteCaptureService` + EOS GetEvent | `CanonCameraDriver` | +| Nikon | `NikonCatalogLiveCaptureService` + catalog 监听 | `NikonCameraDriver` | + +## 连接流程 + +1. 有线传图页 `start` → `ConnectionManager.configureLiveTransfer` + `start` +2. `ICDeviceBrowser` 发现相机 → 申请 contents/control 授权 → `requestOpenSession` +3. `PlatformDetector` 识别品牌 → `CameraFactory.makeDriver` +4. Sony 立即初始化 SDIO 远程会话;Canon/Nikon 边拍边传在 content catalog 就绪后启动 +5. 页面离开 `detach`:仅解绑 delegate,保留设备缓存;App 终止时 `disconnect` + +## 与旅拍相册集成 + +- `OTGLiveTransferCoordinator` 实现 `ConnectionManagerDelegate` 与 `TravelAlbumOTGPhotoSink` +- 边拍边传落盘 → `WiredCameraTransferPipeline.ingestDownloadedAsset` → OSS 上传(边拍边传模式) +- 历史导入由 `CameraHistoryImportView` 独立 Sheet 呈现 + +## 依赖 + +- `ImageCaptureCore`(系统框架) +- `PTPUtilities` 通用 PTP 容器编解码 + +## 注意事项 + +- Sony 历史导入需 MTP 模式;边拍边传使用 PC Remote 模式(见 `SonyMTPHelpContent`) +- Canon 远程模式在 catalog 就绪后初始化,避免打断 SD 卡枚举 +- 不使用 ExternalAccessory / MFi,仅 ImageCaptureCore diff --git a/suixinkan/Core/CameraOTG/Core/CameraDeviceInfo.swift b/suixinkan/Core/CameraOTG/Core/CameraDeviceInfo.swift new file mode 100644 index 0000000..55f92a8 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/CameraDeviceInfo.swift @@ -0,0 +1,16 @@ +// +// CameraDeviceInfo.swift +// otg_swift +// + +import Foundation + +/// ImageCaptureCore 设备信息的轻量快照,供 PlatformDetector 与 Driver 使用。 +struct CameraDeviceInfo { + let name: String + let manufacturer: String? + let model: String? + let serialNumber: String? + let usbVendorID: Int? + let usbProductID: Int? +} diff --git a/suixinkan/Core/CameraOTG/Core/CameraDriver.swift b/suixinkan/Core/CameraOTG/Core/CameraDriver.swift new file mode 100644 index 0000000..995240a --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/CameraDriver.swift @@ -0,0 +1,32 @@ +// +// CameraDriver.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation + +/// 任意品牌相机 Driver 的统一接口。 +/// ViewModel 只依赖此协议,不引用具体品牌实现。 +protocol CameraDriver: AnyObject { + var platform: CameraPlatform { get } + var deviceInfo: CameraDeviceInfo { get } + + /// Session 已由 ConnectionManager 打开;Driver 内通常无需再次连接。 + func connect() async throws + func disconnect() + /// 枚举相机内可导入的历史照片(MTP/PTP 目录扫描)。 + func listObjects() async throws -> [CameraObject] + func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? + /// 下载单张对象到指定目录,返回本地文件 URL。 + func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL +} + +/// 与品牌无关的相机文件描述,由 ICCameraFile 映射而来。 +struct CameraObject: Equatable { + let id: String + let filename: String + let fileSize: Int64 + let capturedAt: Date +} diff --git a/suixinkan/Core/CameraOTG/Core/CameraError.swift b/suixinkan/Core/CameraOTG/Core/CameraError.swift new file mode 100644 index 0000000..bf8c55b --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/CameraError.swift @@ -0,0 +1,29 @@ +// +// CameraError.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation + +/// 相机连接与业务层共用的错误类型。 +enum CameraError: LocalizedError { + /// 品牌 Driver 中该能力尚未实现(如尼康 downloadObject) + case notImplemented + /// PlatformDetector 返回 unknown,无法创建 Driver + case unsupportedPlatform + /// listObjects / download 时在设备树中找不到对应 ICCameraFile + case deviceNotFound + /// Session、授权或 PTP 失败;`String` 为具体原因 + case connectionFailed(String) + + var errorDescription: String? { + switch self { + case .notImplemented: return "功能尚未实现" + case .unsupportedPlatform: return "不支持的相机品牌" + case .deviceNotFound: return "未找到相机" + case .connectionFailed(let msg): return "连接失败:\(msg)" + } + } +} diff --git a/suixinkan/Core/CameraOTG/Core/CameraFactory.swift b/suixinkan/Core/CameraOTG/Core/CameraFactory.swift new file mode 100644 index 0000000..495df2c --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/CameraFactory.swift @@ -0,0 +1,39 @@ +// +// CameraFactory.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +import ImageCaptureCore + +/// 根据 PlatformDetector 结果创建对应品牌的 CameraDriver 实例。 +enum CameraFactory { + /// 创建品牌 Driver;`unknown` 平台返回 nil。 + static func makeDriver( + platform: CameraPlatform, + device: ICCameraDevice, + deviceInfo: CameraDeviceInfo + ) -> CameraDriver? { + OTGLog.info(.factory, "creating driver for platform=\(platform.rawValue), device=\(deviceInfo.name)") + + switch platform { + case .nikon: + let driver = NikonCameraDriver(device: device, deviceInfo: deviceInfo) + OTGLog.info(.factory, "created NikonCameraDriver") + return driver + case .canon: + let driver = CanonCameraDriver(device: device, deviceInfo: deviceInfo) + OTGLog.info(.factory, "created CanonCameraDriver") + return driver + case .sony: + let driver = SonyCameraDriver(device: device, deviceInfo: deviceInfo) + OTGLog.info(.factory, "created SonyCameraDriver") + return driver + case .unknown: + OTGLog.error(.factory, "refused to create driver for unknown platform") + return nil + } + } +} diff --git a/suixinkan/Core/CameraOTG/Core/CameraPlatform.swift b/suixinkan/Core/CameraOTG/Core/CameraPlatform.swift new file mode 100644 index 0000000..fa09a61 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/CameraPlatform.swift @@ -0,0 +1,20 @@ +// +// CameraPlatform.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation + +/// 相机品牌枚举,由 PlatformDetector 输出、CameraFactory 消费。 +enum CameraPlatform: String, CaseIterable { + /// 尼康;当前 Driver 为占位实现 + case nikon + /// 佳能;MTP 历史导入已接入 + case canon + /// 索尼;MTP 导入与 PC Remote 边拍边传分路径实现 + case sony + /// VID 与型号字符串均无法识别;不创建 Driver + case unknown +} diff --git a/suixinkan/Core/CameraOTG/Core/ConnectionManager.swift b/suixinkan/Core/CameraOTG/Core/ConnectionManager.swift new file mode 100644 index 0000000..5d2129d --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/ConnectionManager.swift @@ -0,0 +1,646 @@ +// +// ConnectionManager.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import ImageCaptureCore + +/// ConnectionManager 向 ViewModel 上报连接生命周期与内容目录就绪事件。 +protocol ConnectionManagerDelegate: AnyObject { + func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) + func connectionManager(_ manager: ConnectionManager, didCreate driver: CameraDriver) + func connectionManager(_ manager: ConnectionManager, didFail error: Error) + func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) + func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) + /// 边拍边传:单张照片已下载并写入目标相册。 + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int) +} + +extension ConnectionManagerDelegate { + func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {} + func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {} + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int) {} +} + +/// 单例:ImageCaptureCore 设备发现、授权、Session、Driver 创建与边拍边传编排。 +/// 持有 `ICDeviceBrowser` 与缓存的 `ICCameraDevice`,View 层通过 Delegate 订阅状态。 +final class ConnectionManager: NSObject { + + static let shared = ConnectionManager() + + weak var delegate: ConnectionManagerDelegate? + + private(set) var state: ConnectionState = .idle { + didSet { + if oldValue != state { + OTGLog.info(.connection, "state changed: \(oldValue) -> \(state)") + } + notify { self.delegate?.connectionManager(self, didUpdate: self.state) } + } + } + + private(set) var currentDriver: CameraDriver? + private(set) var isContentCatalogReady = false + private var browser: ICDeviceBrowser? + /// First `didAdd` caches the device; kept after session close until USB unplug (`didRemove`). + private var cachedDevice: ICCameraDevice? + private var isClosingSession = false + private var pendingStartAfterClose = false + private var searchRescanWorkItem: DispatchWorkItem? + private var searchReplugHintWorkItem: DispatchWorkItem? + private var sonyRemoteCapture: SonyRemoteCaptureService? + private var canonRemoteCapture: CanonRemoteCaptureService? + private var nikonLiveCapture: NikonCatalogLiveCaptureService? + /// 边拍边传目标上下文;由有线传图 ViewModel 在 start 前设置。 + private var liveTransferContext: OTGLiveTransferContext? + + private let searchRescanDelay: TimeInterval = 1.5 + /// 长时间搜不到设备时提示用户重新插拔 + private let searchReplugHintDelay: TimeInterval = 4.5 + + private override init() { + super.init() + } + + // MARK: - Public + + /// 设置边拍边传目标相册;传 nil 清除。 + func configureLiveTransfer(context: OTGLiveTransferContext?) { + liveTransferContext = context + OTGLog.debug(.connection, "live transfer album=\(context.map { String($0.albumId) } ?? "nil")") + } + + /// Entry point when OTG UI appears: reconnect cached device or start discovery. + func start() { + OTGLog.info(.connection, "start requested, state=\(state)") + + if case .connected = state { + return + } + + if isClosingSession { + OTGLog.info(.connection, "start deferred, session is closing") + pendingStartAfterClose = true + return + } + + if let cached = cachedDevice { + reconnect(to: cached) + return + } + + startSearching() + } + + func unbindDelegate() { + delegate = nil + cancelSearchTimers() + } + + /// 断开 Session 并释放 Driver;缓存设备保留至 USB 拔出。 + func disconnect() { + guard let device = activeDevice else { + state = .disconnected + return + } + + guard !isClosingSession else { + OTGLog.info(.connection, "disconnect ignored, session already closing") + return + } + + OTGLog.info(.connection, "disconnect requested") + isClosingSession = true + isContentCatalogReady = false + cancelSearchTimers() + sonyRemoteCapture?.stop() + sonyRemoteCapture = nil + canonRemoteCapture?.stop() + canonRemoteCapture = nil + nikonLiveCapture?.stop() + nikonLiveCapture = nil + liveTransferContext = nil + currentDriver?.disconnect() + currentDriver = nil + device.delegate = self + device.requestCloseSession() + } + + // MARK: - Private + + private var activeDevice: ICCameraDevice? { + cachedDevice + } + + private func notify(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async(execute: block) + } + } + + private func startSearching() { + OTGLog.info(.connection, "start searching for cameras") + cancelSearchTimers() + state = .searching + + if browser == nil { + launchBrowser() + } + + scheduleSearchMonitoring() + } + + private func launchBrowser() { + let browser = ICDeviceBrowser() + browser.delegate = self + // iOS 15.2+:同时浏览 Camera 与 Local USB,提高 Sony/Canon 直连发现率 + if #available(iOS 15.2, *) { + let mask = ICDeviceTypeMask( + rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue + ) ?? .camera + browser.browsedDeviceTypeMask = mask + } else { + browser.browsedDeviceTypeMask = .camera + } + browser.start() + self.browser = browser + OTGLog.debug(.connection, "browser launched") + } + + private func reconnect(to camera: ICCameraDevice) { + guard !isClosingSession else { + OTGLog.warning(.connection, "reconnect ignored, session is closing") + return + } + + if case .connected = state { + return + } + + OTGLog.info(.connection, "reconnecting to cached device: \(camera.name ?? "nil")") + cancelSearchTimers() + cachedDevice = camera + state = .connecting(deviceName: camera.name ?? "Unknown Camera") + requestAuthorizations { [weak self] in + self?.openSession(for: camera, context: "cached device") + } + } + + /// 先申请 contents 再申请 control;PTP 传图必须获得 control 授权。 + private func requestAuthorizations(then completion: @escaping () -> Void) { + if browser == nil { + launchBrowser() + } + + guard let browser else { + OTGLog.error(.connection, "authorization skipped, browser unavailable") + completion() + return + } + + browser.requestContentsAuthorization { [weak self] contentsStatus in + guard let self else { return } + + let contentsGranted = contentsStatus == .authorized + OTGLog.info(.connection, "contents authorization: \(contentsStatus.rawValue)") + + guard contentsGranted else { + DispatchQueue.main.async { + OTGLog.error(.connection, "camera contents authorization denied") + self.state = .failed(message: "未获得相机内容访问权限") + self.delegate?.connectionManager( + self, + didFail: CameraError.connectionFailed("未获得相机内容访问权限") + ) + } + return + } + + browser.requestControlAuthorization { [weak self] controlStatus in + guard let self else { return } + + let controlGranted = controlStatus == .authorized + OTGLog.info(.connection, "control authorization: \(controlStatus.rawValue)") + + DispatchQueue.main.async { + guard controlGranted else { + OTGLog.error(.connection, "camera control authorization denied, PTP requires control access") + self.state = .failed(message: "未获得相机控制权限,无法使用 PTP 传图") + self.delegate?.connectionManager( + self, + didFail: CameraError.connectionFailed( + "请在系统弹窗中允许控制外接相机(PTP 传图需要此权限)" + ) + ) + return + } + + completion() + } + } + } + } + + private func openSession(for camera: ICCameraDevice, context: String) { + camera.delegate = self + camera.requestOpenSession() + OTGLog.debug(.connection, "requestOpenSession sent (\(context))") + } + + private func scheduleSearchMonitoring() { + cancelSearchTimers() + + let rescan = DispatchWorkItem { [weak self] in + guard let self, self.state == .searching, self.cachedDevice == nil else { return } + self.rescanBrowser() + } + + let replugHint = DispatchWorkItem { [weak self] in + guard let self, self.state == .searching, self.cachedDevice == nil else { return } + OTGLog.warning(.connection, "search timed out, suggesting replug") + self.notify { self.delegate?.connectionManagerDidSuggestReplug(self) } + self.scheduleSearchMonitoring() + } + + searchRescanWorkItem = rescan + searchReplugHintWorkItem = replugHint + DispatchQueue.main.asyncAfter(deadline: .now() + searchRescanDelay, execute: rescan) + DispatchQueue.main.asyncAfter(deadline: .now() + searchReplugHintDelay, execute: replugHint) + } + + private func rescanBrowser() { + OTGLog.info(.connection, "rescanning: restarting browser") + browser?.stop() + browser?.delegate = nil + browser = nil + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self, self.state == .searching, self.cachedDevice == nil else { return } + self.launchBrowser() + self.scheduleSearchMonitoring() + } + } + + private func cancelSearchTimers() { + searchRescanWorkItem?.cancel() + searchRescanWorkItem = nil + searchReplugHintWorkItem?.cancel() + searchReplugHintWorkItem = nil + } + + private func clearDeviceCache() { + sonyRemoteCapture?.stop() + sonyRemoteCapture = nil + canonRemoteCapture?.stop() + canonRemoteCapture = nil + nikonLiveCapture?.stop() + nikonLiveCapture = nil + cachedDevice = nil + currentDriver?.disconnect() + currentDriver = nil + isContentCatalogReady = false + isClosingSession = false + } +} + +extension ConnectionManager: ICDeviceBrowserDelegate { + + func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) { + OTGLog.info(.connection, "device added: name=\(device.name ?? "nil"), type=\(device.type.rawValue), moreComing=\(moreComing)") + guard let camera = device as? ICCameraDevice else { + OTGLog.warning(.connection, "ignored non-camera device: \(device.name ?? "nil")") + return + } + handleDiscoveredCamera(camera) + } + + func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) { + OTGLog.info(.connection, "device removed: name=\(device.name ?? "nil"), uuid=\(device.uuidString ?? "nil"), moreGoing=\(moreGoing)") + guard device.uuidString == cachedDevice?.uuidString else { return } + cancelSearchTimers() + clearDeviceCache() + state = .idle + } +} + +private extension ConnectionManager { + + func handleDiscoveredCamera(_ camera: ICCameraDevice) { + ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "discovered") + + guard !isClosingSession else { + OTGLog.warning(.connection, "camera ignored while session is closing") + return + } + + if let cached = cachedDevice, cached.uuidString == camera.uuidString { + if case .connected = state { return } + reconnect(to: cached) + return + } + + guard cachedDevice == nil else { + OTGLog.warning(.connection, "camera ignored, already have cached device: \(cachedDevice?.name ?? "nil")") + return + } + + OTGLog.info(.connection, "discovered camera: name=\(camera.name ?? "nil"), uuid=\(camera.uuidString ?? "nil")") + cancelSearchTimers() + cachedDevice = camera + state = .connecting(deviceName: camera.name ?? "Unknown Camera") + requestAuthorizations { [weak self] in + self?.openSession(for: camera, context: "discovered") + } + } + + func buildDeviceInfo(from camera: ICCameraDevice) -> CameraDeviceInfo { + let vendorID = Int(camera.usbVendorID) + let productID = Int(camera.usbProductID) + let info = CameraDeviceInfo( + name: camera.name ?? "Unknown Camera", + manufacturer: camera.productKind, + model: camera.name, + serialNumber: camera.uuidString, + usbVendorID: vendorID != 0 ? vendorID : nil, + usbProductID: productID != 0 ? productID : nil + ) + OTGLog.debug( + .connection, + "device info: \(info.name), manufacturer=\(info.manufacturer ?? "nil"), usbVendorID=\(info.usbVendorID.map { String(format: "0x%04X", $0) } ?? "nil"), serial=\(info.serialNumber ?? "nil")" + ) + return info + } + + /// 检测品牌、创建 Driver 并进入 connected 状态。 + func createDriver(for camera: ICCameraDevice) { + let info = buildDeviceInfo(from: camera) + let platform = PlatformDetector.detect(from: info) + + guard platform != .unknown else { + OTGLog.error(.connection, "unsupported platform for device: \(info.name)") + state = .failed(message: "不支持的相机品牌") + delegate?.connectionManager(self, didFail: CameraError.unsupportedPlatform) + return + } + + guard let driver = CameraFactory.makeDriver( + platform: platform, + device: camera, + deviceInfo: info + ) else { + OTGLog.error(.connection, "failed to create driver for platform: \(platform.rawValue)") + state = .failed(message: "无法创建相机驱动") + return + } + + currentDriver = driver + state = .connected(platform: platform, deviceName: info.name) + OTGLog.info(.connection, "driver ready: \(platform.rawValue), device=\(info.name)") + delegate?.connectionManager(self, didCreate: driver) + } + + /// Session 打开后按品牌配置 PTP:索尼 SDIO + 边拍边传;佳能仅 Probe(远程模式等 catalog 就绪后启动)。 + private func configurePTPAfterSession(_ camera: ICCameraDevice) { + let platform = PlatformDetector.detect(from: buildDeviceInfo(from: camera)) + + Task { + // 等待 Session 稳定后再发 PTP,避免首包超时 + try? await Task.sleep(nanoseconds: 300_000_000) + + switch platform { + case .canon: + await runGetDeviceInfoProbe(on: camera) + + case .sony: + var transactionID: UInt32 = 1 + let result = await SonyPTPHelper.initializeRemoteSession( + on: camera, + startingTransactionID: transactionID + ) + if result.success { + let service = SonyRemoteCaptureService() + service.onShotSaved = makeLiveShotSavedHandler() + sonyRemoteCapture = service + service.start( + camera: camera, + startingTransactionID: result.nextTransactionID, + photoSink: liveTransferContext?.photoSink + ) + OTGLog.info(.sony, "Sony remote session ready") + } else { + OTGLog.error(.sony, "Sony remote session setup failed") + } + + default: + await runGetDeviceInfoProbe(on: camera) + } + } + } + + private func runGetDeviceInfoProbe(on camera: ICCameraDevice) async { + OTGLog.info(.ptp, "PTP probe starting...") + + var transactionID: UInt32 = 1 + let result = await PTPHelper.sendCommand( + on: camera, + code: PTPStandardCommand.getDeviceInfo, + transactionID: &transactionID, + label: "GetDeviceInfo(probe)" + ) + + if result.success { + OTGLog.info(.ptp, "PTP probe passed, deviceInfo=\(result.inData.count) bytes") + } else { + OTGLog.error(.ptp, "PTP probe failed") + } + } + + /// catalog 就绪后再初始化 Canon 远程模式,避免 SetRemoteMode 打断 SD 卡枚举。 + private func startCanonLiveTransferIfNeeded(for camera: ICCameraDevice) { + guard canonRemoteCapture == nil else { return } + guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .canon else { return } + + Task { @MainActor in + try? await Task.sleep(nanoseconds: 500_000_000) + + var transactionID: UInt32 = 1 + let result = await CanonPTPHelper.initializeRemoteSession( + on: camera, + startingTransactionID: transactionID + ) + guard result.success else { + OTGLog.error(.canon, "Canon remote session setup failed") + return + } + + let service = CanonRemoteCaptureService() + service.onShotSaved = makeLiveShotSavedHandler() + canonRemoteCapture = service + service.start( + camera: camera, + startingTransactionID: result.nextTransactionID, + photoSink: liveTransferContext?.photoSink + ) + OTGLog.info(.canon, "Canon remote capture ready") + } + } + + /// catalog 就绪后启动尼康边拍边传(didAdd + catalog 轮询,无需 PTP 远程会话)。 + private func startNikonLiveTransferIfNeeded(for camera: ICCameraDevice) { + guard nikonLiveCapture == nil else { return } + guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .nikon else { return } + + let service = NikonCatalogLiveCaptureService() + service.onShotSaved = makeLiveShotSavedHandler() + nikonLiveCapture = service + service.start(camera: camera, photoSink: liveTransferContext?.photoSink) + OTGLog.info(.nikon, "Nikon catalog live capture ready") + } + + private func makeLiveShotSavedHandler() -> (SavedOTGPhoto) -> Void { + { [weak self] photo in + guard let self, let albumId = self.liveTransferContext?.albumId else { return } + self.notify { + self.delegate?.connectionManager( + self, + didSaveLiveShot: photo, + albumId: albumId + ) + } + } + } +} + +extension ConnectionManager: ICCameraDeviceDelegate { + + func device(_ device: ICDevice, didOpenSessionWithError error: (any Error)?) { + guard device.uuidString == cachedDevice?.uuidString else { return } + + if let error { + OTGLog.error(.connection, "open session failed: \(error.localizedDescription)") + state = .failed(message: error.localizedDescription) + delegate?.connectionManager(self, didFail: error) + return + } + + OTGLog.info(.connection, "session opened: \(device.name ?? "nil")") + guard let camera = device as? ICCameraDevice else { return } + cachedDevice = camera + ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "session-opened") + configurePTPAfterSession(camera) + createDriver(for: camera) + } + + func device(_ device: ICDevice, didCloseSessionWithError error: (any Error)?) { + guard device.uuidString == cachedDevice?.uuidString else { return } + + if let error { + OTGLog.warning(.connection, "session closed with error: \(error.localizedDescription)") + } else { + OTGLog.info(.connection, "session closed: \(device.name ?? "nil")") + } + + isClosingSession = false + sonyRemoteCapture?.stop() + sonyRemoteCapture = nil + canonRemoteCapture?.stop() + canonRemoteCapture = nil + nikonLiveCapture?.stop() + nikonLiveCapture = nil + currentDriver = nil + isContentCatalogReady = false + if let camera = device as? ICCameraDevice { + cachedDevice = camera + } + state = .disconnected + + if pendingStartAfterClose { + pendingStartAfterClose = false + OTGLog.info(.connection, "session close finished, resuming start") + start() + } + } + + func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) { + OTGLog.debug(.connection, "camera items added: \(items.count)") + canonRemoteCapture?.handleCatalogItemsAdded(items) + nikonLiveCapture?.handleCatalogItemsAdded(items) + } + + func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) { + OTGLog.debug(.connection, "camera items removed: \(items.count)") + } + + func cameraDevice( + _ camera: ICCameraDevice, + didReceiveThumbnail thumbnail: CGImage?, + for item: ICCameraItem, + error: (any Error)? + ) { + if let error { + OTGLog.warning(.connection, "thumbnail failed for \(item.name ?? "nil"): \(error.localizedDescription)") + } + } + + func cameraDevice( + _ camera: ICCameraDevice, + didReceiveMetadata metadata: [AnyHashable: Any]?, + for item: ICCameraItem, + error: (any Error)? + ) { + if let error { + OTGLog.warning(.connection, "metadata failed for \(item.name ?? "nil"): \(error.localizedDescription)") + } + } + + func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) { + OTGLog.debug(.connection, "camera items renamed: \(items.count)") + } + + func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) { + OTGLog.debug(.connection, "camera capability changed: \(camera.name ?? "nil")") + } + + func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent event: Data) { + guard cachedDevice?.uuidString == camera.uuidString else { return } + sonyRemoteCapture?.handlePTPEvent(event) + canonRemoteCapture?.handlePTPEvent(event) + } + + /// ImageCaptureCore 内容目录就绪后可安全调用 `listObjects`(MTP 历史导入)。 + func deviceDidBecomeReady(withCompleteContentCatalog camera: ICCameraDevice) { + guard camera.uuidString == cachedDevice?.uuidString else { + OTGLog.warning(.connection, "content catalog ready ignored, unknown camera: \(camera.name ?? "nil")") + return + } + + OTGLog.info(.connection, "camera ready with content catalog: \(camera.name ?? "nil")") + ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "content-catalog-ready") + isContentCatalogReady = true + + guard let driver = currentDriver else { + OTGLog.warning(.connection, "content catalog ready but no driver available") + return + } + + notify { self.delegate?.connectionManager(self, contentCatalogDidBecomeReady: driver) } + startCanonLiveTransferIfNeeded(for: camera) + startNikonLiveTransferIfNeeded(for: camera) + } + + func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) { + OTGLog.info(.connection, "access restriction removed: \(device.name ?? "nil")") + } + + func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) { + OTGLog.warning(.connection, "access restriction enabled: \(device.name ?? "nil")") + } + + func didRemove(_ device: ICDevice) { + OTGLog.info(.connection, "device removed delegate callback: \(device.name ?? "nil")") + } +} diff --git a/suixinkan/Core/CameraOTG/Core/ConnectionState.swift b/suixinkan/Core/CameraOTG/Core/ConnectionState.swift new file mode 100644 index 0000000..e11a25c --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/ConnectionState.swift @@ -0,0 +1,58 @@ +// +// ConnectionState.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation + +/// OTG 连接流程的状态机,供 ViewModel 绑定 `displayText` 展示给用户。 +enum ConnectionState: Equatable { + /// 初始态,尚未开始搜索或已完全复位 + case idle + /// 正在通过 ICDeviceBrowser 发现 USB 相机 + case searching + /// 已发现设备,正在打开 Session 或等待系统授权 + case connecting(deviceName: String) + /// Session 已打开且 Driver 已创建;`platform` 用于品牌相关 UI 提示 + case connected(platform: CameraPlatform, deviceName: String) + /// 用户主动断开或 Session 已关闭,设备仍可能插在 USB 上 + case disconnected + /// 授权被拒、不支持品牌或 Session 打开失败;`message` 为可读错误说明 + case failed(message: String) +} + +extension ConnectionState: CustomStringConvertible { + var description: String { + switch self { + case .idle: return "idle" + case .searching: return "searching" + case .connecting(let name): return "connecting(\(name))" + case .connected(let platform, let name): + return "connected(\(platform.rawValue), \(name))" + case .disconnected: return "disconnected" + case .failed(let message): return "failed(\(message))" + } + } + + var displayText: String { + switch self { + case .idle: return "等待开始" + case .searching: return "正在搜索相机…" + case .connecting(let name): return "正在连接 \(name)…" + case .connected(let platform, let name): + return "已连接 \(platformLabel(platform)) · \(name)" + case .disconnected: return "已断开连接" + case .failed(let msg): return "连接失败:\(msg)" + } + } + private func platformLabel(_ platform: CameraPlatform) -> String { + switch platform { + case .nikon: return "尼康" + case .canon: return "佳能" + case .sony: return "索尼" + case .unknown: return "未知" + } + } +} diff --git a/suixinkan/Core/CameraOTG/Core/ICCameraDevice+DebugLog.swift b/suixinkan/Core/CameraOTG/Core/ICCameraDevice+DebugLog.swift new file mode 100644 index 0000000..b828798 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/ICCameraDevice+DebugLog.swift @@ -0,0 +1,107 @@ +// +// ICCameraDevice+DebugLog.swift +// otg_swift +// + +import CoreGraphics +import ImageCaptureCore + +/// 调试工具:打印 ICCameraDevice 非空属性,辅助 PlatformDetector 与 catalog 为空问题排查。 +enum ICCameraDeviceDebugLogger { + + /// 打印 ICCameraDevice 在 iOS 上可访问的非空属性,便于调试 PlatformDetector 与连接问题。 + static func logNonEmptyProperties(of camera: ICCameraDevice, context: String) { + var lines: [String] = [] + lines.append("=== ICCameraDevice [\(context)] ===") + + // MARK: ICDevice + append("type", String(describing: camera.type.rawValue), to: &lines) + append("name", camera.name, to: &lines) + append("productKind", camera.productKind, to: &lines) + append("transportType", camera.transportType, to: &lines) + append("uuidString", camera.uuidString, to: &lines) + append("systemSymbolName", camera.systemSymbolName, to: &lines) + + appendIfTrue("hasOpenSession", camera.hasOpenSession, to: &lines) + appendCapabilities(camera.capabilities, to: &lines) + appendUserData(camera.userData, to: &lines) + appendIcon(camera.icon, to: &lines) + + appendUSBID("usbVendorID", Int(camera.usbVendorID), to: &lines) + appendUSBID("usbProductID", Int(camera.usbProductID), to: &lines) + appendUSBID("usbLocationID", Int(camera.usbLocationID), to: &lines) + + // MARK: ICCameraDevice + if camera.contentCatalogPercentCompleted > 0 { + lines.append("contentCatalogPercentCompleted=\(camera.contentCatalogPercentCompleted)") + } + + appendItems("contents", camera.contents, to: &lines) + appendItems("mediaFiles", camera.mediaFiles, to: &lines) + + appendIfTrue("ejectable", camera.isEjectable, to: &lines) + appendIfTrue("locked", camera.isLocked, to: &lines) + appendIfTrue("accessRestrictedAppleDevice", camera.isAccessRestrictedAppleDevice, to: &lines) + appendIfTrue("iCloudPhotosEnabled", camera.iCloudPhotosEnabled, to: &lines) + appendIfTrue("tetheredCaptureEnabled", camera.tetheredCaptureEnabled, to: &lines) + + if #available(iOS 14.0, *) { + lines.append("mediaPresentation=\(camera.mediaPresentation.rawValue)") + } + + if camera.batteryLevelAvailable { + lines.append("batteryLevelAvailable=true") + lines.append("batteryLevel=\(camera.batteryLevel)") + } + + if camera.ptpEventHandler != nil { + lines.append("ptpEventHandler=set") + } + + lines.append("=== end ICCameraDevice dump ===") + + for line in lines { + OTGLog.debug(.connection, line) + } + } + + // MARK: - Helpers + + private static func append(_ key: String, _ value: String?, to lines: inout [String]) { + guard let value, !value.isEmpty else { return } + lines.append("\(key)=\(value)") + } + + private static func appendIfTrue(_ key: String, _ value: Bool, to lines: inout [String]) { + guard value else { return } + lines.append("\(key)=true") + } + + private static func appendUSBID(_ key: String, _ value: Int, to lines: inout [String]) { + guard value != 0 else { return } + lines.append("\(key)=0x\(String(format: "%04X", value)) (\(value))") + } + + private static func appendCapabilities(_ capabilities: [String], to lines: inout [String]) { + guard !capabilities.isEmpty else { return } + lines.append("capabilities=[\(capabilities.joined(separator: ", "))]") + } + + private static func appendUserData(_ userData: NSMutableDictionary?, to lines: inout [String]) { + guard let userData, !userData.allKeys.isEmpty else { return } + lines.append("userData=\(userData)") + } + + private static func appendIcon(_ icon: CGImage?, to lines: inout [String]) { + guard let icon else { return } + lines.append("icon=present(\(icon.width)x\(icon.height))") + } + + private static func appendItems(_ key: String, _ items: [ICCameraItem]?, to lines: inout [String]) { + guard let items, !items.isEmpty else { return } + let names = items.prefix(5).map { $0.name ?? "unnamed" }.joined(separator: ", ") + let suffix = items.count > 5 ? ", ..." : "" + lines.append("\(key).count=\(items.count)") + lines.append("\(key).preview=[\(names)\(suffix)]") + } +} diff --git a/suixinkan/Core/CameraOTG/Core/ICCameraFileDownloader.swift b/suixinkan/Core/CameraOTG/Core/ICCameraFileDownloader.swift new file mode 100644 index 0000000..bfb0fbc --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/ICCameraFileDownloader.swift @@ -0,0 +1,46 @@ +// +// ICCameraFileDownloader.swift +// otg_swift +// + +import Foundation +import ImageCaptureCore + +/// 封装 `ICCameraFile.requestDownload`,将相机文件异步保存到本地目录。 +enum ICCameraFileDownloader { + + /// 下载到 `directory`,同名文件会被覆盖(`ICDownloadOption.overwrite`)。 + static func download(_ file: ICCameraFile, to directory: URL) async throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let filename = file.name ?? UUID().uuidString + let destinationURL = directory.appendingPathComponent(filename) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + return try await withCheckedThrowingContinuation { continuation in + let options: [ICDownloadOption: Any] = [ + .downloadsDirectoryURL: directory, + .savedFilename: filename, + .overwrite: true + ] + + file.requestDownload(options: options) { savedFilename, error in + if let error { + continuation.resume(throwing: error) + return + } + + let resolvedFilename = savedFilename ?? filename + let resolvedURL = directory.appendingPathComponent(resolvedFilename) + guard FileManager.default.fileExists(atPath: resolvedURL.path) else { + continuation.resume(throwing: CameraError.connectionFailed("下载文件未找到")) + return + } + continuation.resume(returning: resolvedURL) + } + } + } +} diff --git a/suixinkan/Core/CameraOTG/Core/ICCameraItem+CameraObjects.swift b/suixinkan/Core/CameraOTG/Core/ICCameraItem+CameraObjects.swift new file mode 100644 index 0000000..5c04add --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/ICCameraItem+CameraObjects.swift @@ -0,0 +1,139 @@ +// +// ICCameraItem+CameraObjects.swift +// otg_swift +// + +import Foundation +import ImageCaptureCore +import UniformTypeIdentifiers + +/// 从 `ICCameraDevice` 设备树收集图片文件,并映射为通用 `CameraObject`。 +enum ICCameraItemScanner { + + /// 多策略收集:`mediaFiles` → `contents` 递归 → `filesOfType(public.image)` 兜底。 + static func collectImageFiles(from device: ICCameraDevice, supplementalItems: [ICCameraItem] = []) -> [ICCameraFile] { + var files = collectImageFilesFromDeviceTree(device) + + if files.isEmpty, !supplementalItems.isEmpty { + files = collectImageFiles(from: supplementalItems) + OTGLog.info(.connection, "collectImageFiles: using supplemental items, count=\(files.count)") + } + + // 部分机型 contents 为空但 filesOfType 仍可枚举 + if files.isEmpty { + files = collectImageFiles(fromFilesOfType: device) + } + + return files + } + + private static func collectImageFilesFromDeviceTree(_ device: ICCameraDevice) -> [ICCameraFile] { + let mediaFiles = (device.mediaFiles ?? []).compactMap { $0 as? ICCameraFile } + if !mediaFiles.isEmpty { + return mediaFiles.filter { isImageFilename($0.name) } + } + return collectImageFiles(from: device.contents ?? []) + } + + private static func collectImageFiles(fromFilesOfType device: ICCameraDevice) -> [ICCameraFile] { + guard let names = device.files(ofType: UTType.image.identifier), !names.isEmpty else { + return [] + } + + OTGLog.info(.connection, "filesOfType(public.image) count=\(names.count), preview=\(names.prefix(3))") + + let treeFiles = collectImageFiles(from: device.contents ?? []) + if !treeFiles.isEmpty { + return treeFiles.filter { file in + guard let name = file.name else { return false } + return names.contains(name) || names.contains(where: { ($0 as NSString).lastPathComponent == name }) + } + } + + return [] + } + + /// 递归遍历文件夹,收集扩展名匹配的图片文件。 + static func collectImageFiles(from items: [ICCameraItem]) -> [ICCameraFile] { + var results: [ICCameraFile] = [] + for item in items { + if let file = item as? ICCameraFile, isImageFilename(file.name) { + results.append(file) + } else if let folder = item as? ICCameraFolder { + results.append(contentsOf: collectImageFiles(from: folder.contents ?? [])) + } + } + return results + } + + /// 按 `CameraObject.id`(文件名+大小)反查 ICCameraFile。 + static func findFile(matching object: CameraObject, in device: ICCameraDevice) -> ICCameraFile? { + collectImageFiles(from: device).first { cameraObjectID(for: $0) == object.id } + } + + static func cameraObject(from file: ICCameraFile) -> CameraObject? { + guard let filename = file.name, !filename.isEmpty, isImageFilename(filename) else { return nil } + let fileSize = Int64(file.fileSize) + let capturedAt = file.exifCreationDate ?? file.fileCreationDate ?? file.creationDate ?? .distantPast + return CameraObject( + id: cameraObjectID(for: file), + filename: filename, + fileSize: fileSize, + capturedAt: capturedAt + ) + } + + /// 并发请求 metadata,补全 catalog 枚举阶段缺失的拍摄时间。 + static func cameraObjects(from files: [ICCameraFile]) async -> [CameraObject] { + await withTaskGroup(of: (Int, CameraObject?).self) { group in + for (index, file) in files.enumerated() { + group.addTask { + (index, await cameraObject(from: file)) + } + } + + var indexed = Array(repeating: nil, count: files.count) + for await (index, object) in group { + indexed[index] = object + } + return indexed.compactMap { $0 } + } + } + + static func cameraObject(from file: ICCameraFile) async -> CameraObject? { + guard let filename = file.name, !filename.isEmpty, isImageFilename(filename) else { return nil } + let fileSize = Int64(file.fileSize) + let capturedAt = await ICCameraMetadataLoader.captureDate(from: file) + return CameraObject( + id: cameraObjectID(for: file), + filename: filename, + fileSize: fileSize, + capturedAt: capturedAt + ) + } + + /// 稳定 ID:文件名 + 文件大小(同目录重名不同尺寸可区分)。 + static func cameraObjectID(for file: ICCameraFile) -> String { + cameraObjectID(filename: file.name ?? "unknown", fileSize: Int64(file.fileSize)) + } + + static func cameraObjectID(filename: String, fileSize: Int64) -> String { + "\(filename)|\(fileSize)" + } + + /// 从相册本地文件路径解析 dedup 键,与 `CameraObject.id` 格式一致。 + static func cameraObjectID(localPath: String) -> String? { + guard !localPath.isEmpty, + FileManager.default.fileExists(atPath: localPath) else { return nil } + let filename = (localPath as NSString).lastPathComponent + guard let attrs = try? FileManager.default.attributesOfItem(atPath: localPath), + let size = attrs[.size] as? Int64 else { return nil } + return cameraObjectID(filename: filename, fileSize: size) + } + + static func isImageFilename(_ name: String?) -> Bool { + guard let name else { return false } + let ext = (name as NSString).pathExtension.lowercased() + return ["jpg", "jpeg", "heic", "png", "arw", "cr2", "cr3", "nef", "raf"].contains(ext) + } +} diff --git a/suixinkan/Core/CameraOTG/Core/ICCameraMetadataLoader.swift b/suixinkan/Core/CameraOTG/Core/ICCameraMetadataLoader.swift new file mode 100644 index 0000000..ae906f1 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/ICCameraMetadataLoader.swift @@ -0,0 +1,133 @@ +// +// ICCameraMetadataLoader.swift +// otg_swift +// + +import Foundation +import ImageCaptureCore +import ImageIO + +/// 从 `ICCameraFile` 读取拍摄时间;catalog 枚举阶段 EXIF 日期通常尚未加载,需主动 request metadata。 +enum ICCameraMetadataLoader { + + static func captureDate(from file: ICCameraFile) async -> Date { + if let date = immediateCaptureDate(from: file) { + return date + } + + if let metadata = await requestMetadata(from: file) { + if let date = parseCaptureDate(from: metadata) { + return date + } + } + + if let date = immediateCaptureDate(from: file) { + return date + } + + if let date = parseCaptureDate(fromFilename: file.name ?? "") { + return date + } + + return .distantPast + } + + // MARK: - Private + + private static func immediateCaptureDate(from file: ICCameraFile) -> Date? { + file.exifCreationDate ?? file.fileCreationDate ?? file.creationDate + } + + private static func requestMetadata(from file: ICCameraFile) async -> [AnyHashable: Any]? { + await withCheckedContinuation { continuation in + file.requestMetadataDictionary(options: nil) { metadata, error in + if let error { + OTGLog.debug( + .connection, + "metadata failed for \(file.name ?? "nil"): \(error.localizedDescription)" + ) + } + continuation.resume(returning: metadata) + } + } + } + + private static func parseCaptureDate(from metadata: [AnyHashable: Any]) -> Date? { + if let exif = metadata[kCGImagePropertyExifDictionary as String] as? [String: Any], + let date = parseEXIFDateString(exif[kCGImagePropertyExifDateTimeOriginal as String] as? String) { + return date + } + + if let tiff = metadata[kCGImagePropertyTIFFDictionary as String] as? [String: Any], + let date = parseEXIFDateString(tiff[kCGImagePropertyTIFFDateTime as String] as? String) { + return date + } + + if let date = parseEXIFDateString(metadata[kCGImagePropertyExifDateTimeOriginal as String] as? String) { + return date + } + + return nil + } + + private static func parseEXIFDateString(_ value: String?) -> Date? { + guard let value, !value.isEmpty else { return nil } + return exifDateFormatter.date(from: value) + } + + /// 部分机身文件名含 `YYYYMMDD_HHMMSS` 或 `IMG_YYYYMMDD_HHMMSS`。 + private static func parseCaptureDate(fromFilename filename: String) -> Date? { + let stem = (filename as NSString).deletingPathExtension + let patterns = [ + (#"(\d{8})_(\d{6})"#, ["yyyyMMdd", "HHmmss"]), + (#"IMG_(\d{8})_(\d{6})"#, ["yyyyMMdd", "HHmmss"]) + ] + + for (pattern, formats) in patterns { + guard let regex = try? NSRegularExpression(pattern: pattern), + let match = regex.firstMatch(in: stem, range: NSRange(stem.startIndex..., in: stem)), + match.numberOfRanges == 3, + let dateRange = Range(match.range(at: 1), in: stem), + let timeRange = Range(match.range(at: 2), in: stem) else { + continue + } + + let datePart = String(stem[dateRange]) + let timePart = String(stem[timeRange]) + guard let date = filenameDateFormatter.date(from: datePart), + let time = filenameTimeFormatter.date(from: timePart) else { + continue + } + + var components = Calendar.current.dateComponents([.year, .month, .day], from: date) + let timeComponents = Calendar.current.dateComponents([.hour, .minute, .second], from: time) + components.hour = timeComponents.hour + components.minute = timeComponents.minute + components.second = timeComponents.second + return Calendar.current.date(from: components) + } + + return nil + } + + private static let exifDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" + return formatter + }() + + private static let filenameDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyyMMdd" + return formatter + }() + + private static let filenameTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "HHmmss" + return formatter + }() +} diff --git a/suixinkan/Core/CameraOTG/Core/ICCameraThumbnailLoader.swift b/suixinkan/Core/CameraOTG/Core/ICCameraThumbnailLoader.swift new file mode 100644 index 0000000..71af40c --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/ICCameraThumbnailLoader.swift @@ -0,0 +1,40 @@ +// +// ICCameraThumbnailLoader.swift +// otg_swift +// + +import Foundation +import ImageCaptureCore + +/// 通过 ImageCaptureCore 请求相机内文件的缩略图 Data。 +enum ICCameraThumbnailLoader { + + static func loadThumbnailData( + for object: CameraObject, + from device: ICCameraDevice, + maxPixelSize: Int + ) async -> Data? { + guard let file = ICCameraItemScanner.findFile(matching: object, in: device) else { + return nil + } + return await loadThumbnailData(from: file, maxPixelSize: maxPixelSize) + } + + /// `maxPixelSize` 控制缩略图最长边像素,用于导入网格展示。 + static func loadThumbnailData(from file: ICCameraFile, maxPixelSize: Int) async -> Data? { + await withCheckedContinuation { continuation in + let options: [ICCameraItemThumbnailOption: Any] = [ + ICCameraItemThumbnailOption.imageSourceThumbnailMaxPixelSize: maxPixelSize + ] + + file.requestThumbnailData(options: options) { data, error in + if let error { + OTGLog.debug(.connection, "thumbnail failed for \(file.name ?? "nil"): \(error.localizedDescription)") + continuation.resume(returning: nil) + return + } + continuation.resume(returning: data) + } + } + } +} diff --git a/suixinkan/Core/CameraOTG/Core/OTGLog.swift b/suixinkan/Core/CameraOTG/Core/OTGLog.swift new file mode 100644 index 0000000..fc666f4 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/OTGLog.swift @@ -0,0 +1,112 @@ +// +// OTGLog.swift +// otg_swift +// + +import Foundation +import os + +/// OTG 统一日志,Xcode Console 过滤关键字:`[OTG]` +enum OTGLog { + + /// Console 过滤前缀 + static let filterPrefix = "[OTG]" + + /// 日志级别,映射 os.Logger 与控制台输出 + enum Level: String { + /// 调试细节,仅 DEBUG 构建打印到 Xcode Console + case debug = "DEBUG" + /// 正常流程节点(连接、PTP 成功等) + case info = "INFO" + /// 可恢复异常或降级路径 + case warning = "WARN" + /// 失败路径,需用户或开发者关注 + case error = "ERROR" + } + + /// 日志分类,对应 Console 中 `[Category]` 段 + enum Category: String { + /// ConnectionManager、ICCamera 扫描与 Session + case connection = "Connection" + /// 通用 PTP 命令发送与解析 + case ptp = "PTP" + /// PlatformDetector 品牌识别 + case detector = "Detector" + /// CameraFactory Driver 创建 + case factory = "Factory" + /// NikonCameraDriver + case nikon = "Nikon" + /// CanonCameraDriver + case canon = "Canon" + /// Sony Driver、SDIO 与边拍边传 + case sony = "Sony" + } + + private static let osLog = Logger( + subsystem: Bundle.main.bundleIdentifier ?? "otg_swift", + category: "OTG" + ) + + static func debug( + _ category: Category, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + log(.debug, category: category, message: message(), file: file, line: line) + } + + static func info( + _ category: Category, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + log(.info, category: category, message: message(), file: file, line: line) + } + + static func warning( + _ category: Category, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + log(.warning, category: category, message: message(), file: file, line: line) + } + + static func error( + _ category: Category, + _ message: @autoclosure () -> String, + file: String = #file, + line: Int = #line + ) { + log(.error, category: category, message: message(), file: file, line: line) + } + + private static func log( + _ level: Level, + category: Category, + message: String, + file: String, + line: Int + ) { + let fileName = (file as NSString).lastPathComponent + let formatted = "\(filterPrefix)[\(level.rawValue)][\(category.rawValue)] \(message) (\(fileName):\(line))" + + #if DEBUG + // DEBUG 仅用 print,避免与 osLog 在 Xcode Console 重复输出 + print(formatted) + #else + switch level { + case .debug: + osLog.debug("\(formatted)") + case .info: + osLog.info("\(formatted)") + case .warning: + osLog.warning("\(formatted)") + case .error: + osLog.error("\(formatted)") + } + #endif + } +} diff --git a/suixinkan/Core/CameraOTG/Core/PlatformDetector.swift b/suixinkan/Core/CameraOTG/Core/PlatformDetector.swift new file mode 100644 index 0000000..2a4cdad --- /dev/null +++ b/suixinkan/Core/CameraOTG/Core/PlatformDetector.swift @@ -0,0 +1,111 @@ +// +// PlatformDetector.swift +// otg_swift +// + +import Foundation + +/// 根据 USB Vendor ID 与设备名字符串识别相机品牌。 +enum PlatformDetector { + + private struct Rule { + let platform: CameraPlatform + let keywords: [String] + } + + /// USB Vendor ID → 品牌(最可靠,优先于型号字符串匹配) + private static let vendorIDMap: [Int: CameraPlatform] = [ + 0x054C: .sony, // Sony + 0x04A9: .canon, // Canon + 0x04B0: .nikon // Nikon + ] + + /// 品牌词 + 常见 USB 设备名型号特征(VID 不可用时的兜底) + private static let rules: [Rule] = [ + Rule(platform: .nikon, keywords: [ + "nikon", + "dsc-n", + "coolpix", + "z fc", + "z 6", "z 7", "z 8", "z 9", + "z6", "z7", "z8", "z9", + "d3", "d4", "d5", "d6", "d7", "d8", "d850", "d750" + ]), + Rule(platform: .canon, keywords: [ + "canon", + "eos ", + "eos-", + "powershot", + "ixus", + "eos m", "eos r", "eos 5d", "eos 6d", "eos 7d", "eos 90d" + ]), + Rule(platform: .sony, keywords: [ + "sony", + "alpha", + "zv-", + "zv e", + "ilce-", + "ilca-", + "dsc-", + "nex-", + "fx3", + "fx30", + "hdr-", + "a7", "a9", "a1" + ]) + ] + + static func detect(from info: CameraDeviceInfo) -> CameraPlatform { + // VID 最可靠:厂商 ID 由 USB 描述符提供,优于型号字符串模糊匹配 + if let vendorID = info.usbVendorID, vendorID != 0, + let platform = vendorIDMap[vendorID] { + OTGLog.info( + .detector, + "detected platform=\(platform.rawValue), matched=usbVendorID:0x\(String(format: "%04X", vendorID)), device=\"\(info.name)\"" + ) + return platform + } + + let text = normalizedText(from: info) + + for rule in rules { + if let keyword = rule.keywords.first(where: { matches(text, keyword: $0) }) { + OTGLog.info( + .detector, + "detected platform=\(rule.platform.rawValue), matched=\"\(keyword)\", text=\"\(text)\"" + ) + return rule.platform + } + } + + let vidHint = info.usbVendorID.map { "usbVendorID=0x\(String(format: "%04X", $0))" } ?? "usbVendorID=nil" + OTGLog.warning(.detector, "unknown platform, \(vidHint), text=\"\(text)\"") + return .unknown + } + + private static func normalizedText(from info: CameraDeviceInfo) -> String { + [info.manufacturer, info.model, info.name] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: " ", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func matches(_ text: String, keyword: String) -> Bool { + let key = keyword.lowercased() + guard !key.isEmpty else { return false } + + if text.contains(key) { + return true + } + + let compactText = text.replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + let compactKey = key.replacingOccurrences(of: " ", with: "") + .replacingOccurrences(of: "-", with: "") + + // 兼容 "ZV-E10" vs "ZVE10" 等空格/连字符差异 + return !compactKey.isEmpty && compactText.contains(compactKey) + } +} diff --git a/suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift b/suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift new file mode 100644 index 0000000..cfc979c --- /dev/null +++ b/suixinkan/Core/CameraOTG/PTP/PTPUtilities.swift @@ -0,0 +1,318 @@ +// +// PTPUtilities.swift +// otg_swift +// +// Created by hanqiu on 2026/7/3. +// + +import Foundation +import ImageCaptureCore + +/// PTP Container 类型字段(ISO 15740)。 +enum PTPContainerType: UInt16 { + /// 主机发出的操作命令包 + case command = 0x0001 + /// 伴随命令的数据阶段(out/in Data) + case data = 0x0002 + /// 设备对命令的响应包 + case response = 0x0003 + /// 设备主动上报的事件包(如 Sony SDIE) + case event = 0x0004 +} + +/// 解析后的 PTP 容器:命令、数据、响应或事件包。 +struct PTPContainer { + let length: UInt32 + let type: UInt16 + let code: UInt16 + let transactionID: UInt32 + let params: [UInt32] +} + +/// PTP Container 解析失败原因。 +enum PTPParseError: Error { + /// 数据长度不足 12 字节(Container 最小头) + case tooShort + /// length 字段与 buffer 实际长度不一致 + case invalidLength +} + +/// 标准 PTP 操作码与响应码(ISO 15740)。 +enum PTPStandardCommand { + /// GetDeviceInfo (0x1001),用于 PTP 管线 Probe + static let getDeviceInfo: UInt16 = 0x1001 + /// GetObjectInfo (0x1008),Probe 虚拟对象元数据 + static let getObjectInfo: UInt16 = 0x1008 + /// GetObject (0x1009),下载对象二进制(Step 6) + static let getObject: UInt16 = 0x1009 + /// ObjectAdded 事件 (0x4002) + static let objectAdded: UInt16 = 0x4002 + /// RequestObjectTransfer 事件 (0x4009) + static let requestObjectTransfer: UInt16 = 0x4009 + /// Response OK (0x2001),命令成功响应码 + static let responseOK: UInt16 = 0x2001 +} + +/// PTP 包构建、解析与经 ImageCaptureCore 发送的通用工具。 +enum PTPHelper { + + // MARK: - Build + + /// 构建 PTP Command Container(小端序,length = 12 + 4×参数个数)。 + 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 + } + + // MARK: - Parse + + /// 从二进制数据解析 PTP Container;参数区按 4 字节对齐读取。 + 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 + ) + } + + /// 从 PTP Event Container 提取事件码(如 Sony 0xC203)。 + static func parseEventCode(from eventData: Data) -> UInt16? { + try? parseContainer(eventData).code + } + + static func isResponseOK(_ response: Data) -> Bool { + guard !response.isEmpty else { return true } + guard let parsed = try? parseContainer(response) else { return false } + return parsed.code == PTPStandardCommand.responseOK + } + + static func hexString(_ data: Data) -> String { + data.map { String(format: "%02X", $0) }.joined(separator: " ") + } + + /// 同一文件名+大小视为同一张待传照片,用于边拍边传去重。 + static func objectSignature(filename: String, size: UInt32) -> String { + "\(filename)_\(size)" + } + + /// ObjectInfo 固定布局:offset 8 为 CompressedSize(UInt32 LE)。 + static func parseObjectCompressedSize(_ data: Data) -> UInt32? { + guard data.count >= 12 else { return nil } + return readUInt32LE(data, offset: 8) + } + + /// 从 ObjectInfo 解析文件名;优先 PTP 字符串格式,失败则扫描 UTF-16LE。 + static func parseObjectInfoFilename(_ data: Data) -> String? { + guard data.count >= 53 else { return nil } + + if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty { + return 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 sanitizeFilename(raw) + } + + /// PTP 字符串:1 字节字符数 + UTF-16LE 字符序列(无 NUL 终止)。 + 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.. String { + let withoutNulls = name.replacingOccurrences(of: "\0", with: "") + let trimmed = withoutNulls.trimmingCharacters(in: .whitespacesAndNewlines.union(.controlCharacters)) + let invalid = CharacterSet(charactersIn: "/\\:?%*|\"<>") + let cleaned = trimmed.components(separatedBy: invalid).joined(separator: "_") + return cleaned.isEmpty ? "photo.jpg" : cleaned + } + + /// 粗略判断 inData 是否为 JPEG/PNG 或足够大的二进制块。 + 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 + } + + // MARK: - Send + + struct CommandResult { + let success: Bool + let response: Data + let inData: Data + } + + /// 经 `requestSendPTPCommand` 发送 PTP 命令;调用方传入并递增 `transactionID`。 + /// 必须在主线程发起(ImageCaptureCore 要求),completion 内 resume continuation。 + static func sendCommand( + on camera: ICCameraDevice, + code: UInt16, + transactionID: inout UInt32, + parameters: [UInt32] = [], + outData: Data? = nil, + label: String + ) async -> CommandResult { + let currentID = transactionID + transactionID += 1 + + let commandData = buildCommand( + code: code, + transactionID: currentID, + parameters: parameters + ) + + OTGLog.info(.ptp, "PTP command sending: \(label), bytes=\(commandData.count)") + + return await withCheckedContinuation { continuation in + let finish: (CommandResult) -> Void = { result in + // continuation 必须在主线程 resume,避免 UI/IC 线程问题 + if Thread.isMainThread { + continuation.resume(returning: result) + } else { + DispatchQueue.main.async { + continuation.resume(returning: result) + } + } + } + + let sendPTP = { + camera.requestSendPTPCommand( + commandData, + outData: outData, + completion: { inData, response, error in + OTGLog.info( + .ptp, + "PTP callback received: \(label), inData=\(inData.count), response=\(response.count)" + ) + + if let error { + OTGLog.error(.ptp, "PTP \(label) failed: \(error.localizedDescription)") + finish(CommandResult(success: false, response: response, inData: inData)) + return + } + + var ok = true + if !response.isEmpty { + if let parsed = try? parseContainer(response) { + // 非 0x2001 ResponseOK 视为失败 + if parsed.code != PTPStandardCommand.responseOK { + ok = false + OTGLog.warning( + .ptp, + "PTP \(label) response code=0x\(String(format: "%04X", parsed.code))" + ) + } + } else { + ok = false + OTGLog.warning(.ptp, "PTP \(label) response parse failed") + } + } + + if ok { + OTGLog.info(.ptp, "PTP \(label) OK, inData=\(inData.count) bytes") + } + + finish(CommandResult(success: ok, response: response, inData: inData)) + } + ) + } + + // ImageCaptureCore PTP API 要求从主线程调用 + if Thread.isMainThread { + sendPTP() + } else { + DispatchQueue.main.async(execute: sendPTP) + } + } + } + + // MARK: - Little Endian + + 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/CameraOTG/Storage/OTGPhotoFileWriter.swift b/suixinkan/Core/CameraOTG/Storage/OTGPhotoFileWriter.swift new file mode 100644 index 0000000..a672f49 --- /dev/null +++ b/suixinkan/Core/CameraOTG/Storage/OTGPhotoFileWriter.swift @@ -0,0 +1,100 @@ +import Foundation + +/// OTG 照片文件写入工具,按账号与相册 scoped 目录落盘(不依赖旅拍模块)。 +enum OTGPhotoFileWriter { + private static let rootFolderName = "CameraDownloads" + + /// Documents 根目录。 + static var documentsDirectory: URL { + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + } + + /// 当前账号与相册的原图目录。 + static func originalsDirectory(accountKey: String, albumID: Int) -> URL { + let account = sanitizePathComponent(accountKey, fallback: "guest") + let directory = documentsDirectory + .appendingPathComponent(rootFolderName, isDirectory: true) + .appendingPathComponent(account, isDirectory: true) + .appendingPathComponent("\(max(albumID, 0))", isDirectory: true) + .appendingPathComponent("originals", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + /// 将绝对路径转为相对 Documents 的路径。 + static func relativePath(for url: URL) -> String { + let standardizedURL = url.standardizedFileURL + let documentsPath = documentsDirectory.standardizedFileURL.path + let filePath = standardizedURL.path + if filePath.hasPrefix(documentsPath + "/") { + return String(filePath.dropFirst(documentsPath.count + 1)) + } + let privateDocumentsPath = "/private" + documentsPath + if filePath.hasPrefix(privateDocumentsPath + "/") { + return String(filePath.dropFirst(privateDocumentsPath.count + 1)) + } + return filePath + } + + /// 在 scoped 原图目录分配不冲突的文件 URL。 + static func uniqueFileURL(filename: String, accountKey: String, albumID: Int) throws -> URL { + let directory = originalsDirectory(accountKey: accountKey, albumID: albumID) + let sanitized = PTPHelper.sanitizeFilename(filename) + let baseURL = directory.appendingPathComponent(sanitized) + if !FileManager.default.fileExists(atPath: baseURL.path) { + return baseURL + } + + let stem = (sanitized as NSString).deletingPathExtension + let ext = (sanitized as NSString).pathExtension + var counter = 1 + while counter < 10_000 { + let suffix = ext.isEmpty ? "\(stem)_\(counter)" : "\(stem)_\(counter).\(ext)" + let candidate = directory.appendingPathComponent(suffix) + if !FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + counter += 1 + } + throw CameraError.connectionFailed("无法分配唯一文件名") + } + + /// 原子写入图像数据。 + static func writeImage( + _ data: Data, + filename: String, + accountKey: String, + albumID: Int + ) throws -> URL { + let url = try uniqueFileURL(filename: filename, accountKey: accountKey, albumID: albumID) + try data.write(to: url, options: .atomic) + return url + } + + /// 将已下载文件移动到 scoped 原图目录。 + static func moveDownloadedFile( + from source: URL, + filename: String, + accountKey: String, + albumID: Int + ) throws -> URL { + let destination = try uniqueFileURL(filename: filename, accountKey: accountKey, albumID: albumID) + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.moveItem(at: source, to: destination) + return destination + } + + private static func sanitizePathComponent(_ value: String, fallback: String) -> String { + let trimmed = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: ":", with: "_") + let ascii = trimmed.unicodeScalars.filter { scalar in + scalar.isASCII && (CharacterSet.alphanumerics.contains(scalar) || scalar == "_" || scalar == "-") + } + let component = String(String.UnicodeScalarView(ascii)) + return component.isEmpty ? fallback : component + } +} diff --git a/suixinkan/Features/TravelAlbum/Models/CameraConnectionState.swift b/suixinkan/Features/TravelAlbum/Models/CameraConnectionState.swift index 4649064..7b30bb5 100644 --- a/suixinkan/Features/TravelAlbum/Models/CameraConnectionState.swift +++ b/suixinkan/Features/TravelAlbum/Models/CameraConnectionState.swift @@ -1,6 +1,6 @@ import Foundation -/// 有线传图页展示的相机连接状态(UI 层使用,OTG 连接能力已移除)。 +/// 有线传图页展示的相机连接状态(UI 层使用)。 enum CameraConnectionState: Equatable { case disconnected case searching diff --git a/suixinkan/Features/TravelAlbum/Models/SonyMTPHelpContent.swift b/suixinkan/Features/TravelAlbum/Models/SonyMTPHelpContent.swift new file mode 100644 index 0000000..e029b14 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Models/SonyMTPHelpContent.swift @@ -0,0 +1,41 @@ +import Foundation + +/// 单条索尼 MTP 帮助说明段落。 +struct SonyMTPHelpSection: Equatable, Identifiable { + var id: String { title } + let title: String + let body: String +} + +/// 索尼历史照片导入需 MTP 模式的说明文案。 +enum SonyMTPHelpContent { + static let navigationTitle = "索尼 MTP 导入说明" + + static let introduction = """ + 当前可能处于「电脑遥控(PC Remote)」等 USB 模式,App 无法读取 SD 卡历史照片。\ + 若要使用「历史导入」浏览相机内已有照片,请先将相机切换为 MTP 模式。 + """ + + static let sections: [SonyMTPHelpSection] = [ + SonyMTPHelpSection( + title: "1. USB 连接模式", + body: "进入相机菜单:设置 → USB 连接 → 选择「MTP」或「自动」(优先 MTP)。" + ), + SonyMTPHelpSection( + title: "2. 关闭遥控连接", + body: "关闭:网络 → 电脑遥控、智能手机连接。避免相机仍处于 PC Remote 传图状态。" + ), + SonyMTPHelpSection( + title: "3. 保存目的地", + body: "静态影像保存目的地勿选「仅电脑」;建议「仅拍摄装置」或「电脑+拍摄装置」。" + ), + SonyMTPHelpSection( + title: "4. 重新连接", + body: "修改设置后,拔插 USB 线,回到 App 重新连接相机,再点击「历史导入」。" + ), + SonyMTPHelpSection( + title: "5. 说明", + body: "MTP 连接期间机身可能无法按快门。边拍边传请使用 PC Remote 模式,历史导入请使用 MTP 模式。" + ) + ] +} diff --git a/suixinkan/Features/TravelAlbum/Services/ConnectionState+CameraConnectionState.swift b/suixinkan/Features/TravelAlbum/Services/ConnectionState+CameraConnectionState.swift new file mode 100644 index 0000000..6424526 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/ConnectionState+CameraConnectionState.swift @@ -0,0 +1,20 @@ +import Foundation + +/// 将 OTG 引擎连接状态映射为有线传图页展示状态。 +extension ConnectionState { + /// 转换为旅拍有线传图 UI 使用的连接状态。 + var cameraConnectionState: CameraConnectionState { + switch self { + case .idle, .disconnected: + return .disconnected + case .searching: + return .searching + case .connecting: + return .connecting + case .connected(_, let deviceName): + return .connected(deviceName: deviceName) + case .failed(let message): + return .error(message) + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Services/OTGLiveTransferCoordinator.swift b/suixinkan/Features/TravelAlbum/Services/OTGLiveTransferCoordinator.swift new file mode 100644 index 0000000..4044208 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/OTGLiveTransferCoordinator.swift @@ -0,0 +1,129 @@ +import Foundation + +/// OTG 连接与旅拍有线传图管道之间的协调器。 +@MainActor +final class OTGLiveTransferCoordinator: NSObject, ConnectionManagerDelegate { + var onConnectionStateChange: ((CameraConnectionState) -> Void)? + var onContentCatalogReady: (() -> Void)? + var onLiveShotSaved: ((SavedOTGPhoto) -> Void)? + var onError: ((String) -> Void)? + var onSuggestReplug: (() -> Void)? + + private(set) var currentDriver: (any CameraDriver)? + private(set) var isContentCatalogReady = false + + private let connectionManager: ConnectionManager + private let pipeline: WiredCameraTransferPipeline + private var photoSink: TravelAlbumOTGPhotoSink? + private var liveContext: OTGLiveTransferContext? + + /// 初始化协调器。 + init( + connectionManager: ConnectionManager = .shared, + pipeline: WiredCameraTransferPipeline + ) { + self.connectionManager = connectionManager + self.pipeline = pipeline + super.init() + } + + /// 配置边拍边传上下文并绑定 ConnectionManager。 + func start( + albumId: Int, + accountKey: String, + autoUploadProvider: @escaping () -> Bool, + existingObjectIDsProvider: @escaping () async -> Set + ) { + let sink = TravelAlbumOTGPhotoSink( + albumId: albumId, + accountKey: accountKey, + pipeline: pipeline, + existingObjectIDsProvider: existingObjectIDsProvider, + autoUploadProvider: autoUploadProvider + ) + photoSink = sink + liveContext = OTGLiveTransferContext(albumId: albumId, accountKey: accountKey, photoSink: sink) + + connectionManager.configureLiveTransfer(context: liveContext) + connectionManager.delegate = self + syncFromManager() + connectionManager.start() + } + + /// 解绑 UI 回调,保持设备缓存。 + func detach() { + connectionManager.unbindDelegate() + connectionManager.delegate = nil + } + + /// 主动断开相机连接。 + func disconnect() { + connectionManager.disconnect() + liveContext = nil + photoSink = nil + } + + /// 重新搜索 USB 相机。 + func restartSearch() { + connectionManager.start() + syncFromManager() + } + + /// 当前连接状态(旅拍 UI 格式)。 + var connectionState: CameraConnectionState { + connectionManager.state.cameraConnectionState + } + + /// 创建历史导入 ViewModel。 + func makeHistoryImportViewModel( + albumId: Int, + accountKey: String, + onImported: @escaping () -> Void + ) -> CameraHistoryImportViewModel? { + guard let driver = currentDriver, isContentCatalogReady, let photoSink else { return nil } + return CameraHistoryImportViewModel( + albumId: albumId, + accountKey: accountKey, + driver: driver, + photoSink: photoSink, + onImported: onImported + ) + } + + private func syncFromManager() { + currentDriver = connectionManager.currentDriver + isContentCatalogReady = connectionManager.isContentCatalogReady + onConnectionStateChange?(connectionManager.state.cameraConnectionState) + if isContentCatalogReady { + onContentCatalogReady?() + } + } + + // MARK: - ConnectionManagerDelegate + + func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) { + onConnectionStateChange?(state.cameraConnectionState) + } + + func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) { + currentDriver = driver + } + + func connectionManager(_ manager: ConnectionManager, didFail error: any Error) { + onError?(error.localizedDescription) + } + + func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) { + currentDriver = driver + isContentCatalogReady = true + onContentCatalogReady?() + } + + func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) { + onSuggestReplug?() + } + + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int) { + onLiveShotSaved?(photo) + } +} diff --git a/suixinkan/Features/TravelAlbum/Services/TravelAlbumOTGPhotoSink.swift b/suixinkan/Features/TravelAlbum/Services/TravelAlbumOTGPhotoSink.swift new file mode 100644 index 0000000..b51cdc8 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Services/TravelAlbumOTGPhotoSink.swift @@ -0,0 +1,96 @@ +import Foundation + +/// 旅拍相册 OTG 照片落盘实现:写入 scoped 本地目录并通知传输管道。 +@MainActor +final class TravelAlbumOTGPhotoSink: OTGPhotoSink { + private let albumId: Int + private let accountKey: String + private let pipeline: WiredCameraTransferPipeline + private let existingObjectIDsProvider: () async -> Set + private let autoUploadProvider: () -> Bool + + /// 初始化旅拍相册 OTG 落盘实现。 + init( + albumId: Int, + accountKey: String, + pipeline: WiredCameraTransferPipeline, + existingObjectIDsProvider: @escaping () async -> Set, + autoUploadProvider: @escaping () -> Bool + ) { + self.albumId = albumId + self.accountKey = accountKey + self.pipeline = pipeline + self.existingObjectIDsProvider = existingObjectIDsProvider + self.autoUploadProvider = autoUploadProvider + } + + /// 保存边拍边传图像数据。 + func saveLiveShot( + data: Data, + filename: String, + capturedAt: Date, + cameraObjectID: String + ) async throws -> SavedOTGPhoto { + let fileURL = try OTGPhotoFileWriter.writeImage( + data, + filename: filename, + accountKey: accountKey, + albumID: albumId + ) + return ingestSavedFile( + fileURL: fileURL, + filename: filename, + capturedAt: capturedAt, + cameraObjectID: cameraObjectID + ) + } + + /// 保存历史导入已下载文件。 + func saveDownloadedFile( + at url: URL, + filename: String, + capturedAt: Date, + cameraObjectID: String + ) async throws -> SavedOTGPhoto { + let fileURL = try OTGPhotoFileWriter.moveDownloadedFile( + from: url, + filename: filename, + accountKey: accountKey, + albumID: albumId + ) + return ingestSavedFile( + fileURL: fileURL, + filename: filename, + capturedAt: capturedAt, + cameraObjectID: cameraObjectID + ) + } + + /// 返回当前相册已导入的相机对象 ID。 + func existingCameraObjectIDs() async -> Set { + await existingObjectIDsProvider() + } + + private func ingestSavedFile( + fileURL: URL, + filename: String, + capturedAt: Date, + cameraObjectID: String + ) -> SavedOTGPhoto { + let relativePath = OTGPhotoFileWriter.relativePath(for: fileURL) + let autoUpload = autoUploadProvider() + pipeline.ingestDownloadedAsset( + assetID: cameraObjectID, + filename: filename, + localPath: relativePath, + capturedAt: capturedAt, + autoUpload: autoUpload + ) + return SavedOTGPhoto( + assetID: cameraObjectID, + relativeLocalPath: relativePath, + filename: filename, + capturedAt: capturedAt + ) + } +} diff --git a/suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift b/suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift index ec00f09..798a3ed 100644 --- a/suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift +++ b/suixinkan/Features/TravelAlbum/Services/WiredCameraTransferPipeline.swift @@ -142,6 +142,96 @@ final class WiredCameraTransferPipeline { lastNotifiedProgressByAssetID.removeValue(forKey: assetID) } + /// 将相机已下载到本地的照片纳入传输管道。 + func ingestDownloadedAsset( + assetID: String, + filename: String, + localPath: String, + capturedAt: Date?, + autoUpload: Bool + ) { + guard !excludedAssetIDs.contains(assetID) else { return } + + let capturedAtString = capturedAt.map { CameraTransferTask.capturedAtString(from: $0) } + ?? CameraTransferTask.capturedAtString(forLocalPath: localPath) + + if let index = tasks.firstIndex(where: { $0.assetID == assetID }) { + tasks[index].localPath = localPath + if tasks[index].capturedAt?.isEmpty != false { + tasks[index].capturedAt = capturedAtString + } + if tasks[index].status != .uploaded { + tasks[index].status = .downloaded + } + tasks[index].errorMessage = nil + } else { + tasks.insert( + CameraTransferTask( + assetID: assetID, + filename: filename, + localPath: localPath, + capturedAt: capturedAtString, + status: .downloaded + ), + at: 0 + ) + } + + if autoUpload, uploadEnabled, uploadSink != nil { + autoUploadEligibleAssetIDs.insert(assetID) + processUploadQueue() + } + + notify() + } + + /// 标记 asset 正在从相机下载。 + func markDownloading(assetID: String, filename: String) { + guard !excludedAssetIDs.contains(assetID) else { return } + if let index = tasks.firstIndex(where: { $0.assetID == assetID }) { + tasks[index].status = .downloading + tasks[index].errorMessage = nil + } else { + tasks.insert( + CameraTransferTask(assetID: assetID, filename: filename, status: .downloading), + at: 0 + ) + } + notify() + } + + /// 标记 asset 下载失败。 + func failDownload(assetID: String, message: String) { + guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } + tasks[index].status = .failed + tasks[index].errorMessage = message + notify() + } + + /// 并发上传边拍边传新照片;拍后传输任务只能由用户显式上传。 + private func processUploadQueue() { + guard uploadSink != nil else { return } + + while activeUploadAssetIDs.count < maxConcurrentUploads, + let task = tasks.first(where: { + $0.status == .downloaded + && !activeUploadAssetIDs.contains($0.assetID) + && autoUploadEligibleAssetIDs.contains($0.assetID) + }) { + startConcurrentUpload(assetID: task.assetID) + } + } + + private func startConcurrentUpload(assetID: String) { + guard !activeUploadAssetIDs.contains(assetID) else { return } + activeUploadAssetIDs.insert(assetID) + Task { @MainActor in + await self.uploadTask(assetID: assetID) + self.activeUploadAssetIDs.remove(assetID) + self.processUploadQueue() + } + } + private func uploadAssetIfNeeded(assetID: String) async { if activeUploadAssetIDs.contains(assetID) { return } guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return } diff --git a/suixinkan/Features/TravelAlbum/TravelAlbum.md b/suixinkan/Features/TravelAlbum/TravelAlbum.md index 46531e3..4a5e8c1 100644 --- a/suixinkan/Features/TravelAlbum/TravelAlbum.md +++ b/suixinkan/Features/TravelAlbum/TravelAlbum.md @@ -47,32 +47,50 @@ WiredTransferHeaderView # 蓝色顶栏:相册名 + 手机号 WiredTransferControlCard # 浮动白卡片:设备存储 / 连接状态 / 筛选 Chip / Tab WiredTransferSelectAllBar # 勾选模式全选栏(条件显示) WiredTransferPhotoListPanel # 时间侧栏 + 30 分钟分组列表 -WiredTransferBottomBar # 批量上传 + 指定上传 +WiredTransferBottomBar # 批量上传 + 指定上传 + 历史导入 SpecifyUploadBottomSheet # 指定上传选项弹层 +CameraHistoryImportView # 相机 SD 卡历史导入全屏 Sheet +SonyMTPHelpView # Sony MTP 模式帮助说明 ``` ## 有线传图交互流程 -1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 刷新本地持久化记录 -2. **批量上传三态**: +1. **USB 连接**:进入页面后 `OTGLiveTransferCoordinator` 驱动 `Core/CameraOTG` 的 `ConnectionManager` 搜索并连接 Sony / Canon / Nikon 相机;连接成功 Toast 提示,断开时弹窗警告 +2. **刷新**:catalog 就绪后点击「刷新」→ `refreshCameraFiles()` 同步相机内已有照片(拍后传输模式仅下载不上传);未连接时刷新本地持久化记录 +3. **历史导入**:底部「历史导入」在 `catalogReady && 已连接` 时可点,打开 `CameraHistoryImportView` 按日多选导入;Sony catalog 为空时展示 MTP 帮助入口 +4. **批量上传三态**: - 首次点击 → 进入勾选模式 - 有选中项 → 上传选中照片 - 无选中项 → 取消选择 -3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」 +5. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」 - 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材 -4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section -5. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展 -6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复 - - 「边拍边传」只自动上传该模式下由相机新照片事件发现的照片 - - 「拍后传输」下载到 App 的旧照片切换模式后不会自动补传,需通过批量上传、指定上传、勾选上传或单张重试显式上传 +6. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section +7. **格式 Chip**:当前固定展示 JPG,与「不修图」一样不可点击;RAW 等格式后续扩展 +8. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复 + - 「边拍边传」:相机新拍照片经 OTG 落盘后自动进入上传队列(最多 3 张并发) + - 「拍后传输」:下载到 App 的照片不自动上传,需通过批量上传、指定上传、勾选上传或单张重试显式上传 +9. **前后台**:App 回到前台且未连接时自动重新搜索相机;离开页面仅 detach UI,保持设备缓存(与 demo 一致) + +## OTG 与上传管道 + +``` +WiredCameraTransferView → WiredCameraTransferViewModel + → OTGLiveTransferCoordinator → ConnectionManager (Core/CameraOTG) + → TravelAlbumOTGPhotoSink → WiredCameraTransferPipeline → OSS 上传 +CameraHistoryImportView → CameraHistoryImportViewModel → CameraDriver + OTGPhotoSink +``` + +- OTG 引擎详见 `Core/CameraOTG/CameraOTG.md` +- 边拍边传路径:`ConnectionManager` live shot → `OTGPhotoSink` → `pipeline.ingestDownloadedAsset(autoUpload:)` → `TravelAlbumMaterialUploader` +- 历史导入路径:批量下载落盘,不自动上传,导入完成后刷新主列表 ## 业务流程 1. 创建任务:`POST .../travel-album/create` -2. 进入有线传图:有线传图 UI 页(USB/OTG 相机连接能力已移除,保留页面与本地照片上传) +2. 进入有线传图:USB 连接相机,边拍边传或拍后传输 / 历史导入 3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile` 4. 素材登记:`POST .../travel-album/upload-material` -5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads///originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录 +5. 本地照片路径以账号 + 相册 scoped 目录持久化(`CameraDownloads///originals/xxx.JPG`) ## 有线传图性能策略 @@ -85,16 +103,17 @@ SpecifyUploadBottomSheet # 指定上传选项弹层 - `WiredCameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议 - `TravelAlbumMaterialUploader` 实现 OSS + 后端登记 +- `OTGLiveTransferCoordinator` 桥接 `Core/CameraOTG` 与旅拍上传管道,通过 `OTGPhotoSink` 注入存储 - 与「相册管理」(`Features/Assets`)API、Store、路由完全隔离 ## 依赖模块 -- `Features/TravelAlbum/Services` — 本地存储、上传编排 +- `Core/CameraOTG` — USB 连接、品牌 Driver、边拍边传、PTP +- `Features/TravelAlbum/Services` — 本地存储、上传编排、OTG 协调器 - `Core/Upload` — OSS STS 上传 ## 二期待补齐 - RAW 预览 -- USB/OTG 相机有线连接与边拍边传 - 格式 Chip 扩展 RAW / JPEG+RAW,并与 `WiredCameraTransferPipeline` 联动 - 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗) diff --git a/suixinkan/Features/TravelAlbum/ViewModels/CameraHistoryImportViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/CameraHistoryImportViewModel.swift new file mode 100644 index 0000000..2eecc83 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/CameraHistoryImportViewModel.swift @@ -0,0 +1,220 @@ +import Foundation +import Combine + +/// 按日分组的相机照片区块。 +struct CameraPhotoSection: Equatable, Identifiable { + var id: String { dayKey } + let dayKey: String + let title: String + let photos: [CameraObject] +} + +/// 相机历史导入页状态机。 +enum CameraImportState: Equatable { + case idle + case loading + case loaded(sections: [CameraPhotoSection]) + case importing(current: Int, total: Int) + case finished(importedCount: Int) + case failed(message: String) +} + +/// 相机 SD 卡历史照片多选导入 ViewModel。 +@MainActor +final class CameraHistoryImportViewModel: ObservableObject { + @Published private(set) var state: CameraImportState = .idle + @Published private(set) var selectedPhotoIDs: Set = [] + @Published private(set) var shouldShowSonyMTPHelp = false + @Published private(set) var sections: [CameraPhotoSection] = [] + + let albumId: Int + let accountKey: String + private weak var driver: (any CameraDriver)? + private let photoSink: OTGPhotoSink + private let onImported: () -> Void + + private var allPhotos: [CameraObject] = [] + private var existingAlbumPhotoIDs: Set = [] + private var thumbnailCache: [String: Data] = [:] + + /// 初始化历史导入 ViewModel。 + init( + albumId: Int, + accountKey: String, + driver: any CameraDriver, + photoSink: OTGPhotoSink, + onImported: @escaping () -> Void + ) { + self.albumId = albumId + self.accountKey = accountKey + self.driver = driver + self.photoSink = photoSink + self.onImported = onImported + } + + /// 通过 Driver 拉取相机内照片并按日分组。 + func loadPhotos() { + guard let driver else { + state = .failed(message: "相机未连接") + return + } + + state = .loading + + Task { + do { + existingAlbumPhotoIDs = await photoSink.existingCameraObjectIDs() + let objects = try await driver.listObjects() + allPhotos = objects + let sections = Self.groupByDay(objects) + if sections.isEmpty { + self.sections = [] + state = .failed(message: "相机中没有可导入的照片") + } else { + self.sections = sections + state = .loaded(sections: sections) + } + updateSonyMTPHelpVisibility() + } catch { + state = .failed(message: error.localizedDescription) + updateSonyMTPHelpVisibility() + } + } + } + + /// 判断照片是否已在当前相册。 + func isPhotoAlreadyInAlbum(id: String) -> Bool { + existingAlbumPhotoIDs.contains(id) + } + + /// 切换单张照片选中状态。 + func togglePhoto(id: String) { + guard !isPhotoAlreadyInAlbum(id: id) else { return } + if selectedPhotoIDs.contains(id) { + selectedPhotoIDs.remove(id) + } else { + selectedPhotoIDs.insert(id) + } + } + + /// 切换分组全选。 + func toggleSection(_ section: CameraPhotoSection) { + let importableIDs = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)) + guard !importableIDs.isEmpty else { return } + if importableIDs.isSubset(of: selectedPhotoIDs) { + selectedPhotoIDs.subtract(importableIDs) + } else { + selectedPhotoIDs.formUnion(importableIDs) + } + } + + func isPhotoSelected(id: String) -> Bool { + selectedPhotoIDs.contains(id) + } + + func isSectionFullySelected(_ section: CameraPhotoSection) -> Bool { + let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id) + return !ids.isEmpty && ids.allSatisfy { selectedPhotoIDs.contains($0) } + } + + func hasImportablePhotos(in section: CameraPhotoSection) -> Bool { + section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) } + } + + /// 加载相机缩略图。 + func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? { + if let cached = thumbnailCache[photo.id] { + return cached + } + guard let driver else { return nil } + guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else { + return nil + } + thumbnailCache[photo.id] = data + return data + } + + /// 顺序下载选中项并写入本地存储。 + func importSelected() { + guard let driver else { return } + let selected = allPhotos.filter { + selectedPhotoIDs.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id) + } + guard !selected.isEmpty else { return } + + state = .importing(current: 0, total: selected.count) + + Task { + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("camera-history-import", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + var imported = 0 + + for (index, object) in selected.enumerated() { + do { + let downloadedURL = try await driver.downloadObject(object, to: tempDir) + _ = try await photoSink.saveDownloadedFile( + at: downloadedURL, + filename: object.filename, + capturedAt: object.capturedAt, + cameraObjectID: object.id + ) + imported += 1 + state = .importing(current: index + 1, total: selected.count) + } catch { + OTGLog.error(.connection, "import failed: \(object.filename) \(error.localizedDescription)") + } + } + + selectedPhotoIDs.removeAll() + state = .finished(importedCount: imported) + onImported() + } + } + + private func updateSonyMTPHelpVisibility() { + guard driver?.platform == .sony else { + shouldShowSonyMTPHelp = false + return + } + switch state { + case .loaded(let sections): + shouldShowSonyMTPHelp = sections.isEmpty + case .failed: + shouldShowSonyMTPHelp = allPhotos.isEmpty + default: + shouldShowSonyMTPHelp = false + } + } + + private static func groupByDay(_ photos: [CameraObject]) -> [CameraPhotoSection] { + let calendar = Calendar.current + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy年M月d日" + + let grouped = Dictionary(grouping: photos) { photo in + calendar.startOfDay(for: photo.capturedAt) + } + + return grouped.keys + .sorted(by: >) + .map { day in + let items = grouped[day]!.sorted { $0.capturedAt > $1.capturedAt } + let dayKey = dayKeyString(for: day, calendar: calendar) + let title = sectionTitle(for: day, formatter: formatter, calendar: calendar) + return CameraPhotoSection(dayKey: dayKey, title: title, photos: items) + } + } + + private static func dayKeyString(for day: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: day) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } + + private static func sectionTitle(for day: Date, formatter: DateFormatter, calendar: Calendar) -> String { + if calendar.isDateInToday(day) { return "今天" } + if calendar.isDateInYesterday(day) { return "昨天" } + return formatter.string(from: day) + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift index e3a83a5..f0164a7 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumViewModels.swift @@ -434,6 +434,9 @@ final class WiredCameraTransferViewModel: ObservableObject { @Published var showDeviceStorageAlert = false @Published var showCameraDisconnectedAlert = false @Published var showHelpDoc = false + @Published var showHistoryImport = false + @Published var showDisconnectCamera = false + @Published private(set) var isContentCatalogReady = false @Published var isRefreshingCameraFiles = false @Published var isSearchingCamera = false @Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot() @@ -442,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject { let context: WiredTransferContext private let pipeline = WiredCameraTransferPipeline() + private lazy var otgCoordinator = OTGLiveTransferCoordinator(pipeline: pipeline) private var photoStore: WiredTransferPhotoStore? private var userIDProvider: (() -> String)? private var downloadScope: CameraDownloadStorage.Scope? @@ -502,7 +506,21 @@ final class WiredCameraTransferViewModel: ObservableObject { pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope) refreshDeviceStorageInfo() loadPersistedPhotos() - connectionState = .disconnected + + bindOTGCoordinator() + otgCoordinator.start( + albumId: context.albumId, + accountKey: accountCachePrefix, + autoUploadProvider: { [weak self] in + self?.transferModeOption == Self.modeLiveCapture + }, + existingObjectIDsProvider: { [weak self] in + await self?.existingCameraObjectIDs() ?? [] + } + ) + connectionState = otgCoordinator.connectionState + isContentCatalogReady = otgCoordinator.isContentCatalogReady + wasConnected = connectionState.isConnected while !Task.isCancelled { try? await Task.sleep(nanoseconds: 1_000_000_000) @@ -510,6 +528,37 @@ final class WiredCameraTransferViewModel: ObservableObject { detachFromUI() } + private func bindOTGCoordinator() { + otgCoordinator.onConnectionStateChange = { [weak self] state in + self?.handleConnectionStateChange(state) + } + otgCoordinator.onContentCatalogReady = { [weak self] in + self?.isContentCatalogReady = true + } + otgCoordinator.onLiveShotSaved = { [weak self] (_: SavedOTGPhoto) in + self?.refreshDeviceStorageInfo() + } + otgCoordinator.onError = { [weak self] message in + self?.errorMessage = message + } + otgCoordinator.onSuggestReplug = { [weak self] in + self?.errorMessage = "长时间未找到相机,请尝试重新插拔连接线" + } + } + + private func existingCameraObjectIDs() async -> Set { + var ids = Set() + for record in persistedRecordsByID.values { + if let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath), + let objectID = ICCameraItemScanner.cameraObjectID(localPath: localURL.path) { + ids.insert(objectID) + } else { + ids.insert(record.id) + } + } + return ids + } + /// 页面离开:仅 detach UI 回调,保持相机连接。 func stop() async { detachFromUI() @@ -525,6 +574,7 @@ final class WiredCameraTransferViewModel: ObservableObject { disconnectAlertTask?.cancel() showCameraDisconnectedAlert = false pipeline.detachFromUI() + otgCoordinator.detach() } /// 刷新设备存储信息。 @@ -582,20 +632,98 @@ final class WiredCameraTransferViewModel: ObservableObject { errorMessage = nil } - /// 刷新相机文件列表(OTG 能力已移除,仅刷新本地持久化记录)。 + /// 刷新相机文件列表;catalog 就绪时同步相机内已有照片(拍后传输模式)。 func refreshCameraFiles() async { guard !isRefreshingCameraFiles else { return } isRefreshingCameraFiles = true defer { isRefreshingCameraFiles = false } + + if connectionState.isConnected, isContentCatalogReady, let driver = otgCoordinator.currentDriver { + do { + let objects = try await driver.listObjects() + for object in objects { + guard !deletedPhotoIDs.contains(object.id) else { continue } + guard !persistedRecordsByID.keys.contains(object.id) else { continue } + pipeline.markDownloading(assetID: object.id, filename: object.filename) + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("camera-sync", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let downloadedURL = try await driver.downloadObject(object, to: tempDir) + if let scope = downloadScope, + let sink = makeTransientPhotoSink(scope: scope) { + let saved = try await sink.saveDownloadedFile( + at: downloadedURL, + filename: object.filename, + capturedAt: object.capturedAt, + cameraObjectID: object.id + ) + pipeline.ingestDownloadedAsset( + assetID: saved.assetID, + filename: saved.filename, + localPath: saved.relativeLocalPath, + capturedAt: saved.capturedAt, + autoUpload: false + ) + } + } + } catch { + errorMessage = error.localizedDescription + } + return + } + loadPersistedPhotos() } - /// 重新搜索相机(OTG 能力已移除,保持未连接状态)。 + /// 重新搜索 USB 相机。 func restartCameraSearch() async { - guard !isSearchingCamera else { return } + guard !connectionState.isConnected, !isSearchingCamera else { return } isSearchingCamera = true defer { isSearchingCamera = false } + otgCoordinator.restartSearch() + connectionState = otgCoordinator.connectionState + } + + /// 断开相机连接。 + func disconnectCamera() { + otgCoordinator.disconnect() connectionState = .disconnected + isContentCatalogReady = false + wasConnected = false + } + + /// 打开历史导入页。 + func onHistoryImportClick() { + guard connectionState.isConnected, isContentCatalogReady else { + errorMessage = "请等待相机连接完成后再导入" + return + } + showHistoryImport = true + } + + /// 创建历史导入 ViewModel。 + func makeHistoryImportViewModel(accountKey: String) -> CameraHistoryImportViewModel? { + otgCoordinator.makeHistoryImportViewModel( + albumId: context.albumId, + accountKey: accountKey, + onImported: { [weak self] in + self?.loadPersistedPhotos() + } + ) + } + + private func makeTransientPhotoSink(scope: CameraDownloadStorage.Scope) -> TravelAlbumOTGPhotoSink? { + TravelAlbumOTGPhotoSink( + albumId: context.albumId, + accountKey: scope.accountKey, + pipeline: pipeline, + existingObjectIDsProvider: { [weak self] in + await self?.existingCameraObjectIDs() ?? [] + }, + autoUploadProvider: { [weak self] in + self?.transferModeOption == Self.modeLiveCapture + } + ) } /// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。 diff --git a/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportPhotoCell.swift b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportPhotoCell.swift new file mode 100644 index 0000000..9879e75 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportPhotoCell.swift @@ -0,0 +1,61 @@ +import SwiftUI + +/// 历史导入照片网格单元格。 +struct CameraHistoryImportPhotoCell: View { + let photo: CameraObject + let isImported: Bool + let isSelected: Bool + let thumbnailProvider: () async -> Data? + let onToggle: () -> Void + + @State private var thumbnail: UIImage? + + var body: some View { + ZStack(alignment: .topTrailing) { + Group { + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + } else { + Color.gray.opacity(0.15) + .overlay { + ProgressView() + .scaleEffect(0.7) + } + } + } + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + .clipped() + + if isImported { + Text("已导入") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.black.opacity(0.55)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(6) + } else { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? AppDesign.primary : .white) + .shadow(radius: 2) + .padding(6) + } + } + .contentShape(Rectangle()) + .onTapGesture { + guard !isImported else { return } + onToggle() + } + .task(id: photo.id) { + guard thumbnail == nil else { return } + if let data = await thumbnailProvider(), let image = UIImage(data: data) { + thumbnail = image + } + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportSectionHeader.swift b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportSectionHeader.swift new file mode 100644 index 0000000..b8a3979 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportSectionHeader.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// 历史导入分组标题栏。 +struct CameraHistoryImportSectionHeader: View { + let title: String + let isFullySelected: Bool + let hasImportable: Bool + let onToggleAll: () -> Void + + var body: some View { + HStack { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(WiredTransferDesign.text333) + Spacer() + if hasImportable { + Button(isFullySelected ? "取消全选" : "全选") { + onToggleAll() + } + .font(.system(size: 13)) + .foregroundStyle(AppDesign.primary) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(WiredTransferDesign.pageBackground) + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportView.swift b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportView.swift new file mode 100644 index 0000000..ace26e4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/CameraHistoryImportView.swift @@ -0,0 +1,140 @@ +import SwiftUI + +/// 相机历史导入主页面。 +struct CameraHistoryImportView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var viewModel: CameraHistoryImportViewModel + @State private var showSonyHelp = false + + init(viewModel: CameraHistoryImportViewModel) { + _viewModel = StateObject(wrappedValue: viewModel) + } + + var body: some View { + NavigationStack { + ZStack { + content + if case .importing = viewModel.state { + importingOverlay + } + } + .background(WiredTransferDesign.pageBackground.ignoresSafeArea()) + .navigationTitle("选择照片") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button(importButtonTitle) { + viewModel.importSelected() + } + .disabled(selectedCount == 0) + } + if viewModel.shouldShowSonyMTPHelp { + ToolbarItem(placement: .topBarTrailing) { + Button { + showSonyHelp = true + } label: { + Image(systemName: "questionmark.circle") + } + } + } + } + .task { viewModel.loadPhotos() } + .navigationDestination(isPresented: $showSonyHelp) { + SonyMTPHelpView() + } + .onChange(of: viewModel.state) { state in + if case .finished = state { + dismiss() + } + } + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.state { + case .idle, .loading: + ProgressView("正在加载相机照片…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded, .importing: + photoList(sections: viewModel.sections) + case .finished: + EmptyView() + case .failed(let message): + emptyView(message: message) + } + } + + private func photoList(sections: [CameraPhotoSection]) -> some View { + ScrollView { + LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) { + ForEach(sections) { section in + Section { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 2) { + ForEach(section.photos, id: \.id) { photo in + CameraHistoryImportPhotoCell( + photo: photo, + isImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id), + isSelected: viewModel.isPhotoSelected(id: photo.id), + thumbnailProvider: { await viewModel.thumbnailData(for: photo, maxPixelSize: 160) }, + onToggle: { viewModel.togglePhoto(id: photo.id) } + ) + } + } + } header: { + CameraHistoryImportSectionHeader( + title: section.title, + isFullySelected: viewModel.isSectionFullySelected(section), + hasImportable: viewModel.hasImportablePhotos(in: section), + onToggleAll: { viewModel.toggleSection(section) } + ) + } + } + } + .padding(.horizontal, 2) + } + } + + private func emptyView(message: String) -> some View { + VStack(spacing: 16) { + Text(message) + .font(.system(size: 15)) + .foregroundStyle(WiredTransferDesign.text666) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + if viewModel.shouldShowSonyMTPHelp { + Button("如何切换到 MTP 模式") { + showSonyHelp = true + } + .buttonStyle(.borderedProminent) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var importingOverlay: some View { + Group { + if case .importing(let current, let total) = viewModel.state { + VStack(spacing: 12) { + ProgressView() + Text("正在导入 \(current)/\(total)") + .font(.system(size: 15)) + } + .padding(24) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + } + } + + private var selectedCount: Int { + viewModel.selectedPhotoIDs.count + } + + private var importButtonTitle: String { + "导入 (\(selectedCount))" + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/SonyMTPHelpView.swift b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/SonyMTPHelpView.swift new file mode 100644 index 0000000..ba5f0b3 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/Views/CameraHistoryImport/SonyMTPHelpView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +/// 索尼 MTP 模式帮助页。 +struct SonyMTPHelpView: View { + @Environment(\.dismiss) private var dismiss + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(SonyMTPHelpContent.introduction) + .font(.system(size: 15)) + .foregroundStyle(WiredTransferDesign.text333) + + ForEach(SonyMTPHelpContent.sections) { section in + VStack(alignment: .leading, spacing: 8) { + Text(section.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(WiredTransferDesign.text333) + Text(section.body) + .font(.system(size: 14)) + .foregroundStyle(WiredTransferDesign.text666) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + .padding(16) + } + .background(WiredTransferDesign.pageBackground.ignoresSafeArea()) + .navigationTitle(SonyMTPHelpContent.navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { dismiss() } + } + } + } +} diff --git a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift index 8dfa7a2..d91cdfb 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredCameraTransferView.swift @@ -77,6 +77,7 @@ struct WiredCameraTransferView: View { WiredTransferBottomBar( selectUploadMode: viewModel.selectUploadMode, selectedCount: viewModel.selectedPhotoIDs.count, + isCameraConnected: viewModel.isCameraConnected, onBatchUploadClick: { Task { await viewModel.onBatchUploadButtonClick( @@ -86,7 +87,8 @@ struct WiredCameraTransferView: View { ) } }, - onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick + onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick, + onHistoryImportClick: viewModel.onHistoryImportClick ) } .background(WiredTransferDesign.pageBackground.ignoresSafeArea()) @@ -155,6 +157,13 @@ struct WiredCameraTransferView: View { } } } + .fullScreenCover(isPresented: $viewModel.showHistoryImport) { + if let importVM = viewModel.makeHistoryImportViewModel( + accountKey: accountContext.accountCachePrefix ?? "guest_" + ) { + CameraHistoryImportView(viewModel: importVM) + } + } .sheet(item: previewBinding) { item in WiredTransferPhotoPreviewSheet( imageSources: item.sources, diff --git a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift index 926c735..0429db2 100644 --- a/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift +++ b/suixinkan/Features/TravelAlbum/Views/WiredTransfer/WiredTransferBottomBar.swift @@ -9,8 +9,10 @@ import SwiftUI struct WiredTransferBottomBar: View { let selectUploadMode: Bool let selectedCount: Int + let isCameraConnected: Bool let onBatchUploadClick: () -> Void let onSpecifyUploadClick: () -> Void + let onHistoryImportClick: () -> Void private var batchButtonTitle: String { if !selectUploadMode { return "批量上传" } @@ -33,6 +35,20 @@ struct WiredTransferBottomBar: View { } .buttonStyle(.plain) + Button(action: onHistoryImportClick) { + Text("历史导入") + .font(.system(size: 15)) + .foregroundStyle(isCameraConnected && !selectUploadMode ? AppDesign.primary : WiredTransferDesign.text999) + .frame(maxWidth: .infinity) + .frame(height: WiredTransferDesign.bottomButtonHeight) + .overlay { + RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius) + .stroke(AppDesign.primary, lineWidth: 1) + } + } + .buttonStyle(.plain) + .disabled(selectUploadMode || !isCameraConnected) + Button(action: onSpecifyUploadClick) { Text("指定上传") .font(.system(size: 15)) diff --git a/suixinkanTests/CameraOTG/OTGPhotoStorageTests.swift b/suixinkanTests/CameraOTG/OTGPhotoStorageTests.swift new file mode 100644 index 0000000..cf11454 --- /dev/null +++ b/suixinkanTests/CameraOTG/OTGPhotoStorageTests.swift @@ -0,0 +1,82 @@ +// +// OTGPhotoStorageTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// OTGPhotoFileWriter scoped 路径写入与去重测试。 +final class OTGPhotoStorageTests: XCTestCase { + private let accountKey = "test_account" + private let albumID = 12345 + + override func tearDown() { + let directory = OTGPhotoFileWriter.originalsDirectory(accountKey: accountKey, albumID: albumID) + try? FileManager.default.removeItem(at: directory.deletingLastPathComponent()) + super.tearDown() + } + + /// 测试 scoped 原图目录按账号与相册隔离。 + func testOriginalsDirectoryScopedByAccountAndAlbum() { + let directory = OTGPhotoFileWriter.originalsDirectory(accountKey: accountKey, albumID: albumID) + XCTAssertTrue(directory.path.contains("CameraDownloads")) + XCTAssertTrue(directory.path.contains("\(albumID)")) + XCTAssertTrue(directory.lastPathComponent == "originals") + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path)) + } + + /// 测试 writeImage 落盘并返回相对路径。 + func testWriteImageReturnsRelativePath() throws { + let data = Data(repeating: 0xAB, count: 64) + let fileURL = try OTGPhotoFileWriter.writeImage( + data, + filename: "DSC_0001.JPG", + accountKey: accountKey, + albumID: albumID + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: fileURL.path)) + + let relativePath = OTGPhotoFileWriter.relativePath(for: fileURL) + XCTAssertFalse(relativePath.hasPrefix("/")) + XCTAssertTrue(relativePath.contains("DSC_0001.JPG")) + + let resolved = CameraDownloadStorage.resolveLocalURL(from: relativePath) + XCTAssertEqual(resolved?.standardizedFileURL, fileURL.standardizedFileURL) + } + + /// 测试同名文件自动追加序号避免覆盖。 + func testUniqueFileURLAppendsCounterForDuplicates() throws { + let data = Data(repeating: 0xCD, count: 16) + let firstURL = try OTGPhotoFileWriter.writeImage( + data, + filename: "duplicate.JPG", + accountKey: accountKey, + albumID: albumID + ) + let secondURL = try OTGPhotoFileWriter.uniqueFileURL( + filename: "duplicate.JPG", + accountKey: accountKey, + albumID: albumID + ) + + XCTAssertNotEqual(firstURL.lastPathComponent, secondURL.lastPathComponent) + XCTAssertTrue(secondURL.lastPathComponent.contains("duplicate_1")) + } + + /// 测试 moveDownloadedFile 将临时文件移入 scoped 目录。 + func testMoveDownloadedFileIntoScopedDirectory() throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("otg-move-test.JPG") + try Data(repeating: 0xEF, count: 32).write(to: tempURL) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let destination = try OTGPhotoFileWriter.moveDownloadedFile( + from: tempURL, + filename: "imported.JPG", + accountKey: accountKey, + albumID: albumID + ) + XCTAssertTrue(FileManager.default.fileExists(atPath: destination.path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) + } +} diff --git a/suixinkanTests/CameraOTG/PTPUtilitiesTests.swift b/suixinkanTests/CameraOTG/PTPUtilitiesTests.swift new file mode 100644 index 0000000..15f5b8c --- /dev/null +++ b/suixinkanTests/CameraOTG/PTPUtilitiesTests.swift @@ -0,0 +1,76 @@ +// +// PTPUtilitiesTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// PTP Container 构建与解析测试。 +final class PTPUtilitiesTests: XCTestCase { + /// 测试 buildCommand 与 parseContainer 往返一致。 + func testBuildAndParseCommandRoundTrip() throws { + let command = PTPHelper.buildCommand( + code: PTPStandardCommand.getObject, + transactionID: 42, + parameters: [1, 2, 3] + ) + + let parsed = try PTPHelper.parseContainer(command) + XCTAssertEqual(parsed.length, UInt32(12 + 4 * 3)) + XCTAssertEqual(parsed.type, PTPContainerType.command.rawValue) + XCTAssertEqual(parsed.code, PTPStandardCommand.getObject) + XCTAssertEqual(parsed.transactionID, 42) + XCTAssertEqual(parsed.params, [1, 2, 3]) + } + + /// 测试数据过短时抛出 tooShort。 + func testParseContainerTooShortThrows() { + XCTAssertThrowsError(try PTPHelper.parseContainer(Data([0x01, 0x02]))) { error in + XCTAssertEqual(error as? PTPParseError, .tooShort) + } + } + + /// 测试 length 与 buffer 不一致时抛出 invalidLength。 + func testParseContainerInvalidLengthThrows() { + var data = Data(count: 12) + data[0] = 32 + XCTAssertThrowsError(try PTPHelper.parseContainer(data)) { error in + XCTAssertEqual(error as? PTPParseError, .invalidLength) + } + } + + /// 测试 objectSignature 拼接规则。 + func testObjectSignature() { + XCTAssertEqual(PTPHelper.objectSignature(filename: "DSC_0001.JPG", size: 2048), "DSC_0001.JPG_2048") + } + + /// 测试 sanitizeFilename 去除非法路径字符。 + func testSanitizeFilename() { + XCTAssertEqual(PTPHelper.sanitizeFilename("DSC/0001.JPG"), "DSC_0001.JPG") + XCTAssertEqual(PTPHelper.sanitizeFilename(" "), "photo.jpg") + } + + /// 测试从 ObjectInfo 固定偏移解析压缩大小。 + func testParseObjectCompressedSize() { + var data = Data(count: 12) + data[8] = 0x00 + data[9] = 0x10 + data[10] = 0x00 + data[11] = 0x00 + XCTAssertEqual(PTPHelper.parseObjectCompressedSize(data), 4096) + } + + /// 测试 ResponseOK 判断。 + func testIsResponseOK() throws { + let okResponse = PTPHelper.buildCommand( + code: PTPStandardCommand.responseOK, + transactionID: 1, + parameters: [] + ) + var responseData = okResponse + responseData[4] = UInt8(PTPContainerType.response.rawValue & 0xFF) + responseData[5] = UInt8((PTPContainerType.response.rawValue >> 8) & 0xFF) + XCTAssertTrue(PTPHelper.isResponseOK(responseData)) + } +} diff --git a/suixinkanTests/CameraOTG/PlatformDetectorTests.swift b/suixinkanTests/CameraOTG/PlatformDetectorTests.swift new file mode 100644 index 0000000..70a7b02 --- /dev/null +++ b/suixinkanTests/CameraOTG/PlatformDetectorTests.swift @@ -0,0 +1,95 @@ +// +// PlatformDetectorTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// PlatformDetector USB VID 与型号关键字识别测试。 +final class PlatformDetectorTests: XCTestCase { + /// 测试 Sony USB Vendor ID 识别。 + func testDetectSonyByVendorID() { + let info = CameraDeviceInfo( + name: "USB Device", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: 0x054C, + usbProductID: 0x1234 + ) + XCTAssertEqual(PlatformDetector.detect(from: info), .sony) + } + + /// 测试 Canon USB Vendor ID 识别。 + func testDetectCanonByVendorID() { + let info = CameraDeviceInfo( + name: "USB Device", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: 0x04A9, + usbProductID: nil + ) + XCTAssertEqual(PlatformDetector.detect(from: info), .canon) + } + + /// 测试 Nikon USB Vendor ID 识别。 + func testDetectNikonByVendorID() { + let info = CameraDeviceInfo( + name: "USB Device", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: 0x04B0, + usbProductID: nil + ) + XCTAssertEqual(PlatformDetector.detect(from: info), .nikon) + } + + /// 测试 VID 不可用时按设备名关键字兜底识别。 + func testDetectByDeviceNameKeyword() { + let sony = CameraDeviceInfo( + name: "ILCE-7M4", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: nil, + usbProductID: nil + ) + XCTAssertEqual(PlatformDetector.detect(from: sony), .sony) + + let canon = CameraDeviceInfo( + name: "Canon EOS R5", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: nil, + usbProductID: nil + ) + XCTAssertEqual(PlatformDetector.detect(from: canon), .canon) + + let nikon = CameraDeviceInfo( + name: "Nikon Z 8", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: nil, + usbProductID: nil + ) + XCTAssertEqual(PlatformDetector.detect(from: nikon), .nikon) + } + + /// 测试无法识别时返回 unknown。 + func testDetectUnknownWhenNoMatch() { + let info = CameraDeviceInfo( + name: "Generic Webcam", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: nil, + usbProductID: nil + ) + XCTAssertEqual(PlatformDetector.detect(from: info), .unknown) + } +} diff --git a/suixinkanTests/TravelAlbum/CameraHistoryImportViewModelTests.swift b/suixinkanTests/TravelAlbum/CameraHistoryImportViewModelTests.swift new file mode 100644 index 0000000..e6fc83c --- /dev/null +++ b/suixinkanTests/TravelAlbum/CameraHistoryImportViewModelTests.swift @@ -0,0 +1,224 @@ +// +// CameraHistoryImportViewModelTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 相机历史导入 ViewModel 测试。 +final class CameraHistoryImportViewModelTests: XCTestCase { + /// 测试按日分组与今天/昨天标题。 + func testLoadPhotosGroupsByDay() async throws { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + + let driver = MockHistoryImportCameraDriver() + driver.objects = [ + makeObject(id: "today-1", filename: "today1.JPG", day: today, hour: 10), + makeObject(id: "today-2", filename: "today2.JPG", day: today, hour: 9), + makeObject(id: "yesterday-1", filename: "yesterday1.JPG", day: yesterday, hour: 15), + ] + + let sink = MockHistoryImportPhotoSink() + let viewModel = CameraHistoryImportViewModel( + albumId: 1, + accountKey: "test", + driver: driver, + photoSink: sink, + onImported: {} + ) + + viewModel.loadPhotos() + try await waitUntilLoaded(viewModel) + + guard case .loaded = viewModel.state else { + return XCTFail("expected loaded state, got \(viewModel.state)") + } + XCTAssertEqual(viewModel.sections.count, 2) + XCTAssertEqual(viewModel.sections[0].title, "今天") + XCTAssertEqual(viewModel.sections[0].photos.map(\.id), ["today-1", "today-2"]) + XCTAssertEqual(viewModel.sections[1].title, "昨天") + } + + /// 测试已导入照片不可选。 + func testAlreadyImportedPhotoIsNotSelectable() async throws { + let driver = MockHistoryImportCameraDriver() + driver.objects = [makeObject(id: "dup|1024", filename: "dup.JPG", day: Date(), hour: 12)] + + let sink = MockHistoryImportPhotoSink() + sink.existingIDs = ["dup|1024"] + + let viewModel = CameraHistoryImportViewModel( + albumId: 1, + accountKey: "test", + driver: driver, + photoSink: sink, + onImported: {} + ) + + viewModel.loadPhotos() + try await waitUntilLoaded(viewModel) + + XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: "dup|1024")) + viewModel.togglePhoto(id: "dup|1024") + XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty) + } + + /// 测试分组全选与取消全选。 + func testToggleSectionSelectsImportablePhotosOnly() async throws { + let day = Calendar.current.startOfDay(for: Date()) + let driver = MockHistoryImportCameraDriver() + driver.objects = [ + makeObject(id: "a|100", filename: "a.JPG", day: day, hour: 10), + makeObject(id: "b|200", filename: "b.JPG", day: day, hour: 9), + ] + + let sink = MockHistoryImportPhotoSink() + sink.existingIDs = ["b|200"] + + let viewModel = CameraHistoryImportViewModel( + albumId: 1, + accountKey: "test", + driver: driver, + photoSink: sink, + onImported: {} + ) + + viewModel.loadPhotos() + try await waitUntilLoaded(viewModel) + + let section = try XCTUnwrap(viewModel.sections.first) + viewModel.toggleSection(section) + XCTAssertEqual(viewModel.selectedPhotoIDs, ["a|100"]) + + viewModel.toggleSection(section) + XCTAssertTrue(viewModel.selectedPhotoIDs.isEmpty) + } + + /// 测试 Sony catalog 为空时显示 MTP 帮助入口。 + func testSonyEmptyCatalogShowsMTPHelp() async throws { + let driver = MockHistoryImportCameraDriver() + driver.platform = .sony + driver.objects = [] + + let viewModel = CameraHistoryImportViewModel( + albumId: 1, + accountKey: "test", + driver: driver, + photoSink: MockHistoryImportPhotoSink(), + onImported: {} + ) + + viewModel.loadPhotos() + try await waitForFailedState(viewModel) + + XCTAssertTrue(viewModel.shouldShowSonyMTPHelp) + } + + private func makeObject(id: String, filename: String, day: Date, hour: Int) -> CameraObject { + let capturedAt = Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day + return CameraObject(id: id, filename: filename, fileSize: 1024, capturedAt: capturedAt) + } + + private func waitUntilLoaded( + _ viewModel: CameraHistoryImportViewModel, + timeout: TimeInterval = 2 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if case .loaded = viewModel.state { return } + if case .failed = viewModel.state { + XCTFail("unexpected failed: \(viewModel.state)") + return + } + try await Task.sleep(nanoseconds: 50_000_000) + } + XCTFail("timed out waiting for loaded state") + } + + private func waitForFailedState( + _ viewModel: CameraHistoryImportViewModel, + timeout: TimeInterval = 2 + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if case .failed = viewModel.state { return } + try await Task.sleep(nanoseconds: 50_000_000) + } + XCTFail("timed out waiting for failed state") + } +} + +@MainActor +private final class MockHistoryImportCameraDriver: NSObject, CameraDriver { + var platform: CameraPlatform = .canon + var deviceInfo = CameraDeviceInfo( + name: "Mock Camera", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: nil, + usbProductID: nil + ) + var objects: [CameraObject] = [] + + func connect() async throws {} + func disconnect() {} + + func listObjects() async throws -> [CameraObject] { + objects + } + + func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? { + nil + } + + func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL { + let url = directory.appendingPathComponent(object.filename) + try Data(repeating: 0x01, count: 16).write(to: url) + return url + } +} + +@MainActor +private final class MockHistoryImportPhotoSink: OTGPhotoSink { + var existingIDs: Set = [] + private(set) var savedCount = 0 + + func saveLiveShot( + data: Data, + filename: String, + capturedAt: Date, + cameraObjectID: String + ) async throws -> SavedOTGPhoto { + savedCount += 1 + return SavedOTGPhoto( + assetID: cameraObjectID, + relativeLocalPath: "mock/\(filename)", + filename: filename, + capturedAt: capturedAt + ) + } + + func saveDownloadedFile( + at url: URL, + filename: String, + capturedAt: Date, + cameraObjectID: String + ) async throws -> SavedOTGPhoto { + savedCount += 1 + return SavedOTGPhoto( + assetID: cameraObjectID, + relativeLocalPath: "mock/\(filename)", + filename: filename, + capturedAt: capturedAt + ) + } + + func existingCameraObjectIDs() async -> Set { + existingIDs + } +} diff --git a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift index 4160aff..eba3005 100644 --- a/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift +++ b/suixinkanTests/TravelAlbum/WiredCameraTransferViewModelTests.swift @@ -233,6 +233,47 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer) } + /// 测试 ConnectionState 映射为 CameraConnectionState。 + func testConnectionStateMapsToCameraConnectionState() { + XCTAssertEqual(ConnectionState.idle.cameraConnectionState, .disconnected) + XCTAssertEqual(ConnectionState.disconnected.cameraConnectionState, .disconnected) + XCTAssertEqual(ConnectionState.searching.cameraConnectionState, .searching) + XCTAssertEqual(ConnectionState.connecting(deviceName: "EOS R5").cameraConnectionState, .connecting) + XCTAssertEqual( + ConnectionState.connected(platform: .sony, deviceName: "ILCE-7M4").cameraConnectionState, + .connected(deviceName: "ILCE-7M4") + ) + XCTAssertEqual( + ConnectionState.failed(message: "授权被拒绝").cameraConnectionState, + .error("授权被拒绝") + ) + } + + /// 测试边拍边传 ingest 会触发管道自动上传。 + func testPipelineIngestAutoUploadUploadsAsset() async throws { + let scope = CameraDownloadStorage.Scope(accountKey: "otg-pipe", albumID: 42) + let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "otg_live.JPG", scope: scope) + try Data(repeating: 0xAA, count: 32).write(to: fileURL) + defer { CameraDownloadStorage.removeScopeDirectory(for: scope) } + + let pipeline = WiredCameraTransferPipeline() + let sink = MockCameraAssetUploadSink() + let relativePath = CameraDownloadStorage.relativePath(for: fileURL) + + pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope) + pipeline.ingestDownloadedAsset( + assetID: "obj-1", + filename: "otg_live.JPG", + localPath: relativePath, + capturedAt: Date(), + autoUpload: true + ) + + try await Task.sleep(nanoseconds: 800_000_000) + XCTAssertEqual(sink.uploadedFileNames, ["otg_live.JPG"]) + XCTAssertEqual(pipeline.task(forAssetID: "obj-1")?.status, .uploaded) + } + /// 测试进入页面时会恢复缓存的传输模式。 func testStartRestoresCachedTransferModeOption() async { let account = makeTransferModeTestAccount() @@ -466,6 +507,22 @@ final class WiredCameraTransferViewModelTests: XCTestCase { } } +@MainActor +private final class MockCameraAssetUploadSink: CameraAssetUploadSink { + private(set) var uploadedFileNames: [String] = [] + + func upload( + localURL: URL, + fileName: String, + fileType: Int, + progress: @escaping @Sendable (Int) -> Void + ) async throws -> String { + uploadedFileNames.append(fileName) + progress(100) + return "https://cdn/mock/\(fileName)" + } +} + @MainActor private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing { func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }