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

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