diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index f6f2a59..e6555a2 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -59,15 +59,11 @@ }; 9A01B8E22FFB84950033C402 /* Vendor */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Vendor; sourceTree = ""; }; A1B2C3D42FFB84950033C305 /* suixinkanTests */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = suixinkanTests; sourceTree = ""; }; @@ -334,10 +330,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; diff --git a/suixinkan/Core/Upload/OSSUploadService.swift b/suixinkan/Core/Upload/OSSUploadService.swift index 7f55e6f..ef11643 100644 --- a/suixinkan/Core/Upload/OSSUploadService.swift +++ b/suixinkan/Core/Upload/OSSUploadService.swift @@ -74,6 +74,23 @@ final class OSSUploadService { ) } + /// 上传旅拍相册素材,对齐 Android `moduleType = album`。 + func uploadTravelAlbumMaterial( + data: Data, + fileName: String, + scenicId: Int, + onProgress: @escaping (Int) -> Void + ) async throws -> String { + try await uploadFile( + data: data, + fileName: fileName, + fileType: 2, + scenicId: scenicId, + moduleType: "album", + onProgress: onProgress + ) + } + private func uploadFile( data: Data, fileName: String, @@ -162,6 +179,8 @@ enum OSSUploadPolicy { return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "task_upload": return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" + case "album": + return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" case "scenic_apply": return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" default: diff --git a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift index e6556b4..6db2fe8 100644 --- a/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift +++ b/suixinkan/Features/TravelAlbum/API/TravelAlbumAPI.swift @@ -29,6 +29,9 @@ protocol TravelAlbumServing { isPurchased: Int? ) async throws -> TravelAlbumListResponse + /// 上传并登记旅拍相册素材。 + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial + /// 删除旅拍相册。 func deleteAlbum(id: Int) async throws @@ -107,6 +110,11 @@ final class TravelAlbumAPI: TravelAlbumServing { ) } + /// 上传并登记旅拍相册素材。 + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { + try await client.send(APIRequest(method: .post, path: "\(basePath)/upload-material", body: request)) + } + /// 删除旅拍相册。 func deleteAlbum(id: Int) async throws { let _: EmptyPayload = try await client.send( diff --git a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift index 9e609e2..8225ae8 100644 --- a/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift +++ b/suixinkan/Features/TravelAlbum/Models/TravelAlbumModels.swift @@ -198,6 +198,19 @@ struct TravelAlbumCreateRequest: Encodable, Sendable, Equatable { } } +/// 旅拍相册素材上传登记请求体,对齐 Android `TravelAlbumUploadMaterialRequest`。 +struct TravelAlbumUploadMaterialRequest: Encodable, Sendable, Equatable { + let userEquityTravelId: Int + let fileName: String + let fileUrl: String + + enum CodingKeys: String, CodingKey { + case userEquityTravelId = "user_equity_travel_id" + case fileName = "file_name" + case fileUrl = "file_url" + } +} + /// 旅拍相册通用分页响应。 struct TravelAlbumListResponse: Decodable, Sendable, Equatable { let total: Int diff --git a/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonCameraDriver.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonCameraDriver.swift new file mode 100644 index 0000000..3a976a7 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonCameraDriver.swift @@ -0,0 +1,59 @@ +// +// CanonCameraDriver.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +@preconcurrency 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/Features/TravelAlbum/OTG/Brands/Canon/CanonPTPCommands.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonPTPCommands.swift new file mode 100644 index 0000000..abc30f8 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Brands/Canon/CanonPTPHelper.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonPTPHelper.swift new file mode 100644 index 0000000..d13e4af --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonPTPHelper.swift @@ -0,0 +1,369 @@ +// +// CanonPTPHelper.swift +// otg_swift +// + +import Foundation +@preconcurrency 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/Features/TravelAlbum/OTG/Brands/Canon/CanonRemoteCaptureService.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonRemoteCaptureService.swift new file mode 100644 index 0000000..868dad5 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Canon/CanonRemoteCaptureService.swift @@ -0,0 +1,456 @@ +// +// CanonRemoteCaptureService.swift +// otg_swift +// + +import Foundation +@preconcurrency 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 var albumID: Int? + private let photoRepository: PhotoRepositoryProtocol + /// 下载成功时回调文件名,供 ConnectionManager 转发给 ViewModel。 + var onShotSaved: ((String) -> 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 + + init(photoRepository: PhotoRepositoryProtocol = TravelAlbumOTGPhotoRepository()) { + self.photoRepository = photoRepository + } + + // MARK: - Lifecycle + + /// catalog 就绪且远程会话初始化成功后启动。 + func start( + camera: ICCameraDevice, + startingTransactionID: UInt32, + albumID: Int? + ) { + stop() + + self.camera = camera + self.transactionID = startingTransactionID + self.albumID = albumID + 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), album=\(albumID.map(String.init) ?? "nil")") + 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 + albumID = 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 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?(filename) + } 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 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?(result.filename) + return true + } + + return false + } + + private func saveToAlbum(data: Data, filename: String, reason: String) async -> Bool { + guard let albumID else { + OTGLog.warning(.canon, "\(reason): live transfer album not configured, skip save") + return false + } + + do { + let fileURL = try AlbumPhotoStorage.writeImage( + data, + filename: filename, + albumID: albumID + ) + try photoRepository.addPhoto( + albumID: albumID, + localPath: fileURL.path, + createdAt: Date() + ) + return true + } catch { + OTGLog.error(.canon, "\(reason): save failed: \(error.localizedDescription)") + return false + } + } +} diff --git a/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCameraDriver.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCameraDriver.swift new file mode 100644 index 0000000..87c0f20 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCameraDriver.swift @@ -0,0 +1,58 @@ +// +// NikonCameraDriver.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +@preconcurrency 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/Features/TravelAlbum/OTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift new file mode 100644 index 0000000..a8aeff5 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Nikon/NikonCatalogLiveCaptureService.swift @@ -0,0 +1,210 @@ +// +// NikonCatalogLiveCaptureService.swift +// otg_swift +// + +import Foundation +@preconcurrency import ImageCaptureCore + +/// 尼康边拍边传:监听 ImageCaptureCore catalog 新增文件并下载写入相册。 +/// Z6 系列等在快门后会触发 `didAdd`,无需 Nikon 专用 PTP 协议。 +final class NikonCatalogLiveCaptureService { + + private weak var camera: ICCameraDevice? + private(set) var isReady = false + + private var albumID: Int? + private let photoRepository: PhotoRepositoryProtocol + var onShotSaved: ((String) -> 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 + + init(photoRepository: PhotoRepositoryProtocol = TravelAlbumOTGPhotoRepository()) { + self.photoRepository = photoRepository + } + + // MARK: - Lifecycle + + func start(camera: ICCameraDevice, albumID: Int?) { + stop() + + self.camera = camera + self.albumID = albumID + 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, album=\(albumID.map(String.init) ?? "nil")") + startCatalogPolling() + } + + func stop() { + isReady = false + if Thread.isMainThread { + stopCatalogPolling() + } else { + DispatchQueue.main.sync { stopCatalogPolling() } + } + isDownloading = false + knownCatalogIDs = [] + catalogBaselineEstablished = false + albumID = 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 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?(filename) + } 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 -> Bool { + guard let albumID else { + OTGLog.warning(.nikon, "\(reason): live transfer album not configured, skip save") + return false + } + + do { + let fileURL = try AlbumPhotoStorage.writeImage( + data, + filename: filename, + albumID: albumID + ) + try photoRepository.addPhoto( + albumID: albumID, + localPath: fileURL.path, + createdAt: Date() + ) + return true + } catch { + OTGLog.error(.nikon, "\(reason): save failed: \(error.localizedDescription)") + return false + } + } +} diff --git a/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyCameraDriver.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyCameraDriver.swift new file mode 100644 index 0000000..d83fdb4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyCameraDriver.swift @@ -0,0 +1,61 @@ +// +// SonyCameraDriver.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +@preconcurrency 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/Features/TravelAlbum/OTG/Brands/Sony/SonyPTPCommands.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyPTPCommands.swift new file mode 100644 index 0000000..ef4a330 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Brands/Sony/SonyPTPHelper.swift b/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyPTPHelper.swift new file mode 100644 index 0000000..f8ac1e4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Brands/Sony/SonyPTPHelper.swift @@ -0,0 +1,422 @@ +// +// SonyPTPHelper.swift +// otg_swift +// +// Created by hanqiu on 2026/7/3. +// + +import Foundation +@preconcurrency 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 + + init(photoRepository: PhotoRepositoryProtocol = TravelAlbumOTGPhotoRepository()) { + self.photoRepository = photoRepository + } + + // MARK: - Lifecycle + + /// 在 SDIO 握手成功后启动;`albumID` 为边拍边传目标相册,nil 时只检测不保存。 + func start( + camera: ICCameraDevice, + startingTransactionID: UInt32, + albumID: Int? + ) { + stop() + + self.camera = camera + self.transactionID = startingTransactionID + self.albumID = albumID + self.isReady = true + self.lastDownloadedShotSignature = nil + self.detectedShotCount = 0 + self.savedShotCount = 0 + + OTGLog.info(.sony, "Remote capture service started, transactionID=\(startingTransactionID), album=\(albumID.map(String.init) ?? "nil")") + startD215Polling() + } + + func stop() { + isReady = false + stopD215Polling() + propertyCheckTask?.cancel() + propertyCheckTask = nil + isEvaluatingShotBuffer = false + propertyCheckCoalesceScheduled = false + isDownloading = false + albumID = 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 let albumID else { + OTGLog.warning(.sony, "\(reason): live transfer album 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 + + do { + let fileURL = try AlbumPhotoStorage.writeImage( + downloaded.data, + filename: downloaded.filename, + albumID: albumID + ) + try photoRepository.addPhoto( + albumID: albumID, + localPath: fileURL.path, + createdAt: Date() + ) + OTGLog.info( + .sony, + "Live shot saved (#\(savedShotCount + 1)): \(downloaded.filename), bytes=\(downloaded.data.count), path=\(fileURL.lastPathComponent)" + ) + onShotSaved?(downloaded.filename) + 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/Features/TravelAlbum/OTG/Core/CameraDeviceInfo.swift b/suixinkan/Features/TravelAlbum/OTG/Core/CameraDeviceInfo.swift new file mode 100644 index 0000000..55f92a8 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Core/CameraDriver.swift b/suixinkan/Features/TravelAlbum/OTG/Core/CameraDriver.swift new file mode 100644 index 0000000..995240a --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Core/CameraError.swift b/suixinkan/Features/TravelAlbum/OTG/Core/CameraError.swift new file mode 100644 index 0000000..bf8c55b --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Core/CameraFactory.swift b/suixinkan/Features/TravelAlbum/OTG/Core/CameraFactory.swift new file mode 100644 index 0000000..2b33f57 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Core/CameraFactory.swift @@ -0,0 +1,39 @@ +// +// CameraFactory.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +import Foundation +@preconcurrency 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/Features/TravelAlbum/OTG/Core/CameraPlatform.swift b/suixinkan/Features/TravelAlbum/OTG/Core/CameraPlatform.swift new file mode 100644 index 0000000..fa09a61 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Core/ConnectionManager.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift new file mode 100644 index 0000000..564923b --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift @@ -0,0 +1,658 @@ +// +// ConnectionManager.swift +// otg_swift +// +// Created by hanqiu on 2026/7/2. +// + +@preconcurrency import ImageCaptureCore + +/// ConnectionManager 向 ViewModel 上报连接生命周期与内容目录就绪事件。 +@MainActor +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 filename: String, albumID: Int) +} + +extension ConnectionManagerDelegate { + func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: CameraDriver) {} + func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {} + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {} +} + +/// 有线相机连接管理抽象,便于页面 ViewModel 测试连接与目录就绪状态。 +@MainActor +protocol WiredCameraConnectionManaging: AnyObject { + var delegate: ConnectionManagerDelegate? { get set } + var state: ConnectionState { get } + var currentDriver: CameraDriver? { get } + var isContentCatalogReady: Bool { get } + + func configureLiveTransfer(albumID: Int?) + func start() + func unbindDelegate() + func disconnect() +} + +/// 单例:ImageCaptureCore 设备发现、授权、Session、Driver 创建与边拍边传编排。 +/// 持有 `ICDeviceBrowser` 与缓存的 `ICCameraDevice`,View 层通过 Delegate 订阅状态。 +@MainActor +final class ConnectionManager: NSObject, WiredCameraConnectionManaging { + + 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? + /// 边拍边传目标相册;由 OTGTransferViewModel 在 start 前设置。 + private var liveTransferAlbumID: Int? + + private let searchRescanDelay: TimeInterval = 1.5 + /// 长时间搜不到设备时提示用户重新插拔 + private let searchReplugHintDelay: TimeInterval = 4.5 + + private override init() { + super.init() + } + + // MARK: - Public + + /// 设置边拍边传目标相册(索尼 / 佳能);传 nil 清除。 + func configureLiveTransfer(albumID: Int?) { + liveTransferAlbumID = albumID + OTGLog.debug(.connection, "live transfer album=\(albumID.map(String.init) ?? "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 + liveTransferAlbumID = nil + currentDriver?.disconnect() + currentDriver = nil + device.delegate = self + device.requestCloseSession() + } + + // MARK: - Private + + private var activeDevice: ICCameraDevice? { + cachedDevice + } + + private func notify(_ block: () -> Void) { + 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: + let 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, + albumID: liveTransferAlbumID + ) + 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) + + let 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, + albumID: liveTransferAlbumID + ) + 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, albumID: liveTransferAlbumID) + OTGLog.info(.nikon, "Nikon catalog live capture ready") + } + + private func makeLiveShotSavedHandler() -> (String) -> Void { + { [weak self] filename in + guard let self, let albumID = self.liveTransferAlbumID else { return } + self.notify { + self.delegate?.connectionManager( + self, + didSaveLiveShot: filename, + 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/Features/TravelAlbum/OTG/Core/ConnectionState.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionState.swift new file mode 100644 index 0000000..e11a25c --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Core/ICCameraDevice+DebugLog.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraDevice+DebugLog.swift new file mode 100644 index 0000000..17e4544 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraDevice+DebugLog.swift @@ -0,0 +1,105 @@ +// +// ICCameraDevice+DebugLog.swift +// otg_swift +// + +import CoreGraphics +@preconcurrency 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)") + } + + 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/Features/TravelAlbum/OTG/Core/ICCameraFileDownloader.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraFileDownloader.swift new file mode 100644 index 0000000..9e02bbc --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraFileDownloader.swift @@ -0,0 +1,66 @@ +// +// ICCameraFileDownloader.swift +// otg_swift +// + +import Foundation +@preconcurrency import ImageCaptureCore + +/// 封装 `ICCameraFile.requestDownload`,将相机文件异步保存到本地目录。 +enum ICCameraFileDownloader { + + /// 下载到 `directory`,同名文件自动追加序号,避免覆盖已导入原片。 + static func download(_ file: ICCameraFile, to directory: URL) async throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let filename = uniqueFilename(file.name ?? UUID().uuidString, in: directory) + 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) + } + } + } + + private static func uniqueFilename(_ rawName: String, in directory: URL) -> String { + let sanitized = PTPHelper.sanitizeFilename(rawName) + let baseURL = directory.appendingPathComponent(sanitized) + if !FileManager.default.fileExists(atPath: baseURL.path) { + return sanitized + } + + let stem = (sanitized as NSString).deletingPathExtension + let ext = (sanitized as NSString).pathExtension + var counter = 1 + while counter < 10_000 { + let candidate = ext.isEmpty ? "\(stem)_\(counter)" : "\(stem)_\(counter).\(ext)" + if !FileManager.default.fileExists(atPath: directory.appendingPathComponent(candidate).path) { + return candidate + } + counter += 1 + } + return "\(UUID().uuidString)_\(sanitized)" + } +} diff --git a/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraItem+CameraObjects.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraItem+CameraObjects.swift new file mode 100644 index 0000000..25c8045 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraItem+CameraObjects.swift @@ -0,0 +1,139 @@ +// +// ICCameraItem+CameraObjects.swift +// otg_swift +// + +import Foundation +@preconcurrency 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/Features/TravelAlbum/OTG/Core/ICCameraMetadataLoader.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraMetadataLoader.swift new file mode 100644 index 0000000..7e73d82 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraMetadataLoader.swift @@ -0,0 +1,133 @@ +// +// ICCameraMetadataLoader.swift +// otg_swift +// + +import Foundation +@preconcurrency 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, _) 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/Features/TravelAlbum/OTG/Core/ICCameraThumbnailLoader.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraThumbnailLoader.swift new file mode 100644 index 0000000..f520a8d --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ICCameraThumbnailLoader.swift @@ -0,0 +1,40 @@ +// +// ICCameraThumbnailLoader.swift +// otg_swift +// + +import Foundation +@preconcurrency 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/Features/TravelAlbum/OTG/Core/OTGLog.swift b/suixinkan/Features/TravelAlbum/OTG/Core/OTGLog.swift new file mode 100644 index 0000000..fc666f4 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Core/PlatformDetector.swift b/suixinkan/Features/TravelAlbum/OTG/Core/PlatformDetector.swift new file mode 100644 index 0000000..2a4cdad --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/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/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift new file mode 100644 index 0000000..ab40ca5 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Models/TravelAlbumOTGTransferModels.swift @@ -0,0 +1,166 @@ +// +// TravelAlbumOTGTransferModels.swift +// suixinkan +// + +import Foundation + +/// OTG 传输页展示照片项。 +struct TravelAlbumOTGPhotoItem: Hashable, Sendable { + let id: String + let sourceId: String + let fileName: String + let thumbnailURL: URL? + let capturedAt: String + let fileSizeText: String + let fileSizeBytes: Int64 + var status: TravelAlbumOTGUploadStatus + var progress: Int + var errorMessage: String? + let localPath: String + var remoteUrl: String + + /// 是否未上传完成。 + var isNotUploaded: Bool { + status != .uploaded + } + + /// 是否可被选择上传。 + var canSelectForUpload: Bool { + status == .pending || status == .failed + } +} + +/// OTG 传输页 Tab。 +enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable { + case all + case uploaded + case failed + + var title: String { + switch self { + case .all: return "全部" + case .uploaded: return "已上传" + case .failed: return "失败" + } + } +} + +/// 相机照片格式匹配工具,对齐 Android `OtgPhotoFormatMatcher`。 +enum TravelAlbumOTGPhotoFormatMatcher { + /// 相机照片类型。 + enum PhotoKind: Equatable { + case jpeg + case raw + case other + } + + private static let jpegExtensions: Set = ["jpg", "jpeg", "heif", "hif"] + private static let rawExtensions: Set = [ + "dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2", + "orf", "rw2", "raw", "raf", "srw", "pef", "ptx", "rwl", + ] + private static let jpegFormatCodes: Set = [0x3801, 0x3808] + private static let rawFormatCodes: Set = [0x380D, 0xB101, 0xB103, 0xB108] + + /// 判断文件类型。 + static func classify(name: String, format: Int = 0, isVideo: Bool = false) -> PhotoKind { + if isVideo { return .other } + let ext = URL(fileURLWithPath: name).pathExtension.lowercased() + if rawExtensions.contains(ext) || rawFormatCodes.contains(format) { + return .raw + } + if jpegExtensions.contains(ext) || jpegFormatCodes.contains(format) { + return .jpeg + } + return .other + } + + /// 判断相机对象是否匹配当前上传格式。 + static func matchesUploadKind(_ object: CameraObject, option: TravelAlbumOTGPhotoFormatOption) -> Bool { + switch option { + case .jpg: + return classify(name: object.filename) == .jpeg + case .raw: + return classify(name: object.filename) == .raw + case .jpegRaw: + let kind = classify(name: object.filename) + return kind == .jpeg || kind == .raw + } + } + + /// 根据格式偏好展开实际上传目标。 + static func resolveUploadTargets( + requested: [CameraObject], + allObjects: [CameraObject], + option: TravelAlbumOTGPhotoFormatOption + ) -> [CameraObject] { + guard !requested.isEmpty else { return [] } + var ordered: [CameraObject] = [] + var seen = Set() + for object in requested { + let peers = peers(of: object, allObjects: allObjects) + let jpegs = peers.filter { classify(name: $0.filename) == .jpeg } + let raws = peers.filter { classify(name: $0.filename) == .raw } + let candidates: [CameraObject] + switch option { + case .jpg: + candidates = Array(jpegs.prefix(1)) + case .raw: + candidates = Array(raws.prefix(1)) + case .jpegRaw: + candidates = jpegs + raws + } + candidates.forEach { candidate in + if seen.insert(candidate.id).inserted { + ordered.append(candidate) + } + } + } + return ordered + } + + private static func peers(of object: CameraObject, allObjects: [CameraObject]) -> [CameraObject] { + let stem = captureStem(object.filename) + guard !stem.isEmpty else { return [object] } + let matches = allObjects.filter { captureStem($0.filename) == stem } + return matches.isEmpty ? [object] : matches + } + + private static func captureStem(_ name: String) -> String { + (name as NSString).deletingPathExtension.uppercased() + } +} + +extension TravelAlbumOTGPhotoRecord { + /// 转为页面展示项。 + func toPhotoItem() -> TravelAlbumOTGPhotoItem { + let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in + guard !path.isEmpty else { return false } + if path.hasPrefix("http") { return true } + return FileManager.default.fileExists(atPath: path) + } ?? "" + let previewURL: URL? + if previewPath.hasPrefix("http") { + previewURL = URL(string: previewPath) + } else if !previewPath.isEmpty { + previewURL = URL(fileURLWithPath: previewPath) + } else { + previewURL = nil + } + return TravelAlbumOTGPhotoItem( + id: id, + sourceId: sourceId, + fileName: fileName, + thumbnailURL: previewURL, + capturedAt: capturedAt, + fileSizeText: TravelAlbumOTGPhotoStore.fileSizeText(fileSizeBytes), + fileSizeBytes: fileSizeBytes, + status: status, + progress: progress, + errorMessage: errorMessage, + localPath: localPath, + remoteUrl: remoteUrl + ) + } +} diff --git a/suixinkan/Features/TravelAlbum/OTG/PTP/PTPUtilities.swift b/suixinkan/Features/TravelAlbum/OTG/PTP/PTPUtilities.swift new file mode 100644 index 0000000..b9e212e --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/PTP/PTPUtilities.swift @@ -0,0 +1,307 @@ +// +// PTPUtilities.swift +// otg_swift +// +// Created by hanqiu on 2026/7/3. +// + +import Foundation +@preconcurrency 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。 + @MainActor + 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(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 要求从主线程调用,方法本身由 MainActor 隔离。 + 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/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift new file mode 100644 index 0000000..6c49760 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Storage/TravelAlbumOTGPhotoStore.swift @@ -0,0 +1,386 @@ +// +// TravelAlbumOTGPhotoStore.swift +// suixinkan +// + +import Foundation + +/// OTG 传输照片上传状态,对齐 Android `WiredTransferUploadStatus`。 +enum TravelAlbumOTGUploadStatus: String, Codable, Equatable, Sendable { + case pending = "PENDING" + case transferring = "TRANSFERRING" + case uploading = "UPLOADING" + case uploaded = "UPLOADED" + case failed = "FAILED" +} + +/// OTG 照片格式上传偏好。 +enum TravelAlbumOTGPhotoFormatOption: String, CaseIterable, Equatable, Sendable { + case jpg = "JPG" + case raw = "RAW" + case jpegRaw = "JPEG+RAW" +} + +/// OTG 本地照片记录,按账号、景区、门店与服务端相册 ID 隔离保存。 +struct TravelAlbumOTGPhotoRecord: Codable, Hashable, Sendable { + let id: String + let sourceId: String + let fileName: String + var localPath: String + var thumbnailPath: String + let capturedAt: String + let fileSizeBytes: Int64 + var status: TravelAlbumOTGUploadStatus + var progress: Int + var errorMessage: String? + let albumId: Int + let userId: String + var remoteUrl: String + var updatedAt: Int64 + + /// 创建 OTG 本地照片记录。 + init( + id: String, + sourceId: String? = nil, + fileName: String, + localPath: String, + thumbnailPath: String = "", + capturedAt: String, + fileSizeBytes: Int64, + status: TravelAlbumOTGUploadStatus, + progress: Int = 0, + errorMessage: String? = nil, + albumId: Int, + userId: String, + remoteUrl: String = "", + updatedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1000) + ) { + self.id = id + self.sourceId = sourceId ?? id + self.fileName = fileName + self.localPath = localPath + self.thumbnailPath = thumbnailPath + self.capturedAt = capturedAt + self.fileSizeBytes = fileSizeBytes + self.status = status + self.progress = progress + self.errorMessage = errorMessage + self.albumId = albumId + self.userId = userId + self.remoteUrl = remoteUrl + self.updatedAt = updatedAt + } + + /// 是否仍可在页面展示。 + var isDisplayable: Bool { + if status == .uploaded { return true } + if !remoteUrl.isEmpty { return true } + if !localPath.isEmpty, FileManager.default.fileExists(atPath: localPath) { return true } + if !thumbnailPath.isEmpty, FileManager.default.fileExists(atPath: thumbnailPath) { return true } + return false + } + + /// 把中断中的传输恢复为待上传,避免重进页面卡在上传中。 + func normalizedAfterInterruptedTransfer() -> TravelAlbumOTGPhotoRecord { + guard status == .transferring || status == .uploading else { return self } + var copy = self + copy.status = .pending + copy.progress = 0 + copy.errorMessage = nil + copy.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) + return copy + } +} + +/// 相册内照片的持久化读写接口,供 OTG 边拍边传服务写入。 +protocol PhotoRepositoryProtocol { + /// 读取指定服务端相册 ID 的本地照片记录。 + func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord] + + /// 添加一张已下载到本地的照片。 + func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws + + /// 删除指定照片及本地文件。 + func deletePhoto(photoID: String, albumID: Int) throws +} + +/// OTG 本地照片仓库,使用 JSON 索引和隔离目录保存照片。 +final class TravelAlbumOTGPhotoRepository: PhotoRepositoryProtocol { + private let storage: TravelAlbumOTGPhotoStore + + /// 初始化 OTG 本地照片仓库。 + init(storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore()) { + self.storage = storage + } + + /// 读取指定服务端相册 ID 的本地照片记录。 + func fetchPhotos(albumID: Int) throws -> [TravelAlbumOTGPhotoRecord] { + storage.load(albumId: albumID) + } + + /// 添加一张已下载到本地的照片。 + func addPhoto(albumID: Int, localPath: String, createdAt: Date) throws { + let fileURL = URL(fileURLWithPath: localPath) + let attributes = try? FileManager.default.attributesOfItem(atPath: localPath) + let size = (attributes?[.size] as? NSNumber)?.int64Value ?? 0 + let userId = storage.context.userId + guard !userId.isEmpty else { return } + + let record = TravelAlbumOTGPhotoRecord( + id: TravelAlbumOTGPhotoStore.photoId(for: fileURL.lastPathComponent, createdAt: createdAt), + fileName: fileURL.lastPathComponent, + localPath: localPath, + capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(createdAt), + fileSizeBytes: size, + status: .pending, + albumId: albumID, + userId: userId + ) + storage.upsert(record, albumId: albumID) + } + + /// 删除指定照片及本地文件。 + func deletePhoto(photoID: String, albumID: Int) throws { + storage.remove(albumId: albumID, photoId: photoID) + } +} + +/// OTG 本地存储上下文,决定照片和索引的隔离目录。 +struct TravelAlbumOTGStorageContext: Equatable, Sendable { + let accountCachePrefix: String + let userId: String + let scenicId: Int + let storeId: Int + + /// 使用当前登录账号创建存储上下文。 + static var current: TravelAlbumOTGStorageContext { + TravelAlbumOTGStorageContext( + accountCachePrefix: AppStore.shared.accountCachePrefix, + userId: AppStore.shared.userId, + scenicId: AppStore.shared.currentScenicId, + storeId: AppStore.shared.currentStoreId + ) + } +} + +/// 旅拍相册 OTG 本地照片存储,负责路径隔离、索引读写与文件清理。 +final class TravelAlbumOTGPhotoStore { + let context: TravelAlbumOTGStorageContext + private let fileManager: FileManager + private let applicationSupportDirectory: URL + private let cachesDirectory: URL + + /// 初始化旅拍相册 OTG 存储。 + init( + context: TravelAlbumOTGStorageContext = .current, + fileManager: FileManager = .default, + applicationSupportDirectory: URL? = nil, + cachesDirectory: URL? = nil + ) { + self.context = context + self.fileManager = fileManager + self.applicationSupportDirectory = applicationSupportDirectory + ?? fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + self.cachesDirectory = cachesDirectory + ?? fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0] + } + + /// 读取相册照片记录。 + func load(albumId: Int) -> [TravelAlbumOTGPhotoRecord] { + guard albumId > 0, !context.userId.isEmpty else { return [] } + guard let data = try? Data(contentsOf: indexURL(albumId: albumId)), + let decoded = try? JSONDecoder().decode([TravelAlbumOTGPhotoRecord].self, from: data) else { + return [] + } + let deletedIds = loadDeletedIds(albumId: albumId) + return decoded + .filter { $0.albumId == albumId && $0.userId == context.userId && !deletedIds.contains($0.id) } + .map { $0.normalizedAfterInterruptedTransfer() } + .filter(\.isDisplayable) + .sorted { $0.capturedAt > $1.capturedAt } + } + + /// 保存相册照片记录。 + func save(_ records: [TravelAlbumOTGPhotoRecord], albumId: Int) { + guard albumId > 0, !context.userId.isEmpty else { return } + let normalized = records + .filter { $0.albumId == albumId && $0.userId == context.userId } + .sorted { $0.capturedAt > $1.capturedAt } + do { + try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(normalized) + try data.write(to: indexURL(albumId: albumId), options: .atomic) + } catch { + OTGLog.error(.connection, "save OTG photo records failed: \(error.localizedDescription)") + } + } + + /// 插入或更新单条照片记录。 + func upsert(_ record: TravelAlbumOTGPhotoRecord, albumId: Int) { + var records = load(albumId: albumId).filter { $0.id != record.id } + records.append(record) + save(records, albumId: albumId) + } + + /// 删除单张照片记录并清理本地文件。 + func remove(albumId: Int, photoId: String) { + guard albumId > 0, !photoId.isEmpty else { return } + let records = load(albumId: albumId) + records.filter { $0.id == photoId }.forEach(deleteFiles) + saveDeletedIds(loadDeletedIds(albumId: albumId).union([photoId]), albumId: albumId) + save(records.filter { $0.id != photoId }, albumId: albumId) + } + + /// 清理整个相册的 OTG 本地缓存。 + func clearAlbum(albumId: Int) { + guard albumId > 0 else { return } + try? fileManager.removeItem(at: albumRoot(albumId: albumId)) + try? fileManager.removeItem(at: previewRoot(albumId: albumId)) + } + + /// 返回已删除照片 ID。 + func loadDeletedIds(albumId: Int) -> Set { + guard let data = try? Data(contentsOf: deletedIndexURL(albumId: albumId)), + let decoded = try? JSONDecoder().decode([String].self, from: data) else { + return [] + } + return Set(decoded) + } + + /// 原片目录。 + func originalsDirectory(albumId: Int) throws -> URL { + let url = albumRoot(albumId: albumId).appendingPathComponent("originals", isDirectory: true) + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + try excludeFromBackup(url) + return url + } + + /// 预览目录。 + func previewsDirectory(albumId: Int) throws -> URL { + let url = previewRoot(albumId: albumId) + try fileManager.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + /// 在隔离原片目录下分配不冲突文件 URL。 + func uniqueOriginalFileURL(filename: String, albumId: Int) throws -> URL { + let directory = try originalsDirectory(albumId: albumId) + let sanitized = PTPHelper.sanitizeFilename(filename) + let baseURL = directory.appendingPathComponent(sanitized) + if !fileManager.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.fileExists(atPath: candidate.path) { + return candidate + } + counter += 1 + } + throw CameraError.connectionFailed("无法分配唯一文件名") + } + + /// 写入原片数据,返回隔离目录中的文件 URL。 + func writeImage(_ data: Data, filename: String, albumId: Int) throws -> URL { + let url = try uniqueOriginalFileURL(filename: filename, albumId: albumId) + try data.write(to: url, options: .atomic) + return url + } + + /// 生成稳定照片 ID。 + static func photoId(for fileName: String, createdAt: Date) -> String { + let timestamp = Int64(createdAt.timeIntervalSince1970) + return "\(fileName.uppercased())_\(timestamp)" + } + + /// Android 同款时间格式。 + static func transferTimeText(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return formatter.string(from: date) + } + + /// 格式化文件大小。 + static func fileSizeText(_ bytes: Int64) -> String { + guard bytes > 0 else { return "--" } + let mb = Double(bytes) / 1024.0 / 1024.0 + if mb >= 1024 { + return String(format: "%.2f GB", locale: Locale(identifier: "en_US_POSIX"), mb / 1024.0) + } + return String(format: "%.2f MB", locale: Locale(identifier: "en_US_POSIX"), mb) + } + + private func albumRoot(albumId: Int) -> URL { + applicationSupportDirectory + .appendingPathComponent("TravelAlbumOTG", isDirectory: true) + .appendingPathComponent(safePathComponent(context.accountCachePrefix), isDirectory: true) + .appendingPathComponent("scenic_\(context.scenicId)", isDirectory: true) + .appendingPathComponent("store_\(context.storeId)", isDirectory: true) + .appendingPathComponent("album_\(albumId)", isDirectory: true) + } + + private func previewRoot(albumId: Int) -> URL { + cachesDirectory + .appendingPathComponent("TravelAlbumOTG", isDirectory: true) + .appendingPathComponent(safePathComponent(context.accountCachePrefix), isDirectory: true) + .appendingPathComponent("scenic_\(context.scenicId)", isDirectory: true) + .appendingPathComponent("store_\(context.storeId)", isDirectory: true) + .appendingPathComponent("album_\(albumId)", isDirectory: true) + .appendingPathComponent("previews", isDirectory: true) + } + + private func indexURL(albumId: Int) -> URL { + albumRoot(albumId: albumId).appendingPathComponent("records.json") + } + + private func deletedIndexURL(albumId: Int) -> URL { + albumRoot(albumId: albumId).appendingPathComponent("deleted.json") + } + + private func saveDeletedIds(_ ids: Set, albumId: Int) { + do { + try fileManager.createDirectory(at: albumRoot(albumId: albumId), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(Array(ids)) + try data.write(to: deletedIndexURL(albumId: albumId), options: .atomic) + } catch { + OTGLog.warning(.connection, "save deleted OTG ids failed: \(error.localizedDescription)") + } + } + + private func deleteFiles(_ record: TravelAlbumOTGPhotoRecord) { + [record.localPath, record.thumbnailPath].forEach { path in + guard !path.isEmpty else { return } + try? fileManager.removeItem(atPath: path) + } + } + + private func excludeFromBackup(_ url: URL) throws { + var resourceValues = URLResourceValues() + resourceValues.isExcludedFromBackup = true + var mutableURL = url + try mutableURL.setResourceValues(resourceValues) + } + + private func safePathComponent(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "guest" } + let unsafe = CharacterSet(charactersIn: "/\\:?%*|\"<>") + return trimmed.unicodeScalars.reduce(into: "") { result, scalar in + result += unsafe.contains(scalar) ? "_" : String(scalar) + } + } +} + +/// 兼容从 `otg_swift` 同步来的边拍边传写入调用。 +enum AlbumPhotoStorage { + /// 写入原片数据到当前账号与相册隔离目录。 + static func writeImage(_ data: Data, filename: String, albumID: Int) throws -> URL { + try TravelAlbumOTGPhotoStore().writeImage(data, filename: filename, albumId: albumID) + } +} diff --git a/suixinkan/Features/TravelAlbum/OTG/Upload/TravelAlbumOTGUploader.swift b/suixinkan/Features/TravelAlbum/OTG/Upload/TravelAlbumOTGUploader.swift new file mode 100644 index 0000000..b5be895 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/OTG/Upload/TravelAlbumOTGUploader.swift @@ -0,0 +1,78 @@ +// +// TravelAlbumOTGUploader.swift +// suixinkan +// + +import Foundation + +/// 旅拍相册 OTG 上传服务协议。 +@MainActor +protocol TravelAlbumOTGUploading { + /// 上传本地照片并登记为旅拍相册素材。 + func upload( + record: TravelAlbumOTGPhotoRecord, + scenicId: Int, + progress: @escaping (Int) -> Void + ) async throws -> TravelAlbumMaterial +} + +/// 旅拍相册 OTG 上传服务,负责 OSS 上传与 `upload-material` 素材登记。 +@MainActor +final class TravelAlbumOTGUploader: TravelAlbumOTGUploading { + private let uploader: OSSUploadService + private let api: any TravelAlbumServing + + /// 初始化旅拍相册 OTG 上传服务。 + init( + uploader: OSSUploadService? = nil, + api: (any TravelAlbumServing)? = nil + ) { + self.uploader = uploader ?? NetworkServices.shared.ossUploadService + self.api = api ?? NetworkServices.shared.travelAlbumAPI + } + + /// 上传本地照片并登记为旅拍相册素材。 + func upload( + record: TravelAlbumOTGPhotoRecord, + scenicId: Int, + progress: @escaping (Int) -> Void + ) async throws -> TravelAlbumMaterial { + guard record.albumId > 0 else { + throw TravelAlbumOTGUploadError.invalidAlbum + } + guard !record.localPath.isEmpty, + FileManager.default.fileExists(atPath: record.localPath) else { + throw TravelAlbumOTGUploadError.localFileMissing + } + + let data = try Data(contentsOf: URL(fileURLWithPath: record.localPath)) + let remoteURL = try await uploader.uploadTravelAlbumMaterial( + data: data, + fileName: record.fileName, + scenicId: scenicId, + onProgress: progress + ) + return try await api.uploadMaterial( + TravelAlbumUploadMaterialRequest( + userEquityTravelId: record.albumId, + fileName: record.fileName, + fileUrl: remoteURL + ) + ) + } +} + +/// 旅拍相册 OTG 上传错误。 +enum TravelAlbumOTGUploadError: LocalizedError, Equatable { + case invalidAlbum + case localFileMissing + + var errorDescription: String? { + switch self { + case .invalidAlbum: + return "请先选择有效的相册" + case .localFileMissing: + return "本地文件不存在,无法上传" + } + } +} diff --git a/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift new file mode 100644 index 0000000..a5d7496 --- /dev/null +++ b/suixinkan/Features/TravelAlbum/ViewModels/TravelAlbumCameraImportViewModel.swift @@ -0,0 +1,256 @@ +// +// TravelAlbumCameraImportViewModel.swift +// suixinkan +// + +import Foundation + +/// 相机历史照片导入分组。 +struct TravelAlbumCameraImportSection: Equatable { + let dayKey: String + let title: String + let photos: [CameraObject] +} + +/// 相机历史照片导入页状态。 +enum TravelAlbumCameraImportState: Equatable { + case idle + case loading + case loaded(sections: [TravelAlbumCameraImportSection]) + case importing(current: Int, total: Int) + case finished(importedCount: Int) + case failed(message: String) +} + +/// 旅拍相册相机历史照片导入页 ViewModel,负责相机枚举、多选和本地导入。 +@MainActor +final class TravelAlbumCameraImportViewModel { + let albumId: Int + let albumTitle: String + + private(set) var state: TravelAlbumCameraImportState = .idle { + didSet { + onStateUpdated?(state) + onSonyMTPHelpVisibilityChanged?(shouldShowSonyMTPHelp) + } + } + private(set) var selectedPhotoIds: Set = [] { + didSet { onSelectionUpdated?(selectedPhotoIds.count) } + } + private(set) var failedPhotoIds: Set = [] + + var onStateUpdated: ((TravelAlbumCameraImportState) -> Void)? + var onSelectionUpdated: ((Int) -> Void)? + var onSonyMTPHelpVisibilityChanged: ((Bool) -> Void)? + + private let driver: CameraDriver + private let storage: TravelAlbumOTGPhotoStore + private let appStore: AppStore + private var allPhotos: [CameraObject] = [] + private var existingPhotoIds: Set = [] + private var thumbnailCache: [String: Data] = [:] + + /// 初始化旅拍相册相机历史照片导入页 ViewModel。 + init( + albumId: Int, + albumTitle: String, + driver: CameraDriver, + storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), + appStore: AppStore = .shared + ) { + self.albumId = albumId + self.albumTitle = albumTitle + self.driver = driver + self.storage = storage + self.appStore = appStore + } + + /// 索尼相机且目录为空时展示 MTP 帮助入口。 + var shouldShowSonyMTPHelp: Bool { + guard driver.platform == .sony else { return false } + switch state { + case .loaded(let sections): + return sections.isEmpty + case .failed: + return allPhotos.isEmpty + default: + return false + } + } + + /// 从相机读取历史照片并按日期分组。 + func loadPhotos() { + state = .loading + Task { + do { + reloadExistingPhotoIds() + let objects = try await driver.listObjects() + allPhotos = objects + let sections = Self.groupByDay(objects) + if sections.isEmpty { + state = .failed(message: "相机中没有可导入的照片") + } else { + state = .loaded(sections: sections) + } + } catch { + state = .failed(message: error.localizedDescription) + } + } + } + + /// 是否已经导入当前相册。 + func isPhotoAlreadyInAlbum(id: String) -> Bool { + existingPhotoIds.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: TravelAlbumCameraImportSection) { + let ids = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)) + guard !ids.isEmpty else { return } + if ids.isSubset(of: selectedPhotoIds) { + selectedPhotoIds.subtract(ids) + } else { + selectedPhotoIds.formUnion(ids) + } + } + + /// 是否选中单张照片。 + func isPhotoSelected(id: String) -> Bool { + selectedPhotoIds.contains(id) + } + + /// 是否整组全选。 + func isSectionFullySelected(_ section: TravelAlbumCameraImportSection) -> Bool { + let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id) + return !ids.isEmpty && ids.allSatisfy { selectedPhotoIds.contains($0) } + } + + /// 是否整组半选。 + func isSectionPartiallySelected(_ section: TravelAlbumCameraImportSection) -> Bool { + let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id) + let selectedCount = ids.filter { selectedPhotoIds.contains($0) }.count + return selectedCount > 0 && selectedCount < ids.count + } + + /// 分组是否还有可导入照片。 + func hasImportablePhotos(in section: TravelAlbumCameraImportSection) -> Bool { + section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) } + } + + /// 分组可导入照片数。 + func importablePhotoCount(in section: TravelAlbumCameraImportSection) -> Int { + section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.count + } + + /// 分组已导入照片数。 + func importedPhotoCount(in section: TravelAlbumCameraImportSection) -> Int { + section.photos.filter { isPhotoAlreadyInAlbum(id: $0.id) }.count + } + + /// 加载相机缩略图。 + func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? { + if let cached = thumbnailCache[photo.id] { + return cached + } + guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else { + return nil + } + thumbnailCache[photo.id] = data + return data + } + + /// 将已选照片导入当前相册本地缓存。 + func importSelected() { + let selected = allPhotos.filter { selectedPhotoIds.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id) } + guard !selected.isEmpty else { return } + + state = .importing(current: 0, total: selected.count) + failedPhotoIds = [] + + Task { + let directory = try? storage.originalsDirectory(albumId: albumId) + var importedCount = 0 + + for (index, object) in selected.enumerated() { + defer { state = .importing(current: index + 1, total: selected.count) } + guard let directory else { + failedPhotoIds.insert(object.id) + continue + } + do { + let downloadedURL = try await driver.downloadObject(object, to: directory) + let record = TravelAlbumOTGPhotoRecord( + id: object.id, + sourceId: object.id, + fileName: downloadedURL.lastPathComponent, + localPath: downloadedURL.path, + thumbnailPath: "", + capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt), + fileSizeBytes: object.fileSize, + status: .pending, + progress: 0, + albumId: albumId, + userId: appStore.userId + ) + storage.upsert(record, albumId: albumId) + existingPhotoIds.insert(object.id) + importedCount += 1 + } catch { + failedPhotoIds.insert(object.id) + OTGLog.error(.connection, "history import failed: \(object.filename) \(error.localizedDescription)") + } + } + + selectedPhotoIds.subtract(existingPhotoIds) + state = .finished(importedCount: importedCount) + } + } + + private func reloadExistingPhotoIds() { + existingPhotoIds = Set( + storage.load(albumId: albumId).flatMap { record in + [record.id, record.sourceId].filter { !$0.isEmpty } + } + ) + } + + private static func groupByDay(_ photos: [CameraObject]) -> [TravelAlbumCameraImportSection] { + 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 + TravelAlbumCameraImportSection( + dayKey: dayKeyString(for: day, calendar: calendar), + title: sectionTitle(for: day, formatter: formatter, calendar: calendar), + photos: grouped[day]!.sorted { $0.capturedAt > $1.capturedAt } + ) + } + } + + 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/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 8c69607..32066d1 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -5,32 +5,435 @@ import Foundation -/// 有线相机传输页 ViewModel,仅承载断开空态 UI 文案,不接入 OTG 或图片存储业务。 +/// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。 +@MainActor final class WiredCameraTransferViewModel { let albumId: Int let albumTitle: String let headerPhone: String let scenicSpotLabel: String? - let cameraStatusText = "未连接 USB" - let actionButtonText = "等待连接" - let deviceModelName = "设备存储" - let availableStorageText = "-- GB 可用" + + private(set) var connectionState: ConnectionState = .idle { + didSet { notifyStateChanged() } + } + private(set) var photos: [TravelAlbumOTGPhotoItem] = [] { + didSet { notifyStateChanged() } + } + private(set) var selectedTab: TravelAlbumOTGTransferTab = .all { + didSet { notifyStateChanged() } + } + private(set) var selectedPhotoIds: Set = [] { + didSet { notifyStateChanged() } + } + private(set) var selectUploadMode = false { + didSet { notifyStateChanged() } + } + private(set) var sonyMTPHint: String? + private(set) var isContentCatalogReady = false + let retouchOption = "不修图" - let photoFormatOption = "JPG" - let transferModeOption = "边拍边传" - let tabCounts = [0, 0, 0] + private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg + private(set) var transferModeOption = "边拍边传" + var onStateChange: (() -> Void)? var onShowMessage: ((String) -> Void)? + var onLiveShotSaved: ((String) -> Void)? - init(albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil) { + private let connectionManager: any WiredCameraConnectionManaging + private let storage: TravelAlbumOTGPhotoStore + private let repository: TravelAlbumOTGPhotoRepository + private let uploader: any TravelAlbumOTGUploading + private let appStore: AppStore + + private var currentDriver: CameraDriver? + private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] + private var deletedPhotoIds: Set = [] + private var isUploading = false + + @MainActor + init( + albumId: Int, + albumTitle: String, + headerPhone: String, + scenicSpotLabel: String? = nil, + connectionManager: (any WiredCameraConnectionManaging)? = nil, + storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), + uploader: (any TravelAlbumOTGUploading)? = nil, + appStore: AppStore = .shared + ) { self.albumId = albumId self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle self.headerPhone = headerPhone self.scenicSpotLabel = scenicSpotLabel + self.connectionManager = connectionManager ?? ConnectionManager.shared + self.storage = storage + self.repository = TravelAlbumOTGPhotoRepository(storage: storage) + self.uploader = uploader ?? TravelAlbumOTGUploader() + self.appStore = appStore } - /// 响应所有传输按钮点击,仅提示 UI 已同步。 - func showUIOnlyMessage() { - onShowMessage?("仅同步 UI,暂未接入 OTG 传输") + /// 相机状态文案。 + var cameraStatusText: String { + switch connectionState { + case .idle, .disconnected: + return "未连接 USB" + case .searching: + return "正在搜索" + case .connecting: + return "正在连接" + case .connected: + return "已连接" + case .failed: + return "连接失败" + } + } + + /// 操作按钮文案。 + var actionButtonText: String { + switch connectionState { + case .idle, .disconnected, .failed: + return "重新连接" + case .searching, .connecting: + return "等待连接" + case .connected: + return "刷新" + } + } + + /// 设备名称。 + var deviceModelName: String { + switch connectionState { + case .connecting(let name), .connected(_, let name): + return name + default: + return "设备存储" + } + } + + /// 当前设备容量占位文案。 + var availableStorageText: String { + "-- GB 可用" + } + + /// Tab 统计。 + var tabCounts: [Int] { + [ + photos.count, + photos.filter { $0.status == .uploaded }.count, + photos.filter { $0.status == .failed }.count, + ] + } + + /// 过滤后的照片列表。 + func filteredPhotos() -> [TravelAlbumOTGPhotoItem] { + switch selectedTab { + case .all: + return photos + case .uploaded: + return photos.filter { $0.status == .uploaded } + case .failed: + return photos.filter { $0.status == .failed } + } + } + + /// 绑定连接管理器并启动发现。 + func start() { + connectionManager.configureLiveTransfer(albumID: albumId) + connectionManager.delegate = self + syncFromConnectionManager() + loadPersistedPhotos() + connectionManager.start() + } + + /// 页面消失时解绑 UI delegate,保留底层设备缓存。 + func stop() { + connectionManager.unbindDelegate() + } + + /// 主动断开相机连接。 + func disconnect() { + connectionManager.disconnect() + } + + /// 刷新相机文件列表或重新连接。 + func refreshCameraFiles() { + switch connectionState { + case .connected: + loadPersistedPhotos() + case .idle, .disconnected, .failed: + connectionManager.start() + default: + break + } + } + + /// 重新加载当前相册本地 OTG 照片。 + func reloadLocalPhotos() { + loadPersistedPhotos() + } + + /// 切换 Tab。 + func selectTab(_ tab: TravelAlbumOTGTransferTab) { + selectedTab = tab + selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id))) + } + + /// 切换传输模式。 + func selectTransferMode(_ option: String) { + guard option == "边拍边传" || option == "拍后传输" else { return } + transferModeOption = option + } + + /// 切换上传格式。 + func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) { + guard photoFormatOption != option else { return } + photoFormatOption = option + applyMergedPhotos() + } + + /// 批量上传按钮:首次进入选择模式,再次上传选中照片。 + func onBatchUploadButtonClick() { + if !selectUploadMode { + selectUploadMode = true + selectedPhotoIds = [] + return + } + if selectedPhotoIds.isEmpty { + selectUploadMode = false + return + } + uploadSelectedTransferPhotos() + } + + /// 指定上传:默认上传所有未上传照片。 + func onSpecifyUploadButtonClick() { + let ids = Set(photos.filter(\.isNotUploaded).map(\.id)) + startUploadByIds(ids, emptyMessage: "暂无未上传的照片") + } + + /// 创建相机历史照片选择导入页 ViewModel。 + func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? { + guard isContentCatalogReady else { + showMessage("相机照片目录尚未就绪,请稍候再试") + return nil + } + guard let currentDriver else { + showMessage("请先连接相机") + return nil + } + if case .connected = connectionState { + return TravelAlbumCameraImportViewModel( + albumId: albumId, + albumTitle: albumTitle, + driver: currentDriver, + storage: storage, + appStore: appStore + ) + } + showMessage("请先连接相机") + return nil + } + + /// 选择或取消选择照片。 + func toggleTransferPhotoSelection(photoId: String) { + guard selectUploadMode, + photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else { + return + } + if selectedPhotoIds.contains(photoId) { + selectedPhotoIds.remove(photoId) + } else { + selectedPhotoIds.insert(photoId) + } + } + + /// 上传选中照片。 + func uploadSelectedTransferPhotos() { + let ids = selectedPhotoIds + guard !ids.isEmpty else { + showMessage("请先选择照片") + return + } + selectUploadMode = false + selectedPhotoIds = [] + startUploadByIds(ids, emptyMessage: "所选照片不可上传") + } + + /// 重试上传单张照片。 + func retryPhoto(photoId: String) { + startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传") + } + + /// 删除单张本地照片。 + func deletePhoto(photoId: String) { + storage.remove(albumId: albumId, photoId: photoId) + deletedPhotoIds.insert(photoId) + persistedRecordsById.removeValue(forKey: photoId) + selectedPhotoIds.remove(photoId) + applyMergedPhotos() + showMessage("已删除") + } + + /// 清空当前相册本地 OTG 缓存。 + func clearLocalAlbumCache() { + storage.clearAlbum(albumId: albumId) + persistedRecordsById = [:] + photos = [] + } + + private func syncFromConnectionManager() { + currentDriver = connectionManager.currentDriver + isContentCatalogReady = connectionManager.isContentCatalogReady + connectionState = connectionManager.state + } + + private func loadPersistedPhotos() { + deletedPhotoIds = storage.loadDeletedIds(albumId: albumId) + let records = storage.load(albumId: albumId) + persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) }) + applyMergedPhotos() + } + + private func applyMergedPhotos() { + let persistedItems = persistedRecordsById.values + .filter { !deletedPhotoIds.contains($0.id) } + .map { $0.toPhotoItem() } + photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt } + } + + private func startUploadByIds(_ ids: Set, emptyMessage: String) { + guard !isUploading else { + showMessage("正在上传,请稍候") + return + } + let targets = resolveUploadTargets(ids) + guard !targets.isEmpty else { + showMessage(emptyMessage) + return + } + isUploading = true + showMessage("开始上传 \(targets.count) 张照片") + Task { + for id in targets { + await uploadPhoto(id: id) + } + isUploading = false + notifyStateChanged() + } + } + + private func resolveUploadTargets(_ ids: Set) -> [String] { + ids.filter { persistedRecordsById[$0] != nil } + } + + private func uploadPhoto(id: String) async { + do { + let record = try await localRecordForUpload(id: id) + updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil) + let material = try await uploader.upload( + record: record, + scenicId: appStore.currentScenicId + ) { [weak self] progress in + self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) + } + updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl) + } catch { + updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) + showMessage(error.localizedDescription) + } + } + + private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord { + if let record = persistedRecordsById[id], + !record.localPath.isEmpty, + FileManager.default.fileExists(atPath: record.localPath) { + return record + } + throw TravelAlbumOTGUploadError.localFileMissing + } + + private func updateRecord( + id: String, + status: TravelAlbumOTGUploadStatus, + progress: Int, + error: String?, + remoteUrl: String? = nil + ) { + var record = persistedRecordsById[id] + if record == nil, let item = photos.first(where: { $0.id == id }) { + record = TravelAlbumOTGPhotoRecord( + id: item.id, + sourceId: item.sourceId, + fileName: item.fileName, + localPath: item.localPath, + thumbnailPath: item.thumbnailURL?.isFileURL == true ? item.thumbnailURL?.path ?? "" : "", + capturedAt: item.capturedAt, + fileSizeBytes: item.fileSizeBytes, + status: status, + progress: progress, + errorMessage: error, + albumId: albumId, + userId: appStore.userId, + remoteUrl: remoteUrl ?? item.remoteUrl + ) + } + guard var record else { return } + record.status = status + record.progress = min(max(progress, 0), 100) + record.errorMessage = error + if let remoteUrl { + record.remoteUrl = remoteUrl + } + record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) + persistedRecordsById[id] = record + storage.upsert(record, albumId: albumId) + applyMergedPhotos() + } + + private func notifyStateChanged() { + if Thread.isMainThread { + onStateChange?() + } else { + DispatchQueue.main.async { [weak self] in self?.onStateChange?() } + } + } + + private func showMessage(_ message: String) { + if Thread.isMainThread { + onShowMessage?(message) + } else { + DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) } + } + } + +} + +extension WiredCameraTransferViewModel: ConnectionManagerDelegate { + func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) { + connectionState = state + } + + func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) { + currentDriver = driver + } + + func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) { + currentDriver = driver + isContentCatalogReady = true + notifyStateChanged() + } + + func connectionManager(_ manager: ConnectionManager, didFail error: any Error) { + showMessage(error.localizedDescription) + } + + func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) { + showMessage("长时间未找到相机,请尝试重新插拔连接线") + } + + func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) { + guard albumID == albumId else { return } + loadPersistedPhotos() + onLiveShotSaved?(filename) } } diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index 3f1a3dc..9850113 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -24,7 +24,7 @@ weixinURLParamsAPI NSCameraUsageDescription - 需要访问相机以扫描订单核销二维码 + 需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输 NSLocationWhenInUseUsageDescription 需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡 NSPhotoLibraryUsageDescription diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift new file mode 100644 index 0000000..02d8407 --- /dev/null +++ b/suixinkan/UI/TravelAlbum/TravelAlbumCameraImportViewController.swift @@ -0,0 +1,537 @@ +// +// TravelAlbumCameraImportViewController.swift +// suixinkan +// + +import SnapKit +import UIKit + +/// 旅拍相册相机历史照片选择导入页。 +final class TravelAlbumCameraImportViewController: UIViewController { + private enum Layout { + static let columns: CGFloat = 3 + static let spacing: CGFloat = 2 + static let inset: CGFloat = 2 + static let headerHeight: CGFloat = 44 + } + + private let viewModel: TravelAlbumCameraImportViewModel + var onImportFinished: ((Int) -> Void)? + + private var collectionView: UICollectionView! + private var sections: [TravelAlbumCameraImportSection] = [] + private var lastCollectionWidth: CGFloat = 0 + + private lazy var importBarButton = UIBarButtonItem( + title: "导入 (0)", + style: .done, + target: self, + action: #selector(importTapped) + ) + private lazy var helpBarButton = UIBarButtonItem( + image: UIImage(systemName: "questionmark.circle"), + style: .plain, + target: self, + action: #selector(helpTapped) + ) + private let activityIndicator = UIActivityIndicatorView(style: .large) + private let messageLabel = UILabel() + private let helpButton = UIButton(type: .system) + + /// 初始化旅拍相册相机历史照片选择导入页。 + init(viewModel: TravelAlbumCameraImportViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "选择照片" + view.backgroundColor = .white + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "取消", + style: .plain, + target: self, + action: #selector(cancelTapped) + ) + navigationItem.rightBarButtonItem = importBarButton + importBarButton.isEnabled = false + setupUI() + setupConstraints() + bindViewModel() + viewModel.loadPhotos() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let width = collectionView.bounds.width + guard width > 0, width != lastCollectionWidth else { return } + lastCollectionWidth = width + collectionView.collectionViewLayout.invalidateLayout() + } + + private func setupUI() { + let layout = UICollectionViewFlowLayout() + layout.minimumLineSpacing = Layout.spacing + layout.minimumInteritemSpacing = Layout.spacing + layout.sectionInset = UIEdgeInsets( + top: Layout.inset, + left: Layout.inset, + bottom: Layout.inset, + right: Layout.inset + ) + layout.headerReferenceSize = CGSize(width: 0, height: Layout.headerHeight) + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) + collectionView.backgroundColor = .white + collectionView.allowsMultipleSelection = true + collectionView.dataSource = self + collectionView.delegate = self + collectionView.register( + TravelAlbumCameraImportPhotoCell.self, + forCellWithReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier + ) + collectionView.register( + TravelAlbumCameraImportSectionHeaderView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier + ) + + activityIndicator.hidesWhenStopped = true + messageLabel.numberOfLines = 0 + messageLabel.textAlignment = .center + messageLabel.textColor = AppColor.textSecondary + messageLabel.font = .systemFont(ofSize: 15) + messageLabel.isHidden = true + + helpButton.setTitle("如何切换到 MTP 模式", for: .normal) + helpButton.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium) + helpButton.backgroundColor = AppColor.primary.withAlphaComponent(0.10) + helpButton.setTitleColor(AppColor.primary, for: .normal) + helpButton.layer.cornerRadius = 9 + helpButton.isHidden = true + helpButton.addTarget(self, action: #selector(helpTapped), for: .touchUpInside) + + view.addSubview(collectionView) + view.addSubview(activityIndicator) + view.addSubview(messageLabel) + view.addSubview(helpButton) + } + + private func setupConstraints() { + collectionView.snp.makeConstraints { make in + make.edges.equalTo(view.safeAreaLayoutGuide) + } + activityIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + messageLabel.snp.makeConstraints { make in + make.centerX.equalToSuperview() + make.centerY.equalToSuperview().offset(-28) + make.leading.greaterThanOrEqualToSuperview().offset(32) + make.trailing.lessThanOrEqualToSuperview().offset(-32) + } + helpButton.snp.makeConstraints { make in + make.top.equalTo(messageLabel.snp.bottom).offset(20) + make.centerX.equalToSuperview() + make.height.equalTo(38) + make.width.greaterThanOrEqualTo(160) + } + } + + private func bindViewModel() { + viewModel.onStateUpdated = { [weak self] state in + self?.render(state) + } + viewModel.onSelectionUpdated = { [weak self] count in + self?.updateImportButton(count: count) + self?.collectionView.reloadData() + } + viewModel.onSonyMTPHelpVisibilityChanged = { [weak self] show in + self?.updateHelpVisibility(show) + } + } + + private func render(_ state: TravelAlbumCameraImportState) { + switch state { + case .idle, .loading: + collectionView.isHidden = true + messageLabel.isHidden = true + helpButton.isHidden = true + importBarButton.isEnabled = false + navigationItem.leftBarButtonItem?.isEnabled = true + activityIndicator.startAnimating() + + case .loaded(let loadedSections): + activityIndicator.stopAnimating() + collectionView.isHidden = false + messageLabel.isHidden = true + helpButton.isHidden = true + navigationItem.leftBarButtonItem?.isEnabled = true + sections = loadedSections + collectionView.reloadData() + + case .importing(let current, let total): + collectionView.isHidden = true + messageLabel.isHidden = false + messageLabel.text = "正在导入 \(current)/\(total)..." + helpButton.isHidden = true + importBarButton.isEnabled = false + navigationItem.leftBarButtonItem?.isEnabled = false + activityIndicator.startAnimating() + + case .finished(let count): + activityIndicator.stopAnimating() + dismiss(animated: true) { [weak self] in + self?.onImportFinished?(count) + } + + case .failed(let message): + activityIndicator.stopAnimating() + collectionView.isHidden = true + messageLabel.isHidden = false + messageLabel.text = message + importBarButton.isEnabled = false + navigationItem.leftBarButtonItem?.isEnabled = true + helpButton.isHidden = !viewModel.shouldShowSonyMTPHelp + } + } + + private func updateHelpVisibility(_ show: Bool) { + navigationItem.rightBarButtonItems = show ? [importBarButton, helpBarButton] : [importBarButton] + if case .failed = viewModel.state { + helpButton.isHidden = !show + } + } + + private func updateImportButton(count: Int) { + importBarButton.title = "导入 (\(count))" + importBarButton.isEnabled = count > 0 + } + + private func thumbnailPixelSize() -> Int { + guard let flow = collectionView.collectionViewLayout as? UICollectionViewFlowLayout else { + return 300 + } + return Int(itemSize(layout: flow).width * UIScreen.main.scale) + } + + private func itemSize(layout: UICollectionViewFlowLayout) -> CGSize { + let totalSpacing = layout.sectionInset.left + + layout.sectionInset.right + + layout.minimumInteritemSpacing * (Layout.columns - 1) + let width = floor((collectionView.bounds.width - totalSpacing) / Layout.columns) + return CGSize(width: width, height: width + 28) + } + + @objc private func cancelTapped() { + dismiss(animated: true) + } + + @objc private func importTapped() { + viewModel.importSelected() + } + + @objc private func helpTapped() { + guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return } + UIApplication.shared.open(url) + } +} + +extension TravelAlbumCameraImportViewController: UICollectionViewDataSource { + func numberOfSections(in collectionView: UICollectionView) -> Int { + sections.count + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + sections[section].photos.count + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: TravelAlbumCameraImportPhotoCell.reuseIdentifier, + for: indexPath + ) as! TravelAlbumCameraImportPhotoCell + let photo = sections[indexPath.section].photos[indexPath.item] + cell.apply( + photo: photo, + selected: viewModel.isPhotoSelected(id: photo.id), + alreadyImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id), + maxPixelSize: thumbnailPixelSize() + ) { [weak self] photo, maxPixelSize in + await self?.viewModel.thumbnailData(for: photo, maxPixelSize: maxPixelSize) + } + return cell + } + + func collectionView( + _ collectionView: UICollectionView, + viewForSupplementaryElementOfKind kind: String, + at indexPath: IndexPath + ) -> UICollectionReusableView { + guard kind == UICollectionView.elementKindSectionHeader else { + return UICollectionReusableView() + } + let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: TravelAlbumCameraImportSectionHeaderView.reuseIdentifier, + for: indexPath + ) as! TravelAlbumCameraImportSectionHeaderView + let section = sections[indexPath.section] + header.apply( + title: section.title, + count: section.photos.count, + importableCount: viewModel.importablePhotoCount(in: section), + importedCount: viewModel.importedPhotoCount(in: section), + hasImportablePhotos: viewModel.hasImportablePhotos(in: section), + fullySelected: viewModel.isSectionFullySelected(section), + partiallySelected: viewModel.isSectionPartiallySelected(section) + ) + header.onToggle = { [weak self] in + guard let self else { return } + self.viewModel.toggleSection(section) + self.collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + return header + } +} + +extension TravelAlbumCameraImportViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + let photo = sections[indexPath.section].photos[indexPath.item] + guard !viewModel.isPhotoAlreadyInAlbum(id: photo.id) else { return } + viewModel.togglePhoto(id: photo.id) + collectionView.reloadItems(at: [indexPath]) + collectionView.reloadSections(IndexSet(integer: indexPath.section)) + } + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + guard let flow = collectionViewLayout as? UICollectionViewFlowLayout else { + return CGSize(width: 120, height: 148) + } + return itemSize(layout: flow) + } +} + +/// 相机历史照片导入 Cell。 +private final class TravelAlbumCameraImportPhotoCell: UICollectionViewCell { + static let reuseIdentifier = "TravelAlbumCameraImportPhotoCell" + + private let imageView = UIImageView() + private let filenameLabel = UILabel() + private let checkmarkView = UIImageView() + private let selectedOverlayView = UIView() + private let importedOverlayView = UIView() + private let importedBadgeLabel = UILabel() + private var representedPhotoId: String? + private var loadToken = UUID() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + representedPhotoId = nil + loadToken = UUID() + imageView.image = nil + filenameLabel.text = nil + applySelection(false, alreadyImported: false) + applyImported(false) + } + + /// 绑定相机照片与选择状态。 + func apply( + photo: CameraObject, + selected: Bool, + alreadyImported: Bool, + maxPixelSize: Int, + thumbnailProvider: @escaping (CameraObject, Int) async -> Data? + ) { + representedPhotoId = photo.id + filenameLabel.text = photo.filename + setPlaceholderImage() + applyImported(alreadyImported) + applySelection(selected, alreadyImported: alreadyImported) + + let token = UUID() + loadToken = token + Task { @MainActor in + guard let data = await thumbnailProvider(photo, maxPixelSize), + self.loadToken == token, + self.representedPhotoId == photo.id, + let image = UIImage(data: data) else { + return + } + self.imageView.image = image + self.imageView.tintColor = nil + } + } + + private func setupUI() { + contentView.backgroundColor = AppColor.pageBackground + contentView.clipsToBounds = true + contentView.layer.cornerRadius = 4 + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + selectedOverlayView.backgroundColor = AppColor.primary.withAlphaComponent(0.25) + selectedOverlayView.isHidden = true + importedOverlayView.backgroundColor = UIColor.black.withAlphaComponent(0.35) + importedOverlayView.isHidden = true + + importedBadgeLabel.text = "已在相册" + importedBadgeLabel.font = .systemFont(ofSize: 9, weight: .semibold) + importedBadgeLabel.textColor = .white + importedBadgeLabel.textAlignment = .center + importedBadgeLabel.backgroundColor = UIColor.black.withAlphaComponent(0.55) + importedBadgeLabel.layer.cornerRadius = 4 + importedBadgeLabel.clipsToBounds = true + importedBadgeLabel.isHidden = true + + checkmarkView.image = UIImage(systemName: "checkmark.circle.fill") + checkmarkView.tintColor = AppColor.primary + checkmarkView.isHidden = true + + filenameLabel.font = .systemFont(ofSize: 9, weight: .medium) + filenameLabel.textColor = AppColor.textSecondary + filenameLabel.textAlignment = .center + filenameLabel.numberOfLines = 2 + + [imageView, selectedOverlayView, importedOverlayView, importedBadgeLabel, checkmarkView, filenameLabel].forEach { + contentView.addSubview($0) + } + + imageView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(contentView.snp.width) + } + selectedOverlayView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(imageView) + } + importedOverlayView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.bottom.equalTo(imageView) + } + importedBadgeLabel.snp.makeConstraints { make in + make.center.equalTo(imageView) + make.height.equalTo(18) + make.width.greaterThanOrEqualTo(52) + } + checkmarkView.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(4) + make.size.equalTo(22) + } + filenameLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(2) + make.leading.trailing.equalToSuperview().inset(2) + make.bottom.lessThanOrEqualToSuperview().offset(-2) + } + } + + private func setPlaceholderImage() { + let config = UIImage.SymbolConfiguration(pointSize: 28, weight: .light) + imageView.image = UIImage(systemName: "photo", withConfiguration: config) + imageView.tintColor = AppColor.textTertiary + } + + private func applySelection(_ selected: Bool, alreadyImported: Bool) { + let showSelection = selected && !alreadyImported + selectedOverlayView.isHidden = !showSelection + checkmarkView.isHidden = !showSelection + contentView.layer.borderWidth = showSelection ? 2 : 0 + contentView.layer.borderColor = showSelection ? AppColor.primary.cgColor : nil + } + + private func applyImported(_ alreadyImported: Bool) { + importedOverlayView.isHidden = !alreadyImported + importedBadgeLabel.isHidden = !alreadyImported + contentView.alpha = alreadyImported ? 0.55 : 1 + } +} + +/// 相机历史照片导入分组 Header。 +private final class TravelAlbumCameraImportSectionHeaderView: UICollectionReusableView { + static let reuseIdentifier = "TravelAlbumCameraImportSectionHeaderView" + + var onToggle: (() -> Void)? + + private let checkButton = UIButton(type: .system) + private let titleLabel = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// 绑定分组信息。 + func apply( + title: String, + count: Int, + importableCount: Int, + importedCount: Int, + hasImportablePhotos: Bool, + fullySelected: Bool, + partiallySelected: Bool + ) { + titleLabel.text = importedCount > 0 ? "\(title) · 可导入 \(importableCount) / 共 \(count) 张" : "\(title) · \(count) 张" + checkButton.isEnabled = hasImportablePhotos + let symbolName: String + if !hasImportablePhotos { + symbolName = "circle" + } else if fullySelected { + symbolName = "checkmark.circle.fill" + } else if partiallySelected { + symbolName = "minus.circle.fill" + } else { + symbolName = "circle" + } + checkButton.setImage(UIImage(systemName: symbolName), for: .normal) + checkButton.tintColor = hasImportablePhotos && (fullySelected || partiallySelected) ? AppColor.primary : AppColor.textTertiary + } + + private func setupUI() { + backgroundColor = .white + checkButton.addTarget(self, action: #selector(toggleTapped), for: .touchUpInside) + titleLabel.font = .systemFont(ofSize: 15, weight: .semibold) + titleLabel.textColor = AppColor.textPrimary + + addSubview(checkButton) + addSubview(titleLabel) + checkButton.snp.makeConstraints { make in + make.leading.equalToSuperview().offset(12) + make.centerY.equalToSuperview() + make.size.equalTo(28) + } + titleLabel.snp.makeConstraints { make in + make.leading.equalTo(checkButton.snp.trailing).offset(8) + make.trailing.equalToSuperview().offset(-12) + make.centerY.equalToSuperview() + } + } + + @objc private func toggleTapped() { + onToggle?() + } +} diff --git a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift index b748d5f..f866d8f 100644 --- a/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift +++ b/suixinkan/UI/TravelAlbum/TravelAlbumDetailViewController.swift @@ -166,7 +166,11 @@ final class TravelAlbumDetailViewController: BaseViewController { Task { @MainActor in self?.showToast(message) } } viewModel.onDeletedAlbum = { [weak self] _ in - Task { @MainActor in self?.navigationController?.popViewController(animated: true) } + Task { @MainActor in + guard let self else { return } + TravelAlbumOTGPhotoStore().clearAlbum(albumId: self.viewModel.albumId) + self.navigationController?.popViewController(animated: true) + } } } diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index 12c2170..b763e1d 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -3,10 +3,11 @@ // suixinkan // +import Kingfisher import SnapKit import UIKit -/// 有线相机传输页,仅同步 Android 断开空态 UI,不接入 OTG 或上传业务。 +/// 有线相机传输页,对齐 Android OTG 页面并接入真实 ImageCaptureCore 传输。 final class WiredCameraTransferViewController: BaseViewController { private let viewModel: WiredCameraTransferViewModel @@ -22,10 +23,16 @@ final class WiredCameraTransferViewController: BaseViewController { private let refreshButton = UIButton(type: .system) private let helpLabel = UILabel() private let chipsStack = UIStackView() + private let retouchButton = UIButton(type: .system) + private let formatButton = UIButton(type: .system) + private let modeButton = UIButton(type: .system) private let statsCard = UIStackView() private let emptyLabel = UILabel() + private var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! private let bottomBar = UIView() private let batchButton = UIButton(type: .system) + private let historyImportButton = UIButton(type: .system) private let specifyButton = UIButton(type: .system) init(viewModel: WiredCameraTransferViewModel) { @@ -42,8 +49,15 @@ final class WiredCameraTransferViewController: BaseViewController { navigationController?.setNavigationBarHidden(true, animated: false) } + override func viewDidLoad() { + super.viewDidLoad() + viewModel.start() + applyViewModel() + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + viewModel.stop() navigationController?.setNavigationBarHidden(false, animated: animated) } @@ -69,41 +83,50 @@ final class WiredCameraTransferViewController: BaseViewController { storagePanel.layer.cornerRadius = 12 storagePanel.layer.borderColor = AppColor.border.cgColor storagePanel.layer.borderWidth = 3 - storageTitleLabel.text = viewModel.deviceModelName storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium) storageTitleLabel.textColor = AppColor.textPrimary storageTitleLabel.textAlignment = .center - storageValueLabel.text = viewModel.availableStorageText storageValueLabel.font = .systemFont(ofSize: 10) storageValueLabel.textColor = AppColor.primary storageValueLabel.textAlignment = .center - statusLabel.text = viewModel.cameraStatusText statusLabel.font = .systemFont(ofSize: 11, weight: .medium) - statusLabel.textColor = AppColor.danger - statusLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.10) statusLabel.layer.cornerRadius = 4 statusLabel.clipsToBounds = true statusLabel.textAlignment = .center - refreshButton.setTitle(viewModel.actionButtonText, for: .normal) refreshButton.setTitleColor(.white, for: .normal) refreshButton.titleLabel?.font = .systemFont(ofSize: 12) - refreshButton.backgroundColor = AppColor.danger + refreshButton.backgroundColor = AppColor.primary refreshButton.layer.cornerRadius = 8 - helpLabel.attributedText = helpText() helpLabel.font = .systemFont(ofSize: 10) - helpLabel.numberOfLines = 2 + helpLabel.numberOfLines = 3 + helpLabel.isUserInteractionEnabled = true chipsStack.axis = .horizontal chipsStack.spacing = 6 - [viewModel.retouchOption, viewModel.photoFormatOption, viewModel.transferModeOption].forEach { - chipsStack.addArrangedSubview(makeChip($0)) + [retouchButton, formatButton, modeButton].forEach { + configureChipButton($0) + chipsStack.addArrangedSubview($0) } statsCard.axis = .horizontal statsCard.distribution = .fillEqually - ["全部", "已上传", "失败"].enumerated().forEach { index, title in - statsCard.addArrangedSubview(makeStat(title: title, count: viewModel.tabCounts[index], selected: index == 0, danger: index == 2)) + + collectionView = UICollectionView(frame: .zero, collectionViewLayout: makeLayout()) + collectionView.backgroundColor = AppColor.pageBackground + collectionView.delegate = self + collectionView.register(WiredTransferPhotoCell.self, forCellWithReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier) + dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { + [weak self] collectionView, indexPath, item in + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: WiredTransferPhotoCell.reuseIdentifier, + for: indexPath + ) as! WiredTransferPhotoCell + let selected = self?.viewModel.selectedPhotoIds.contains(item.id) == true + cell.apply(item: item, selectionMode: self?.viewModel.selectUploadMode == true, selected: selected) + cell.onRetry = { [weak self] in self?.viewModel.retryPhoto(photoId: item.id) } + cell.onDelete = { [weak self] in self?.confirmDeletePhoto(item.id) } + return cell } emptyLabel.text = "暂无照片" @@ -113,6 +136,7 @@ final class WiredCameraTransferViewController: BaseViewController { bottomBar.backgroundColor = .white configureBottomButton(batchButton, title: "批量上传", filled: true) + configureBottomButton(historyImportButton, title: "历史导入", filled: false) configureBottomButton(specifyButton, title: "指定上传", filled: false) view.addSubview(headerView) @@ -128,9 +152,11 @@ final class WiredCameraTransferViewController: BaseViewController { statusCard.addSubview(helpLabel) statusCard.addSubview(chipsStack) statusCard.addSubview(statsCard) + view.addSubview(collectionView) view.addSubview(emptyLabel) view.addSubview(bottomBar) bottomBar.addSubview(batchButton) + bottomBar.addSubview(historyImportButton) bottomBar.addSubview(specifyButton) } @@ -206,11 +232,20 @@ final class WiredCameraTransferViewController: BaseViewController { make.height.equalTo(44) make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) } + historyImportButton.snp.makeConstraints { make in + make.top.height.width.equalTo(batchButton) + make.leading.equalTo(batchButton.snp.trailing).offset(8) + } specifyButton.snp.makeConstraints { make in make.top.height.width.equalTo(batchButton) - make.leading.equalTo(batchButton.snp.trailing).offset(12) + make.leading.equalTo(historyImportButton.snp.trailing).offset(8) make.trailing.equalToSuperview().offset(-16) } + collectionView.snp.makeConstraints { make in + make.top.equalTo(statusCard.snp.bottom).offset(14) + make.leading.trailing.equalToSuperview().inset(16) + make.bottom.equalTo(bottomBar.snp.top).offset(-10) + } emptyLabel.snp.makeConstraints { make in make.top.equalTo(statusCard.snp.bottom) make.leading.trailing.equalToSuperview() @@ -219,62 +254,102 @@ final class WiredCameraTransferViewController: BaseViewController { } override func bindActions() { + viewModel.onStateChange = { [weak self] in + Task { @MainActor in self?.applyViewModel() } + } viewModel.onShowMessage = { [weak self] message in Task { @MainActor in self?.showToast(message) } } + viewModel.onLiveShotSaved = { [weak self] filename in + Task { @MainActor in self?.showToast("已保存 \(filename)") } + } backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) - refreshButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside) - batchButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside) - specifyButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside) + refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside) + batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside) + historyImportButton.addTarget(self, action: #selector(historyImportTapped), for: .touchUpInside) + specifyButton.addTarget(self, action: #selector(specifyTapped), for: .touchUpInside) + formatButton.menu = UIMenu(children: TravelAlbumOTGPhotoFormatOption.allCases.map { option in + UIAction(title: option.rawValue) { [weak self] _ in self?.viewModel.selectPhotoFormat(option) } + }) + formatButton.showsMenuAsPrimaryAction = true + modeButton.menu = UIMenu(children: ["边拍边传", "拍后传输"].map { option in + UIAction(title: option) { [weak self] _ in self?.viewModel.selectTransferMode(option) } + }) + modeButton.showsMenuAsPrimaryAction = true + helpLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(helpTapped))) } - private func makeChip(_ text: String) -> UIView { - let label = UILabel() - label.text = text - label.font = .systemFont(ofSize: 11) - label.textColor = AppColor.textTertiary - label.backgroundColor = AppColor.pageBackground - label.layer.cornerRadius = 8 - label.layer.borderWidth = 1 - label.layer.borderColor = AppColor.border.cgColor - label.clipsToBounds = true - label.textAlignment = .center - return label + @MainActor + private func applyViewModel() { + storageTitleLabel.text = viewModel.deviceModelName + storageValueLabel.text = viewModel.availableStorageText + statusLabel.text = viewModel.cameraStatusText + let isFailed = viewModel.cameraStatusText.contains("失败") || viewModel.cameraStatusText.contains("未连接") + statusLabel.textColor = isFailed ? AppColor.danger : AppColor.primary + statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10) + refreshButton.setTitle(viewModel.actionButtonText, for: .normal) + + retouchButton.setTitle(viewModel.retouchOption, for: .normal) + formatButton.setTitle(viewModel.photoFormatOption.rawValue, for: .normal) + modeButton.setTitle(viewModel.transferModeOption, for: .normal) + helpLabel.attributedText = helpText(viewModel.sonyMTPHint) + + rebuildStats() + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([0]) + snapshot.appendItems(viewModel.filteredPhotos()) + dataSource.apply(snapshot, animatingDifferences: true) + + emptyLabel.isHidden = !viewModel.filteredPhotos().isEmpty + collectionView.isHidden = viewModel.filteredPhotos().isEmpty + if viewModel.selectUploadMode { + let count = viewModel.selectedPhotoIds.count + batchButton.setTitle(count > 0 ? "上传选中(\(count))" : "取消", for: .normal) + } else { + batchButton.setTitle("批量上传", for: .normal) + } + historyImportButton.setTitle("历史导入", for: .normal) } - private func makeStat(title: String, count: Int, selected: Bool, danger: Bool) -> UIView { - let container = UIView() - let countLabel = UILabel() - let titleLabel = UILabel() - let underline = UIView() - countLabel.text = "\(count)" - countLabel.font = .systemFont(ofSize: 18, weight: .semibold) - countLabel.textColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary) - countLabel.textAlignment = .center - titleLabel.text = title - titleLabel.font = .systemFont(ofSize: 13) - titleLabel.textColor = selected ? AppColor.primary : AppColor.textSecondary - titleLabel.textAlignment = .center - underline.backgroundColor = selected ? AppColor.primary : .clear - underline.layer.cornerRadius = 1 - container.addSubview(countLabel) - container.addSubview(titleLabel) - container.addSubview(underline) - countLabel.snp.makeConstraints { make in - make.top.equalToSuperview().offset(8) - make.leading.trailing.equalToSuperview() + private func rebuildStats() { + statsCard.arrangedSubviews.forEach { view in + statsCard.removeArrangedSubview(view) + view.removeFromSuperview() } - titleLabel.snp.makeConstraints { make in - make.top.equalTo(countLabel.snp.bottom).offset(2) - make.leading.trailing.equalToSuperview() + for tab in TravelAlbumOTGTransferTab.allCases { + let selected = viewModel.selectedTab == tab + let button = makeStatButton( + title: tab.title, + count: viewModel.tabCounts[tab.rawValue], + selected: selected, + danger: tab == .failed + ) + button.tag = tab.rawValue + button.addTarget(self, action: #selector(tabTapped(_:)), for: .touchUpInside) + statsCard.addArrangedSubview(button) } - underline.snp.makeConstraints { make in - make.top.equalTo(titleLabel.snp.bottom).offset(4) - make.centerX.equalToSuperview() - make.width.equalTo(24) - make.height.equalTo(2) - } - return container + } + + private func configureChipButton(_ button: UIButton) { + button.titleLabel?.font = .systemFont(ofSize: 11) + button.setTitleColor(AppColor.textTertiary, for: .normal) + button.backgroundColor = AppColor.pageBackground + button.layer.cornerRadius = 8 + button.layer.borderWidth = 1 + button.layer.borderColor = AppColor.border.cgColor + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + } + + private func makeStatButton(title: String, count: Int, selected: Bool, danger: Bool) -> UIButton { + var config = UIButton.Configuration.plain() + config.title = "\(count)\n\(title)" + config.titleAlignment = .center + config.baseForegroundColor = selected ? AppColor.primary : (danger ? AppColor.danger : AppColor.textPrimary) + config.background.backgroundColor = .white + let button = UIButton(configuration: config) + button.titleLabel?.numberOfLines = 2 + button.titleLabel?.font = .systemFont(ofSize: 14, weight: selected ? .semibold : .regular) + return button } private func configureBottomButton(_ button: UIButton, title: String, filled: Bool) { @@ -292,20 +367,240 @@ final class WiredCameraTransferViewController: BaseViewController { } } - private func helpText() -> NSAttributedString { + private func makeLayout() -> UICollectionViewCompositionalLayout { + UICollectionViewCompositionalLayout { _, environment in + let spacing: CGFloat = 8 + let width = (environment.container.effectiveContentSize.width - spacing * 2) / 3 + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), heightDimension: .absolute(width + 58)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), heightDimension: .absolute(width + 58)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, repeatingSubitem: item, count: 3) + group.interItemSpacing = .fixed(spacing) + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = 12 + return section + } + } + + private func helpText(_ sonyHint: String?) -> NSAttributedString { let text = NSMutableAttributedString( - string: "未连接,查看", + string: sonyHint ?? "未连接,查看", attributes: [.foregroundColor: AppColor.textSecondary] ) - text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary])) + if sonyHint == nil { + text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary])) + } return text } + private func confirmDeletePhoto(_ photoId: String) { + let alert = UIAlertController(title: "删除照片", message: "仅删除本地 OTG 缓存,不会删除服务端已上传素材。", preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "取消", style: .cancel)) + alert.addAction(UIAlertAction(title: "删除", style: .destructive) { [weak self] _ in + self?.viewModel.deletePhoto(photoId: photoId) + }) + present(alert, animated: true) + } + @objc private func backTapped() { navigationController?.popViewController(animated: true) } - @objc private func uiOnlyTapped() { - viewModel.showUIOnlyMessage() + @objc private func refreshTapped() { + viewModel.refreshCameraFiles() + } + + @objc private func batchTapped() { + viewModel.onBatchUploadButtonClick() + } + + @objc private func historyImportTapped() { + guard let importViewModel = viewModel.makeCameraImportViewModel() else { return } + let importController = TravelAlbumCameraImportViewController(viewModel: importViewModel) + importController.onImportFinished = { [weak self] count in + self?.viewModel.reloadLocalPhotos() + self?.showToast("已导入 \(count) 张照片") + } + let navigationController = UINavigationController(rootViewController: importController) + navigationController.modalPresentationStyle = .pageSheet + present(navigationController, animated: true) + } + + @objc private func specifyTapped() { + viewModel.onSpecifyUploadButtonClick() + } + + @objc private func tabTapped(_ sender: UIButton) { + guard let tab = TravelAlbumOTGTransferTab(rawValue: sender.tag) else { return } + viewModel.selectTab(tab) + } + + @objc private func helpTapped() { + guard let url = URL(string: "https://sharesky.feishu.cn/wiki/CaAhwl3Gnin4Kqk0BVocK3SGnab?from=from_copylink") else { return } + UIApplication.shared.open(url) + } +} + +extension WiredCameraTransferViewController: UICollectionViewDelegate { + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + guard let item = dataSource.itemIdentifier(for: indexPath) else { return } + if viewModel.selectUploadMode { + viewModel.toggleTransferPhotoSelection(photoId: item.id) + } else if item.status == .failed { + viewModel.retryPhoto(photoId: item.id) + } + } +} + +/// 有线传输照片宫格 Cell。 +private final class WiredTransferPhotoCell: UICollectionViewCell { + static let reuseIdentifier = "WiredTransferPhotoCell" + + private let imageView = UIImageView() + private let statusLabel = UILabel() + private let titleLabel = UILabel() + private let sizeLabel = UILabel() + private let progressView = UIProgressView(progressViewStyle: .default) + private let retryButton = UIButton(type: .system) + private let deleteButton = UIButton(type: .system) + private let selectionOverlay = UIImageView() + + var onRetry: (() -> Void)? + var onDelete: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupUI() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + imageView.kf.cancelDownloadTask() + imageView.image = nil + onRetry = nil + onDelete = nil + } + + func apply(item: TravelAlbumOTGPhotoItem, selectionMode: Bool, selected: Bool) { + if let url = item.thumbnailURL { + imageView.kf.setImage(with: url, placeholder: UIImage(systemName: "photo")) + } else { + imageView.image = UIImage(systemName: "photo") + imageView.tintColor = AppColor.textTertiary + } + titleLabel.text = item.fileName + sizeLabel.text = item.fileSizeText + statusLabel.text = statusText(item.status, progress: item.progress) + statusLabel.backgroundColor = statusColor(item.status).withAlphaComponent(0.90) + progressView.isHidden = item.status != .uploading && item.status != .transferring + progressView.progress = Float(item.progress) / 100.0 + retryButton.isHidden = item.status != .failed + deleteButton.isHidden = selectionMode + selectionOverlay.isHidden = !selectionMode + selectionOverlay.image = UIImage(systemName: selected ? "checkmark.circle.fill" : "circle") + selectionOverlay.tintColor = selected ? AppColor.primary : .white + } + + private func setupUI() { + contentView.backgroundColor = .white + contentView.layer.cornerRadius = 8 + contentView.clipsToBounds = true + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + statusLabel.font = .systemFont(ofSize: 10, weight: .medium) + statusLabel.textColor = .white + statusLabel.textAlignment = .center + statusLabel.layer.cornerRadius = 4 + statusLabel.clipsToBounds = true + titleLabel.font = .systemFont(ofSize: 11, weight: .medium) + titleLabel.textColor = AppColor.textPrimary + titleLabel.lineBreakMode = .byTruncatingMiddle + sizeLabel.font = .systemFont(ofSize: 10) + sizeLabel.textColor = AppColor.textTertiary + retryButton.setImage(UIImage(systemName: "arrow.clockwise"), for: .normal) + retryButton.tintColor = AppColor.danger + deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) + deleteButton.tintColor = AppColor.textTertiary + selectionOverlay.contentMode = .scaleAspectFit + + contentView.addSubview(imageView) + imageView.addSubview(statusLabel) + imageView.addSubview(selectionOverlay) + contentView.addSubview(titleLabel) + contentView.addSubview(sizeLabel) + contentView.addSubview(progressView) + contentView.addSubview(retryButton) + contentView.addSubview(deleteButton) + + imageView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + make.height.equalTo(contentView.snp.width) + } + statusLabel.snp.makeConstraints { make in + make.top.leading.equalToSuperview().offset(5) + make.height.equalTo(18) + make.width.greaterThanOrEqualTo(44) + } + selectionOverlay.snp.makeConstraints { make in + make.top.trailing.equalToSuperview().inset(6) + make.size.equalTo(22) + } + titleLabel.snp.makeConstraints { make in + make.top.equalTo(imageView.snp.bottom).offset(5) + make.leading.equalToSuperview().offset(6) + make.trailing.equalTo(deleteButton.snp.leading).offset(-4) + } + sizeLabel.snp.makeConstraints { make in + make.top.equalTo(titleLabel.snp.bottom).offset(2) + make.leading.trailing.equalTo(titleLabel) + } + progressView.snp.makeConstraints { make in + make.leading.trailing.bottom.equalToSuperview().inset(6) + } + retryButton.snp.makeConstraints { make in + make.centerY.equalTo(titleLabel) + make.trailing.equalToSuperview().offset(-2) + make.size.equalTo(26) + } + deleteButton.snp.makeConstraints { make in + make.centerY.equalTo(titleLabel) + make.trailing.equalToSuperview().offset(-2) + make.size.equalTo(26) + } + + retryButton.addTarget(self, action: #selector(retryTapped), for: .touchUpInside) + deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside) + } + + private func statusText(_ status: TravelAlbumOTGUploadStatus, progress: Int) -> String { + switch status { + case .pending: return "待上传" + case .transferring: return "传输\(progress)%" + case .uploading: return "上传\(progress)%" + case .uploaded: return "已上传" + case .failed: return "失败" + } + } + + private func statusColor(_ status: TravelAlbumOTGUploadStatus) -> UIColor { + switch status { + case .pending: return AppColor.textTertiary + case .transferring, .uploading: return AppColor.primary + case .uploaded: return UIColor(hex: 0x16A34A) + case .failed: return AppColor.danger + } + } + + @objc private func retryTapped() { + onRetry?() + } + + @objc private func deleteTapped() { + onDelete?() } } diff --git a/suixinkanTests/TravelAlbumAPITests.swift b/suixinkanTests/TravelAlbumAPITests.swift index beb5c9c..1700ceb 100644 --- a/suixinkanTests/TravelAlbumAPITests.swift +++ b/suixinkanTests/TravelAlbumAPITests.swift @@ -73,6 +73,29 @@ final class TravelAlbumAPITests: XCTestCase { XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code") } + func testUploadMaterialBuildsPathAndBody() async throws { + let material = envelopeJSON(#"{"id":6,"user_equity_travel_id":3,"file_name":"A.JPG","file_url":"https://cdn/a.jpg"}"#) + let session = MockURLSession(responses: [material]) + let api = TravelAlbumAPI(client: APIClient(environment: .testing, session: session)) + + let response = try await api.uploadMaterial( + TravelAlbumUploadMaterialRequest( + userEquityTravelId: 3, + fileName: "A.JPG", + fileUrl: "https://cdn/a.jpg" + ) + ) + + XCTAssertEqual(response.id, 6) + let request = try XCTUnwrap(session.requests.first) + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/travel-album/upload-material") + let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any] + XCTAssertEqual(body?["user_equity_travel_id"] as? Int, 3) + XCTAssertEqual(body?["file_name"] as? String, "A.JPG") + XCTAssertEqual(body?["file_url"] as? String, "https://cdn/a.jpg") + } + private func envelopeJSON(_ dataJSON: String) -> Data { """ {"code":100000,"msg":"success","data":\(dataJSON)} diff --git a/suixinkanTests/TravelAlbumOTGCoreTests.swift b/suixinkanTests/TravelAlbumOTGCoreTests.swift new file mode 100644 index 0000000..e9d71e9 --- /dev/null +++ b/suixinkanTests/TravelAlbumOTGCoreTests.swift @@ -0,0 +1,87 @@ +// +// TravelAlbumOTGCoreTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 旅拍相册 OTG Core 逻辑测试。 +final class TravelAlbumOTGCoreTests: XCTestCase { + func testPlatformDetectorPrefersVendorID() { + let sony = CameraDeviceInfo( + name: "Unknown", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: 0x054C, + usbProductID: nil + ) + let canon = CameraDeviceInfo( + name: "EOS R6", + manufacturer: nil, + model: nil, + serialNumber: nil, + usbVendorID: nil, + usbProductID: nil + ) + + XCTAssertEqual(PlatformDetector.detect(from: sony), .sony) + XCTAssertEqual(PlatformDetector.detect(from: canon), .canon) + } + + func testPTPCommandBuildAndParse() throws { + let data = PTPHelper.buildCommand(code: 0x1001, transactionID: 7, parameters: [1, 2]) + + let parsed = try PTPHelper.parseContainer(data) + + XCTAssertEqual(parsed.length, 20) + XCTAssertEqual(parsed.type, PTPContainerType.command.rawValue) + XCTAssertEqual(parsed.code, 0x1001) + XCTAssertEqual(parsed.transactionID, 7) + XCTAssertEqual(parsed.params, [1, 2]) + } + + func testPhotoFormatMatcherResolvesJpegRawPeers() { + let jpeg = CameraObject(id: "J", filename: "IMG_0001.JPG", fileSize: 1, capturedAt: Date()) + let raw = CameraObject(id: "R", filename: "IMG_0001.ARW", fileSize: 2, capturedAt: Date()) + let other = CameraObject(id: "O", filename: "IMG_0002.MOV", fileSize: 3, capturedAt: Date()) + + XCTAssertEqual(TravelAlbumOTGPhotoFormatMatcher.classify(name: jpeg.filename), .jpeg) + XCTAssertEqual(TravelAlbumOTGPhotoFormatMatcher.classify(name: raw.filename), .raw) + XCTAssertEqual(TravelAlbumOTGPhotoFormatMatcher.classify(name: other.filename, isVideo: true), .other) + + let jpgTargets = TravelAlbumOTGPhotoFormatMatcher.resolveUploadTargets( + requested: [raw], + allObjects: [jpeg, raw, other], + option: .jpg + ) + let pairTargets = TravelAlbumOTGPhotoFormatMatcher.resolveUploadTargets( + requested: [jpeg], + allObjects: [jpeg, raw, other], + option: .jpegRaw + ) + + XCTAssertEqual(jpgTargets.map(\.id), ["J"]) + XCTAssertEqual(pairTargets.map(\.id), ["J", "R"]) + } + + func testOSSAlbumObjectKeyUsesAlbumModule() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7))! + let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! + + let key = OSSUploadPolicy.objectKey( + fileName: "A/B.JPG", + scenicId: 9, + moduleType: "album", + date: date, + uuid: uuid, + timeZone: TimeZone(secondsFromGMT: 0)! + ) + + XCTAssertTrue(key.hasPrefix("album/20260707/9/11111111222233334444555555555555_")) + XCTAssertTrue(key.hasSuffix("A_B.JPG")) + } +} diff --git a/suixinkanTests/TravelAlbumOTGStorageTests.swift b/suixinkanTests/TravelAlbumOTGStorageTests.swift new file mode 100644 index 0000000..4549696 --- /dev/null +++ b/suixinkanTests/TravelAlbumOTGStorageTests.swift @@ -0,0 +1,109 @@ +// +// TravelAlbumOTGStorageTests.swift +// suixinkanTests +// + +import XCTest +@testable import suixinkan + +/// 旅拍相册 OTG 本地存储测试。 +final class TravelAlbumOTGStorageTests: XCTestCase { + func testStoragePathContainsAccountScenicStoreAndAlbum() throws { + let root = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = makeStore(root: root) + + let originals = try store.originalsDirectory(albumId: 33) + let previews = try store.previewsDirectory(albumId: 33) + + XCTAssertTrue(originals.path.contains("u1_photog/scenic_11/store_22/album_33/originals")) + XCTAssertTrue(previews.path.contains("u1_photog/scenic_11/store_22/album_33/previews")) + } + + func testUniqueOriginalFileURLAvoidsOverwrite() throws { + let root = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = makeStore(root: root) + + let first = try store.uniqueOriginalFileURL(filename: "IMG/1.JPG", albumId: 33) + try Data([1]).write(to: first) + let second = try store.uniqueOriginalFileURL(filename: "IMG/1.JPG", albumId: 33) + + XCTAssertEqual(first.lastPathComponent, "IMG_1.JPG") + XCTAssertEqual(second.lastPathComponent, "IMG_1_1.JPG") + } + + func testLoadNormalizesInterruptedTransferToPending() throws { + let root = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = makeStore(root: root) + let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33) + try Data([1, 2, 3]).write(to: file) + let record = TravelAlbumOTGPhotoRecord( + id: "1", + fileName: "A.JPG", + localPath: file.path, + capturedAt: "2026-07-07 10:00:00", + fileSizeBytes: 3, + status: .uploading, + progress: 50, + albumId: 33, + userId: "u1" + ) + + store.save([record], albumId: 33) + let loaded = store.load(albumId: 33) + + XCTAssertEqual(loaded.first?.status, .pending) + XCTAssertEqual(loaded.first?.progress, 0) + } + + func testRemoveAndClearAlbumDeleteFiles() throws { + let root = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let store = makeStore(root: root) + let file = try store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 33) + try Data([1]).write(to: file) + let record = TravelAlbumOTGPhotoRecord( + id: "1", + fileName: "A.JPG", + localPath: file.path, + capturedAt: "2026-07-07 10:00:00", + fileSizeBytes: 1, + status: .pending, + albumId: 33, + userId: "u1" + ) + store.save([record], albumId: 33) + + store.remove(albumId: 33, photoId: "1") + + XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) + XCTAssertTrue(store.load(albumId: 33).isEmpty) + + let another = try store.uniqueOriginalFileURL(filename: "B.JPG", albumId: 33) + try Data([2]).write(to: another) + store.clearAlbum(albumId: 33) + + XCTAssertFalse(FileManager.default.fileExists(atPath: another.deletingLastPathComponent().deletingLastPathComponent().path)) + } + + private func makeStore(root: URL) -> TravelAlbumOTGPhotoStore { + TravelAlbumOTGPhotoStore( + context: TravelAlbumOTGStorageContext( + accountCachePrefix: "u1_photog", + userId: "u1", + scenicId: 11, + storeId: 22 + ), + applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport", isDirectory: true), + cachesDirectory: root.appendingPathComponent("Caches", isDirectory: true) + ) + } + + private func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 0a3e474..6461d8d 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -130,21 +130,400 @@ final class TravelAlbumDetailViewModelTests: XCTestCase { } /// 有线传输 ViewModel 测试。 +@MainActor final class WiredCameraTransferViewModelTests: XCTestCase { - func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() { + func testDefaultDisconnectedStateAndOptions() { let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000") - var message = "" - viewModel.onShowMessage = { message = $0 } XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB") XCTAssertEqual(viewModel.tabCounts, [0, 0, 0]) - viewModel.showUIOnlyMessage() - XCTAssertEqual(message, "仅同步 UI,暂未接入 OTG 传输") + XCTAssertEqual(viewModel.photoFormatOption, .jpg) + XCTAssertEqual(viewModel.transferModeOption, "边拍边传") + XCTAssertTrue(viewModel.filteredPhotos().isEmpty) + } + + func testEnteringPageDoesNotListCameraObjects() async { + let context = makeOTGTestContext() + let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()) + let driver = MockCameraDriver(objects: [object]) + let manager = MockWiredCameraConnectionManager(driver: driver) + let viewModel = makeWiredViewModel(context: context, manager: manager) + + viewModel.start() + try? await Task.sleep(nanoseconds: 80_000_000) + + XCTAssertEqual(driver.listCallCount, 0) + XCTAssertTrue(viewModel.filteredPhotos().isEmpty) + } + + func testHistoryImportRequiresConnectedCatalog() { + let context = makeOTGTestContext() + let driver = MockCameraDriver(objects: []) + let manager = MockWiredCameraConnectionManager(driver: driver, isContentCatalogReady: false) + let viewModel = makeWiredViewModel(context: context, manager: manager) + var message = "" + viewModel.onShowMessage = { message = $0 } + + viewModel.start() + XCTAssertNil(viewModel.makeCameraImportViewModel()) + + XCTAssertEqual(message, "相机照片目录尚未就绪,请稍候再试") + } + + func testHistoryImportCreatesImportViewModelWhenCatalogReady() { + let context = makeOTGTestContext() + let jpeg = CameraObject(id: "JPG|10", filename: "IMG_0001.JPG", fileSize: 10, capturedAt: Date()) + let driver = MockCameraDriver(objects: [jpeg]) + let manager = MockWiredCameraConnectionManager(driver: driver) + let viewModel = makeWiredViewModel(context: context, manager: manager) + + viewModel.start() + + XCTAssertNotNil(viewModel.makeCameraImportViewModel()) + XCTAssertEqual(driver.listCallCount, 0) + } + + func testHistoryImportDoesNotDownloadFromTransferPage() async { + let context = makeOTGTestContext() + let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()) + let driver = MockCameraDriver(objects: [object]) + let manager = MockWiredCameraConnectionManager(driver: driver) + let viewModel = makeWiredViewModel(context: context, manager: manager) + + viewModel.start() + _ = viewModel.makeCameraImportViewModel() + try? await Task.sleep(nanoseconds: 80_000_000) + + XCTAssertEqual(driver.downloadedObjectIds, []) + XCTAssertTrue(viewModel.filteredPhotos().isEmpty) } } +/// 旅拍相册相机历史导入 ViewModel 测试。 @MainActor +final class TravelAlbumCameraImportViewModelTests: XCTestCase { + func testLoadPhotosListsCameraObjectsAndGroupsByDay() async { + let context = makeOTGTestContext() + let calendar = Calendar(identifier: .gregorian) + let first = calendar.date(from: DateComponents(year: 2026, month: 7, day: 8, hour: 10))! + let second = calendar.date(from: DateComponents(year: 2026, month: 7, day: 7, hour: 10))! + let driver = MockCameraDriver(objects: [ + CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: first), + CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: second), + ]) + let viewModel = makeImportViewModel(context: context, driver: driver) + + viewModel.loadPhotos() + await waitUntil { + if case .loaded(let sections) = viewModel.state { + return sections.count == 2 + } + return false + } + + XCTAssertEqual(driver.listCallCount, 1) + if case .loaded(let sections) = viewModel.state { + XCTAssertEqual(sections.first?.photos.first?.id, "A|10") + } else { + XCTFail("Expected loaded state") + } + } + + func testAlreadyImportedPhotoCannotBeSelected() async throws { + let context = makeOTGTestContext() + let object = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()) + let localFile = try context.store.uniqueOriginalFileURL(filename: "A.JPG", albumId: 9) + try Data([1]).write(to: localFile) + context.store.save([ + TravelAlbumOTGPhotoRecord( + id: "local-id", + sourceId: object.id, + fileName: "A.JPG", + localPath: localFile.path, + capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(object.capturedAt), + fileSizeBytes: 1, + status: .pending, + albumId: 9, + userId: "u1" + ) + ], albumId: 9) + let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: [object])) + + viewModel.loadPhotos() + await waitUntil { + if case .loaded = viewModel.state { return true } + return false + } + viewModel.togglePhoto(id: object.id) + + XCTAssertTrue(viewModel.isPhotoAlreadyInAlbum(id: object.id)) + XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty) + } + + func testSelectionAndSectionToggleStates() async { + let context = makeOTGTestContext() + let photos = [ + CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()), + CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date()), + ] + let viewModel = makeImportViewModel(context: context, driver: MockCameraDriver(objects: photos)) + + viewModel.loadPhotos() + await waitUntil { + if case .loaded = viewModel.state { return true } + return false + } + guard case .loaded(let sections) = viewModel.state, let section = sections.first else { + return XCTFail("Expected loaded state") + } + + viewModel.togglePhoto(id: photos[0].id) + XCTAssertTrue(viewModel.isSectionPartiallySelected(section)) + XCTAssertFalse(viewModel.isSectionFullySelected(section)) + + viewModel.toggleSection(section) + XCTAssertTrue(viewModel.isSectionFullySelected(section)) + + viewModel.toggleSection(section) + XCTAssertTrue(viewModel.selectedPhotoIds.isEmpty) + } + + func testImportSelectedDownloadsOnlySelectedAndWritesPendingRecords() async { + let context = makeOTGTestContext() + let selected = CameraObject(id: "A|10", filename: "A.JPG", fileSize: 10, capturedAt: Date()) + let unselected = CameraObject(id: "B|10", filename: "B.JPG", fileSize: 10, capturedAt: Date()) + let driver = MockCameraDriver(objects: [selected, unselected]) + let viewModel = makeImportViewModel(context: context, driver: driver) + + viewModel.loadPhotos() + await waitUntil { + if case .loaded = viewModel.state { return true } + return false + } + viewModel.togglePhoto(id: selected.id) + viewModel.importSelected() + await waitUntil { + if case .finished = viewModel.state { return true } + return false + } + + XCTAssertEqual(driver.downloadedObjectIds, [selected.id]) + let records = context.store.load(albumId: 9) + XCTAssertEqual(records.count, 1) + XCTAssertEqual(records.first?.sourceId, selected.id) + XCTAssertEqual(records.first?.status, .pending) + } + + func testImportSelectedContinuesAfterSingleFailure() async { + let context = makeOTGTestContext() + let failed = CameraObject(id: "F|10", filename: "F.JPG", fileSize: 10, capturedAt: Date()) + let success = CameraObject(id: "S|10", filename: "S.JPG", fileSize: 10, capturedAt: Date()) + let driver = MockCameraDriver(objects: [failed, success], failingObjectIds: [failed.id]) + let viewModel = makeImportViewModel(context: context, driver: driver) + + viewModel.loadPhotos() + await waitUntil { + if case .loaded(let sections) = viewModel.state { + sections.first?.photos.count == 2 + } else { + false + } + } + guard case .loaded(let sections) = viewModel.state, let section = sections.first else { + return XCTFail("Expected loaded state") + } + viewModel.toggleSection(section) + viewModel.importSelected() + await waitUntil { + if case .finished = viewModel.state { return true } + return false + } + + XCTAssertEqual(Set(driver.downloadedObjectIds), [failed.id, success.id]) + XCTAssertTrue(viewModel.failedPhotoIds.contains(failed.id)) + XCTAssertEqual(context.store.load(albumId: 9).first?.sourceId, success.id) + } + + func testSonyEmptyCatalogShowsMTPHelp() async { + let context = makeOTGTestContext() + let driver = MockCameraDriver(platform: .sony, objects: []) + let viewModel = makeImportViewModel(context: context, driver: driver) + + viewModel.loadPhotos() + await waitUntil { + if case .failed = viewModel.state { return true } + return false + } + + XCTAssertTrue(viewModel.shouldShowSonyMTPHelp) + } + + private func makeImportViewModel( + context: OTGTestContext, + driver: MockCameraDriver + ) -> TravelAlbumCameraImportViewModel { + prepareAppStoreForOTGTests() + return TravelAlbumCameraImportViewModel( + albumId: 9, + albumTitle: "相册", + driver: driver, + storage: context.store + ) + } +} + +/// OTG 测试存储上下文。 +private struct OTGTestContext { + let root: URL + let store: TravelAlbumOTGPhotoStore +} + +@MainActor +private func makeWiredViewModel( + context: OTGTestContext, + manager: MockWiredCameraConnectionManager, + uploader: MockTravelAlbumOTGUploader? = nil +) -> WiredCameraTransferViewModel { + prepareAppStoreForOTGTests() + return WiredCameraTransferViewModel( + albumId: 9, + albumTitle: "相册", + headerPhone: "13800138000", + connectionManager: manager, + storage: context.store, + uploader: uploader ?? MockTravelAlbumOTGUploader() + ) +} + +@MainActor +private func makeOTGTestContext() -> OTGTestContext { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + let store = TravelAlbumOTGPhotoStore( + context: TravelAlbumOTGStorageContext( + accountCachePrefix: "u1_photog", + userId: "u1", + scenicId: 11, + storeId: 22 + ), + applicationSupportDirectory: root.appendingPathComponent("ApplicationSupport", isDirectory: true), + cachesDirectory: root.appendingPathComponent("Caches", isDirectory: true) + ) + return OTGTestContext(root: root, store: store) +} + +@MainActor +private func prepareAppStoreForOTGTests() { + AppStore.shared.userId = "u1" + AppStore.shared.accountType = "photog" + AppStore.shared.currentScenicId = 11 + AppStore.shared.currentStoreId = 22 +} + +private func waitUntil( + timeout: TimeInterval = 1.0, + condition: @escaping @MainActor () -> Bool +) async { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await condition() { return } + try? await Task.sleep(nanoseconds: 20_000_000) + } +} + +/// 有线相机连接管理测试替身。 +@MainActor +private final class MockWiredCameraConnectionManager: WiredCameraConnectionManaging { + weak var delegate: ConnectionManagerDelegate? + var state: ConnectionState + var currentDriver: CameraDriver? + var isContentCatalogReady: Bool + + init( + state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"), + driver: CameraDriver?, + isContentCatalogReady: Bool = true + ) { + self.state = state + self.currentDriver = driver + self.isContentCatalogReady = isContentCatalogReady + } + + func configureLiveTransfer(albumID: Int?) {} + func start() {} + func unbindDelegate() {} + func disconnect() { + state = .disconnected + } +} + +/// 相机 Driver 测试替身。 +private final class MockCameraDriver: CameraDriver { + let platform: CameraPlatform + let deviceInfo: CameraDeviceInfo + private let objects: [CameraObject] + private let failingObjectIds: Set + private(set) var listCallCount = 0 + private(set) var downloadedObjectIds: [String] = [] + + init( + platform: CameraPlatform = .canon, + objects: [CameraObject], + failingObjectIds: Set = [] + ) { + self.platform = platform + self.deviceInfo = CameraDeviceInfo( + name: "Canon", + manufacturer: "Canon", + model: "Mock", + serialNumber: nil, + usbVendorID: 0x04A9, + usbProductID: 1 + ) + self.objects = objects + self.failingObjectIds = failingObjectIds + } + + func connect() async throws {} + func disconnect() {} + + func listObjects() async throws -> [CameraObject] { + listCallCount += 1 + return objects + } + + func requestThumbnailData(for object: CameraObject, maxPixelSize: Int) async -> Data? { + nil + } + + func downloadObject(_ object: CameraObject, to directory: URL) async throws -> URL { + downloadedObjectIds.append(object.id) + if failingObjectIds.contains(object.id) { + throw CameraError.connectionFailed("下载失败") + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let url = directory.appendingPathComponent(object.filename) + try Data([1, 2, 3]).write(to: url, options: .atomic) + return url + } +} + +/// OTG 上传测试替身。 +@MainActor +private final class MockTravelAlbumOTGUploader: TravelAlbumOTGUploading { + private(set) var uploadCallCount = 0 + + func upload( + record: TravelAlbumOTGPhotoRecord, + scenicId: Int, + progress: @escaping (Int) -> Void + ) async throws -> TravelAlbumMaterial { + uploadCallCount += 1 + return TravelAlbumMaterial(id: 1, fileName: record.fileName, fileUrl: "https://cdn.example.com/\(record.fileName)") + } +} + /// 旅拍相册 API 测试替身。 +@MainActor final class TravelAlbumMockAPI: TravelAlbumServing { struct MaterialRequest: Equatable { let userEquityTravelId: Int @@ -159,12 +538,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing { var createResponse = TravelAlbumCreateResponse(id: 0) var infoResponse = TravelAlbum() var materialListResponses: [TravelAlbumListResponse] = [] + var uploadMaterialResponse = TravelAlbumMaterial() var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var createError: Error? private(set) var availableOrdersCallCount = 0 private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var materialRequests: [MaterialRequest] = [] + private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = [] private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedMaterialIds: [Int] = [] @@ -207,6 +588,11 @@ final class TravelAlbumMockAPI: TravelAlbumServing { return materialListResponses.removeFirst() } + func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial { + uploadMaterialRequests.append(request) + return uploadMaterialResponse + } + func deleteAlbum(id: Int) async throws { deletedAlbumIds.append(id) }