feat: add travel album OTG import flow

This commit is contained in:
2026-07-08 09:24:51 +08:00
parent 00bda390e8
commit 92fcad7ac9
42 changed files with 6826 additions and 89 deletions

View File

@ -59,15 +59,11 @@
}; };
9A01B8E22FFB84950033C402 /* Vendor */ = { 9A01B8E22FFB84950033C402 /* Vendor */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Vendor; path = Vendor;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
A1B2C3D42FFB84950033C305 /* suixinkanTests */ = { A1B2C3D42FFB84950033C305 /* suixinkanTests */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = suixinkanTests; path = suixinkanTests;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -334,10 +330,14 @@
inputFileListPaths = ( inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-input-files.xcfilelist",
); );
inputPaths = (
);
name = "[CP] Copy Pods Resources"; name = "[CP] Copy Pods Resources";
outputFileListPaths = ( outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist", "${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources-${CONFIGURATION}-output-files.xcfilelist",
); );
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n";

View File

@ -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( private func uploadFile(
data: Data, data: Data,
fileName: String, fileName: String,
@ -162,6 +179,8 @@ enum OSSUploadPolicy {
return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "bank_card/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "task_upload": case "task_upload":
return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "task/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "album":
return "album/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
case "scenic_apply": case "scenic_apply":
return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)" return "scenic_apply/\(currentDate)/\(scenicId)/\(uploadId)_\(safeName)"
default: default:

View File

@ -29,6 +29,9 @@ protocol TravelAlbumServing {
isPurchased: Int? isPurchased: Int?
) async throws -> TravelAlbumListResponse<TravelAlbumMaterial> ) async throws -> TravelAlbumListResponse<TravelAlbumMaterial>
///
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial
/// ///
func deleteAlbum(id: Int) async throws 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 { func deleteAlbum(id: Int) async throws {
let _: EmptyPayload = try await client.send( let _: EmptyPayload = try await client.send(

View File

@ -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<Item: Decodable & Sendable & Equatable>: Decodable, Sendable, Equatable { struct TravelAlbumListResponse<Item: Decodable & Sendable & Equatable>: Decodable, Sendable, Equatable {
let total: Int let total: Int

View File

@ -0,0 +1,59 @@
//
// CanonCameraDriver.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// DriverImageCaptureCore 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
}
}

View File

@ -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
}

View File

@ -0,0 +1,369 @@
//
// CanonPTPHelper.swift
// otg_swift
//
import Foundation
@preconcurrency import ImageCaptureCore
/// EOS PTPGetEvent 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 handleObjectAdded
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
)
}
}

View File

@ -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<UInt32> = []
private var knownCatalogIDs: Set<String> = []
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<String>()
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
}
}
}

View File

@ -0,0 +1,58 @@
//
// NikonCameraDriver.swift
// otg_swift
//
// Created by hanqiu on 2026/7/2.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// DriverImageCaptureCore 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
}
}

View File

@ -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<String> = []
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<String>()
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
}
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -0,0 +1,422 @@
//
// SonyPTPHelper.swift
// otg_swift
//
// Created by hanqiu on 2026/7/3.
//
import Foundation
@preconcurrency import ImageCaptureCore
/// PC RemoteSDIO 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
}
/// D215Shooting_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..<enumCount {
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
}
guard offset + 1 < data.count else { return false }
enumCount = Int(PTPHelper.readUInt16LE(data, offset: offset))
offset += 2
for _ in 0..<enumCount {
guard skipVariableValue(dataType: dataType, data: data, offset: &offset) else { return false }
}
return true
default:
return true
}
}
}

View File

@ -0,0 +1,334 @@
//
// SonyRemoteCaptureService.swift
// otg_swift
//
import Foundation
@preconcurrency import ImageCaptureCore
/// Sony PC Remote Step 5+ GetObject Step 6
final class SonyRemoteCaptureService {
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 pollTimer: Timer?
private var propertyCheckTask: Task<Void, Never>?
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")
}
}

View File

@ -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?
}

View File

@ -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
}

View File

@ -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)"
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -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 SessionDriver
/// `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 controlPTP 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")")
}
}

View File

@ -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 "未知"
}
}
}

View File

@ -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)]")
}
}

View File

@ -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)"
}
}

View File

@ -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<CameraObject?>(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)
}
}

View File

@ -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
}()
}

View File

@ -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)
}
}
}
}

View File

@ -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 {
/// ConnectionManagerICCamera 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 DriverSDIO
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
}
}

View File

@ -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)
}
}

View File

@ -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<String> = ["jpg", "jpeg", "heif", "hif"]
private static let rawExtensions: Set<String> = [
"dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2",
"orf", "rw2", "raw", "raf", "srw", "pef", "ptx", "rwl",
]
private static let jpegFormatCodes: Set<Int> = [0x3801, 0x3808]
private static let rawFormatCodes: Set<Int> = [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<String>()
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
)
}
}

View File

@ -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 Containerlength = 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 CompressedSizeUInt32 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..<charCount {
let unit = readUInt16LE(data, offset: offset + index * 2)
// ZV-E10 charCount NUL
if unit != 0 {
codeUnits.append(unit)
}
}
offset += charCount * 2
guard !codeUnits.isEmpty else { return nil }
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
return (string, offset)
}
/// PTP
static func sanitizeFilename(_ name: String) -> 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)
}
}

View File

@ -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<String> {
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<String>, 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)
}
}

View File

@ -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 "本地文件不存在,无法上传"
}
}
}

View File

@ -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<String> = [] {
didSet { onSelectionUpdated?(selectedPhotoIds.count) }
}
private(set) var failedPhotoIds: Set<String> = []
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<String> = []
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)
}
}

View File

@ -5,32 +5,435 @@
import Foundation import Foundation
/// 线 ViewModel UI OTG /// 线 ViewModel ImageCaptureCore
@MainActor
final class WiredCameraTransferViewModel { final class WiredCameraTransferViewModel {
let albumId: Int let albumId: Int
let albumTitle: String let albumTitle: String
let headerPhone: String let headerPhone: String
let scenicSpotLabel: String? let scenicSpotLabel: String?
let cameraStatusText = "未连接 USB"
let actionButtonText = "等待连接" private(set) var connectionState: ConnectionState = .idle {
let deviceModelName = "设备存储" didSet { notifyStateChanged() }
let availableStorageText = "-- GB 可用" }
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
didSet { notifyStateChanged() }
}
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
didSet { notifyStateChanged() }
}
private(set) var selectedPhotoIds: Set<String> = [] {
didSet { notifyStateChanged() }
}
private(set) var selectUploadMode = false {
didSet { notifyStateChanged() }
}
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图" let retouchOption = "不修图"
let photoFormatOption = "JPG" private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
let transferModeOption = "边拍边传" private(set) var transferModeOption = "边拍边传"
let tabCounts = [0, 0, 0]
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> 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<String> = []
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.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
self.headerPhone = headerPhone self.headerPhone = headerPhone
self.scenicSpotLabel = scenicSpotLabel 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() { var cameraStatusText: String {
onShowMessage?("仅同步 UI暂未接入 OTG 传输") 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<String>, 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>) -> [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)
} }
} }

View File

@ -24,7 +24,7 @@
<string>weixinURLParamsAPI</string> <string>weixinURLParamsAPI</string>
</array> </array>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>需要访问相机以扫描订单核销二维码</string> <string>需要访问相机以扫描订单核销二维码,并连接有线相机进行旅拍相册传输</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡</string> <string>需要获取您的位置信息,用于位置上报、景区距离排序及多点位核销打卡</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>

View File

@ -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?()
}
}

View File

@ -166,7 +166,11 @@ final class TravelAlbumDetailViewController: BaseViewController {
Task { @MainActor in self?.showToast(message) } Task { @MainActor in self?.showToast(message) }
} }
viewModel.onDeletedAlbum = { [weak self] _ in 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)
}
} }
} }

View File

@ -3,10 +3,11 @@
// suixinkan // suixinkan
// //
import Kingfisher
import SnapKit import SnapKit
import UIKit import UIKit
/// 线 Android UI OTG /// 线 Android OTG ImageCaptureCore
final class WiredCameraTransferViewController: BaseViewController { final class WiredCameraTransferViewController: BaseViewController {
private let viewModel: WiredCameraTransferViewModel private let viewModel: WiredCameraTransferViewModel
@ -22,10 +23,16 @@ final class WiredCameraTransferViewController: BaseViewController {
private let refreshButton = UIButton(type: .system) private let refreshButton = UIButton(type: .system)
private let helpLabel = UILabel() private let helpLabel = UILabel()
private let chipsStack = UIStackView() 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 statsCard = UIStackView()
private let emptyLabel = UILabel() private let emptyLabel = UILabel()
private var collectionView: UICollectionView!
private var dataSource: UICollectionViewDiffableDataSource<Int, TravelAlbumOTGPhotoItem>!
private let bottomBar = UIView() private let bottomBar = UIView()
private let batchButton = UIButton(type: .system) private let batchButton = UIButton(type: .system)
private let historyImportButton = UIButton(type: .system)
private let specifyButton = UIButton(type: .system) private let specifyButton = UIButton(type: .system)
init(viewModel: WiredCameraTransferViewModel) { init(viewModel: WiredCameraTransferViewModel) {
@ -42,8 +49,15 @@ final class WiredCameraTransferViewController: BaseViewController {
navigationController?.setNavigationBarHidden(true, animated: false) navigationController?.setNavigationBarHidden(true, animated: false)
} }
override func viewDidLoad() {
super.viewDidLoad()
viewModel.start()
applyViewModel()
}
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
viewModel.stop()
navigationController?.setNavigationBarHidden(false, animated: animated) navigationController?.setNavigationBarHidden(false, animated: animated)
} }
@ -69,41 +83,50 @@ final class WiredCameraTransferViewController: BaseViewController {
storagePanel.layer.cornerRadius = 12 storagePanel.layer.cornerRadius = 12
storagePanel.layer.borderColor = AppColor.border.cgColor storagePanel.layer.borderColor = AppColor.border.cgColor
storagePanel.layer.borderWidth = 3 storagePanel.layer.borderWidth = 3
storageTitleLabel.text = viewModel.deviceModelName
storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium) storageTitleLabel.font = .systemFont(ofSize: 11, weight: .medium)
storageTitleLabel.textColor = AppColor.textPrimary storageTitleLabel.textColor = AppColor.textPrimary
storageTitleLabel.textAlignment = .center storageTitleLabel.textAlignment = .center
storageValueLabel.text = viewModel.availableStorageText
storageValueLabel.font = .systemFont(ofSize: 10) storageValueLabel.font = .systemFont(ofSize: 10)
storageValueLabel.textColor = AppColor.primary storageValueLabel.textColor = AppColor.primary
storageValueLabel.textAlignment = .center storageValueLabel.textAlignment = .center
statusLabel.text = viewModel.cameraStatusText
statusLabel.font = .systemFont(ofSize: 11, weight: .medium) statusLabel.font = .systemFont(ofSize: 11, weight: .medium)
statusLabel.textColor = AppColor.danger
statusLabel.backgroundColor = AppColor.danger.withAlphaComponent(0.10)
statusLabel.layer.cornerRadius = 4 statusLabel.layer.cornerRadius = 4
statusLabel.clipsToBounds = true statusLabel.clipsToBounds = true
statusLabel.textAlignment = .center statusLabel.textAlignment = .center
refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
refreshButton.setTitleColor(.white, for: .normal) refreshButton.setTitleColor(.white, for: .normal)
refreshButton.titleLabel?.font = .systemFont(ofSize: 12) refreshButton.titleLabel?.font = .systemFont(ofSize: 12)
refreshButton.backgroundColor = AppColor.danger refreshButton.backgroundColor = AppColor.primary
refreshButton.layer.cornerRadius = 8 refreshButton.layer.cornerRadius = 8
helpLabel.attributedText = helpText()
helpLabel.font = .systemFont(ofSize: 10) helpLabel.font = .systemFont(ofSize: 10)
helpLabel.numberOfLines = 2 helpLabel.numberOfLines = 3
helpLabel.isUserInteractionEnabled = true
chipsStack.axis = .horizontal chipsStack.axis = .horizontal
chipsStack.spacing = 6 chipsStack.spacing = 6
[viewModel.retouchOption, viewModel.photoFormatOption, viewModel.transferModeOption].forEach { [retouchButton, formatButton, modeButton].forEach {
chipsStack.addArrangedSubview(makeChip($0)) configureChipButton($0)
chipsStack.addArrangedSubview($0)
} }
statsCard.axis = .horizontal statsCard.axis = .horizontal
statsCard.distribution = .fillEqually 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<Int, TravelAlbumOTGPhotoItem>(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 = "暂无照片" emptyLabel.text = "暂无照片"
@ -113,6 +136,7 @@ final class WiredCameraTransferViewController: BaseViewController {
bottomBar.backgroundColor = .white bottomBar.backgroundColor = .white
configureBottomButton(batchButton, title: "批量上传", filled: true) configureBottomButton(batchButton, title: "批量上传", filled: true)
configureBottomButton(historyImportButton, title: "历史导入", filled: false)
configureBottomButton(specifyButton, title: "指定上传", filled: false) configureBottomButton(specifyButton, title: "指定上传", filled: false)
view.addSubview(headerView) view.addSubview(headerView)
@ -128,9 +152,11 @@ final class WiredCameraTransferViewController: BaseViewController {
statusCard.addSubview(helpLabel) statusCard.addSubview(helpLabel)
statusCard.addSubview(chipsStack) statusCard.addSubview(chipsStack)
statusCard.addSubview(statsCard) statusCard.addSubview(statsCard)
view.addSubview(collectionView)
view.addSubview(emptyLabel) view.addSubview(emptyLabel)
view.addSubview(bottomBar) view.addSubview(bottomBar)
bottomBar.addSubview(batchButton) bottomBar.addSubview(batchButton)
bottomBar.addSubview(historyImportButton)
bottomBar.addSubview(specifyButton) bottomBar.addSubview(specifyButton)
} }
@ -206,11 +232,20 @@ final class WiredCameraTransferViewController: BaseViewController {
make.height.equalTo(44) make.height.equalTo(44)
make.bottom.equalTo(view.safeAreaLayoutGuide).offset(-12) 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 specifyButton.snp.makeConstraints { make in
make.top.height.width.equalTo(batchButton) 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) 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 emptyLabel.snp.makeConstraints { make in
make.top.equalTo(statusCard.snp.bottom) make.top.equalTo(statusCard.snp.bottom)
make.leading.trailing.equalToSuperview() make.leading.trailing.equalToSuperview()
@ -219,62 +254,102 @@ final class WiredCameraTransferViewController: BaseViewController {
} }
override func bindActions() { override func bindActions() {
viewModel.onStateChange = { [weak self] in
Task { @MainActor in self?.applyViewModel() }
}
viewModel.onShowMessage = { [weak self] message in viewModel.onShowMessage = { [weak self] message in
Task { @MainActor in self?.showToast(message) } 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) backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside)
refreshButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside) refreshButton.addTarget(self, action: #selector(refreshTapped), for: .touchUpInside)
batchButton.addTarget(self, action: #selector(uiOnlyTapped), for: .touchUpInside) batchButton.addTarget(self, action: #selector(batchTapped), for: .touchUpInside)
specifyButton.addTarget(self, action: #selector(uiOnlyTapped), 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 { @MainActor
let label = UILabel() private func applyViewModel() {
label.text = text storageTitleLabel.text = viewModel.deviceModelName
label.font = .systemFont(ofSize: 11) storageValueLabel.text = viewModel.availableStorageText
label.textColor = AppColor.textTertiary statusLabel.text = viewModel.cameraStatusText
label.backgroundColor = AppColor.pageBackground let isFailed = viewModel.cameraStatusText.contains("失败") || viewModel.cameraStatusText.contains("未连接")
label.layer.cornerRadius = 8 statusLabel.textColor = isFailed ? AppColor.danger : AppColor.primary
label.layer.borderWidth = 1 statusLabel.backgroundColor = (isFailed ? AppColor.danger : AppColor.primary).withAlphaComponent(0.10)
label.layer.borderColor = AppColor.border.cgColor refreshButton.setTitle(viewModel.actionButtonText, for: .normal)
label.clipsToBounds = true
label.textAlignment = .center retouchButton.setTitle(viewModel.retouchOption, for: .normal)
return label formatButton.setTitle(viewModel.photoFormatOption.rawValue, for: .normal)
modeButton.setTitle(viewModel.transferModeOption, for: .normal)
helpLabel.attributedText = helpText(viewModel.sonyMTPHint)
rebuildStats()
var snapshot = NSDiffableDataSourceSnapshot<Int, TravelAlbumOTGPhotoItem>()
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 { private func rebuildStats() {
let container = UIView() statsCard.arrangedSubviews.forEach { view in
let countLabel = UILabel() statsCard.removeArrangedSubview(view)
let titleLabel = UILabel() view.removeFromSuperview()
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()
} }
titleLabel.snp.makeConstraints { make in for tab in TravelAlbumOTGTransferTab.allCases {
make.top.equalTo(countLabel.snp.bottom).offset(2) let selected = viewModel.selectedTab == tab
make.leading.trailing.equalToSuperview() 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) { 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( let text = NSMutableAttributedString(
string: "未连接,查看", string: sonyHint ?? "未连接,查看",
attributes: [.foregroundColor: AppColor.textSecondary] attributes: [.foregroundColor: AppColor.textSecondary]
) )
if sonyHint == nil {
text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary])) text.append(NSAttributedString(string: "《帮助文档》", attributes: [.foregroundColor: AppColor.primary]))
}
return text 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() { @objc private func backTapped() {
navigationController?.popViewController(animated: true) navigationController?.popViewController(animated: true)
} }
@objc private func uiOnlyTapped() { @objc private func refreshTapped() {
viewModel.showUIOnlyMessage() 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?()
} }
} }

View File

@ -73,6 +73,29 @@ final class TravelAlbumAPITests: XCTestCase {
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/travel-album/mp-code") 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 { private func envelopeJSON(_ dataJSON: String) -> Data {
""" """
{"code":100000,"msg":"success","data":\(dataJSON)} {"code":100000,"msg":"success","data":\(dataJSON)}

View File

@ -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"))
}
}

View File

@ -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
}
}

View File

@ -130,21 +130,400 @@ final class TravelAlbumDetailViewModelTests: XCTestCase {
} }
/// 线 ViewModel /// 线 ViewModel
@MainActor
final class WiredCameraTransferViewModelTests: XCTestCase { final class WiredCameraTransferViewModelTests: XCTestCase {
func testDefaultDisconnectedStateDoesNotDependOnBusinessServices() { func testDefaultDisconnectedStateAndOptions() {
let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000") let viewModel = WiredCameraTransferViewModel(albumId: 1, albumTitle: "相册", headerPhone: "13800138000")
var message = ""
viewModel.onShowMessage = { message = $0 }
XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB") XCTAssertEqual(viewModel.cameraStatusText, "未连接 USB")
XCTAssertEqual(viewModel.tabCounts, [0, 0, 0]) XCTAssertEqual(viewModel.tabCounts, [0, 0, 0])
viewModel.showUIOnlyMessage() XCTAssertEqual(viewModel.photoFormatOption, .jpg)
XCTAssertEqual(message, "仅同步 UI暂未接入 OTG 传输") 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 @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<String>
private(set) var listCallCount = 0
private(set) var downloadedObjectIds: [String] = []
init(
platform: CameraPlatform = .canon,
objects: [CameraObject],
failingObjectIds: Set<String> = []
) {
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 /// API
@MainActor
final class TravelAlbumMockAPI: TravelAlbumServing { final class TravelAlbumMockAPI: TravelAlbumServing {
struct MaterialRequest: Equatable { struct MaterialRequest: Equatable {
let userEquityTravelId: Int let userEquityTravelId: Int
@ -159,12 +538,14 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
var createResponse = TravelAlbumCreateResponse(id: 0) var createResponse = TravelAlbumCreateResponse(id: 0)
var infoResponse = TravelAlbum() var infoResponse = TravelAlbum()
var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = [] var materialListResponses: [TravelAlbumListResponse<TravelAlbumMaterial>] = []
var uploadMaterialResponse = TravelAlbumMaterial()
var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "") var mpCodeResponse = TravelAlbumMpCodeResponse(mpCodeOssUrl: "")
var createError: Error? var createError: Error?
private(set) var availableOrdersCallCount = 0 private(set) var availableOrdersCallCount = 0
private(set) var createRequests: [TravelAlbumCreateRequest] = [] private(set) var createRequests: [TravelAlbumCreateRequest] = []
private(set) var materialRequests: [MaterialRequest] = [] private(set) var materialRequests: [MaterialRequest] = []
private(set) var uploadMaterialRequests: [TravelAlbumUploadMaterialRequest] = []
private(set) var deletedAlbumIds: [Int] = [] private(set) var deletedAlbumIds: [Int] = []
private(set) var deletedMaterialIds: [Int] = [] private(set) var deletedMaterialIds: [Int] = []
@ -207,6 +588,11 @@ final class TravelAlbumMockAPI: TravelAlbumServing {
return materialListResponses.removeFirst() return materialListResponses.removeFirst()
} }
func uploadMaterial(_ request: TravelAlbumUploadMaterialRequest) async throws -> TravelAlbumMaterial {
uploadMaterialRequests.append(request)
return uploadMaterialResponse
}
func deleteAlbum(id: Int) async throws { func deleteAlbum(id: Int) async throws {
deletedAlbumIds.append(id) deletedAlbumIds.append(id)
} }