feat: add travel album OTG import flow
This commit is contained in:
@ -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?
|
||||
}
|
||||
32
suixinkan/Features/TravelAlbum/OTG/Core/CameraDriver.swift
Normal file
32
suixinkan/Features/TravelAlbum/OTG/Core/CameraDriver.swift
Normal 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
|
||||
}
|
||||
29
suixinkan/Features/TravelAlbum/OTG/Core/CameraError.swift
Normal file
29
suixinkan/Features/TravelAlbum/OTG/Core/CameraError.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
39
suixinkan/Features/TravelAlbum/OTG/Core/CameraFactory.swift
Normal file
39
suixinkan/Features/TravelAlbum/OTG/Core/CameraFactory.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
20
suixinkan/Features/TravelAlbum/OTG/Core/CameraPlatform.swift
Normal file
20
suixinkan/Features/TravelAlbum/OTG/Core/CameraPlatform.swift
Normal 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
|
||||
}
|
||||
658
suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift
Normal file
658
suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift
Normal 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 设备发现、授权、Session、Driver 创建与边拍边传编排。
|
||||
/// 持有 `ICDeviceBrowser` 与缓存的 `ICCameraDevice`,View 层通过 Delegate 订阅状态。
|
||||
@MainActor
|
||||
final class ConnectionManager: NSObject, WiredCameraConnectionManaging {
|
||||
|
||||
static let shared = ConnectionManager()
|
||||
|
||||
weak var delegate: ConnectionManagerDelegate?
|
||||
|
||||
private(set) var state: ConnectionState = .idle {
|
||||
didSet {
|
||||
if oldValue != state {
|
||||
OTGLog.info(.connection, "state changed: \(oldValue) -> \(state)")
|
||||
}
|
||||
notify { self.delegate?.connectionManager(self, didUpdate: self.state) }
|
||||
}
|
||||
}
|
||||
|
||||
private(set) var currentDriver: CameraDriver?
|
||||
private(set) var isContentCatalogReady = false
|
||||
private var browser: ICDeviceBrowser?
|
||||
/// First `didAdd` caches the device; kept after session close until USB unplug (`didRemove`).
|
||||
private var cachedDevice: ICCameraDevice?
|
||||
private var isClosingSession = false
|
||||
private var pendingStartAfterClose = false
|
||||
private var searchRescanWorkItem: DispatchWorkItem?
|
||||
private var searchReplugHintWorkItem: DispatchWorkItem?
|
||||
private var sonyRemoteCapture: SonyRemoteCaptureService?
|
||||
private var canonRemoteCapture: CanonRemoteCaptureService?
|
||||
private var nikonLiveCapture: NikonCatalogLiveCaptureService?
|
||||
/// 边拍边传目标相册;由 OTGTransferViewModel 在 start 前设置。
|
||||
private var liveTransferAlbumID: Int?
|
||||
|
||||
private let searchRescanDelay: TimeInterval = 1.5
|
||||
/// 长时间搜不到设备时提示用户重新插拔
|
||||
private let searchReplugHintDelay: TimeInterval = 4.5
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// 设置边拍边传目标相册(索尼 / 佳能);传 nil 清除。
|
||||
func configureLiveTransfer(albumID: Int?) {
|
||||
liveTransferAlbumID = albumID
|
||||
OTGLog.debug(.connection, "live transfer album=\(albumID.map(String.init) ?? "nil")")
|
||||
}
|
||||
|
||||
/// Entry point when OTG UI appears: reconnect cached device or start discovery.
|
||||
func start() {
|
||||
OTGLog.info(.connection, "start requested, state=\(state)")
|
||||
|
||||
if case .connected = state {
|
||||
return
|
||||
}
|
||||
|
||||
if isClosingSession {
|
||||
OTGLog.info(.connection, "start deferred, session is closing")
|
||||
pendingStartAfterClose = true
|
||||
return
|
||||
}
|
||||
|
||||
if let cached = cachedDevice {
|
||||
reconnect(to: cached)
|
||||
return
|
||||
}
|
||||
|
||||
startSearching()
|
||||
}
|
||||
|
||||
func unbindDelegate() {
|
||||
delegate = nil
|
||||
cancelSearchTimers()
|
||||
}
|
||||
|
||||
/// 断开 Session 并释放 Driver;缓存设备保留至 USB 拔出。
|
||||
func disconnect() {
|
||||
guard let device = activeDevice else {
|
||||
state = .disconnected
|
||||
return
|
||||
}
|
||||
|
||||
guard !isClosingSession else {
|
||||
OTGLog.info(.connection, "disconnect ignored, session already closing")
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "disconnect requested")
|
||||
isClosingSession = true
|
||||
isContentCatalogReady = false
|
||||
cancelSearchTimers()
|
||||
sonyRemoteCapture?.stop()
|
||||
sonyRemoteCapture = nil
|
||||
canonRemoteCapture?.stop()
|
||||
canonRemoteCapture = nil
|
||||
nikonLiveCapture?.stop()
|
||||
nikonLiveCapture = nil
|
||||
liveTransferAlbumID = nil
|
||||
currentDriver?.disconnect()
|
||||
currentDriver = nil
|
||||
device.delegate = self
|
||||
device.requestCloseSession()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var activeDevice: ICCameraDevice? {
|
||||
cachedDevice
|
||||
}
|
||||
|
||||
private func notify(_ block: () -> Void) {
|
||||
block()
|
||||
}
|
||||
|
||||
private func startSearching() {
|
||||
OTGLog.info(.connection, "start searching for cameras")
|
||||
cancelSearchTimers()
|
||||
state = .searching
|
||||
|
||||
if browser == nil {
|
||||
launchBrowser()
|
||||
}
|
||||
|
||||
scheduleSearchMonitoring()
|
||||
}
|
||||
|
||||
private func launchBrowser() {
|
||||
let browser = ICDeviceBrowser()
|
||||
browser.delegate = self
|
||||
// iOS 15.2+:同时浏览 Camera 与 Local USB,提高 Sony/Canon 直连发现率
|
||||
if #available(iOS 15.2, *) {
|
||||
let mask = ICDeviceTypeMask(
|
||||
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
|
||||
) ?? .camera
|
||||
browser.browsedDeviceTypeMask = mask
|
||||
} else {
|
||||
browser.browsedDeviceTypeMask = .camera
|
||||
}
|
||||
browser.start()
|
||||
self.browser = browser
|
||||
OTGLog.debug(.connection, "browser launched")
|
||||
}
|
||||
|
||||
private func reconnect(to camera: ICCameraDevice) {
|
||||
guard !isClosingSession else {
|
||||
OTGLog.warning(.connection, "reconnect ignored, session is closing")
|
||||
return
|
||||
}
|
||||
|
||||
if case .connected = state {
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "reconnecting to cached device: \(camera.name ?? "nil")")
|
||||
cancelSearchTimers()
|
||||
cachedDevice = camera
|
||||
state = .connecting(deviceName: camera.name ?? "Unknown Camera")
|
||||
requestAuthorizations { [weak self] in
|
||||
self?.openSession(for: camera, context: "cached device")
|
||||
}
|
||||
}
|
||||
|
||||
/// 先申请 contents 再申请 control;PTP 传图必须获得 control 授权。
|
||||
private func requestAuthorizations(then completion: @escaping () -> Void) {
|
||||
if browser == nil {
|
||||
launchBrowser()
|
||||
}
|
||||
|
||||
guard let browser else {
|
||||
OTGLog.error(.connection, "authorization skipped, browser unavailable")
|
||||
completion()
|
||||
return
|
||||
}
|
||||
|
||||
browser.requestContentsAuthorization { [weak self] contentsStatus in
|
||||
guard let self else { return }
|
||||
|
||||
let contentsGranted = contentsStatus == .authorized
|
||||
OTGLog.info(.connection, "contents authorization: \(contentsStatus.rawValue)")
|
||||
|
||||
guard contentsGranted else {
|
||||
DispatchQueue.main.async {
|
||||
OTGLog.error(.connection, "camera contents authorization denied")
|
||||
self.state = .failed(message: "未获得相机内容访问权限")
|
||||
self.delegate?.connectionManager(
|
||||
self,
|
||||
didFail: CameraError.connectionFailed("未获得相机内容访问权限")
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
browser.requestControlAuthorization { [weak self] controlStatus in
|
||||
guard let self else { return }
|
||||
|
||||
let controlGranted = controlStatus == .authorized
|
||||
OTGLog.info(.connection, "control authorization: \(controlStatus.rawValue)")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
guard controlGranted else {
|
||||
OTGLog.error(.connection, "camera control authorization denied, PTP requires control access")
|
||||
self.state = .failed(message: "未获得相机控制权限,无法使用 PTP 传图")
|
||||
self.delegate?.connectionManager(
|
||||
self,
|
||||
didFail: CameraError.connectionFailed(
|
||||
"请在系统弹窗中允许控制外接相机(PTP 传图需要此权限)"
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openSession(for camera: ICCameraDevice, context: String) {
|
||||
camera.delegate = self
|
||||
camera.requestOpenSession()
|
||||
OTGLog.debug(.connection, "requestOpenSession sent (\(context))")
|
||||
}
|
||||
|
||||
private func scheduleSearchMonitoring() {
|
||||
cancelSearchTimers()
|
||||
|
||||
let rescan = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
|
||||
self.rescanBrowser()
|
||||
}
|
||||
|
||||
let replugHint = DispatchWorkItem { [weak self] in
|
||||
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
|
||||
OTGLog.warning(.connection, "search timed out, suggesting replug")
|
||||
self.notify { self.delegate?.connectionManagerDidSuggestReplug(self) }
|
||||
self.scheduleSearchMonitoring()
|
||||
}
|
||||
|
||||
searchRescanWorkItem = rescan
|
||||
searchReplugHintWorkItem = replugHint
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + searchRescanDelay, execute: rescan)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + searchReplugHintDelay, execute: replugHint)
|
||||
}
|
||||
|
||||
private func rescanBrowser() {
|
||||
OTGLog.info(.connection, "rescanning: restarting browser")
|
||||
browser?.stop()
|
||||
browser?.delegate = nil
|
||||
browser = nil
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
guard let self, self.state == .searching, self.cachedDevice == nil else { return }
|
||||
self.launchBrowser()
|
||||
self.scheduleSearchMonitoring()
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelSearchTimers() {
|
||||
searchRescanWorkItem?.cancel()
|
||||
searchRescanWorkItem = nil
|
||||
searchReplugHintWorkItem?.cancel()
|
||||
searchReplugHintWorkItem = nil
|
||||
}
|
||||
|
||||
private func clearDeviceCache() {
|
||||
sonyRemoteCapture?.stop()
|
||||
sonyRemoteCapture = nil
|
||||
canonRemoteCapture?.stop()
|
||||
canonRemoteCapture = nil
|
||||
nikonLiveCapture?.stop()
|
||||
nikonLiveCapture = nil
|
||||
cachedDevice = nil
|
||||
currentDriver?.disconnect()
|
||||
currentDriver = nil
|
||||
isContentCatalogReady = false
|
||||
isClosingSession = false
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectionManager: ICDeviceBrowserDelegate {
|
||||
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
||||
OTGLog.info(.connection, "device added: name=\(device.name ?? "nil"), type=\(device.type.rawValue), moreComing=\(moreComing)")
|
||||
guard let camera = device as? ICCameraDevice else {
|
||||
OTGLog.warning(.connection, "ignored non-camera device: \(device.name ?? "nil")")
|
||||
return
|
||||
}
|
||||
handleDiscoveredCamera(camera)
|
||||
}
|
||||
|
||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
||||
OTGLog.info(.connection, "device removed: name=\(device.name ?? "nil"), uuid=\(device.uuidString ?? "nil"), moreGoing=\(moreGoing)")
|
||||
guard device.uuidString == cachedDevice?.uuidString else { return }
|
||||
cancelSearchTimers()
|
||||
clearDeviceCache()
|
||||
state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
private extension ConnectionManager {
|
||||
|
||||
func handleDiscoveredCamera(_ camera: ICCameraDevice) {
|
||||
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "discovered")
|
||||
|
||||
guard !isClosingSession else {
|
||||
OTGLog.warning(.connection, "camera ignored while session is closing")
|
||||
return
|
||||
}
|
||||
|
||||
if let cached = cachedDevice, cached.uuidString == camera.uuidString {
|
||||
if case .connected = state { return }
|
||||
reconnect(to: cached)
|
||||
return
|
||||
}
|
||||
|
||||
guard cachedDevice == nil else {
|
||||
OTGLog.warning(.connection, "camera ignored, already have cached device: \(cachedDevice?.name ?? "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "discovered camera: name=\(camera.name ?? "nil"), uuid=\(camera.uuidString ?? "nil")")
|
||||
cancelSearchTimers()
|
||||
cachedDevice = camera
|
||||
state = .connecting(deviceName: camera.name ?? "Unknown Camera")
|
||||
requestAuthorizations { [weak self] in
|
||||
self?.openSession(for: camera, context: "discovered")
|
||||
}
|
||||
}
|
||||
|
||||
func buildDeviceInfo(from camera: ICCameraDevice) -> CameraDeviceInfo {
|
||||
let vendorID = Int(camera.usbVendorID)
|
||||
let productID = Int(camera.usbProductID)
|
||||
let info = CameraDeviceInfo(
|
||||
name: camera.name ?? "Unknown Camera",
|
||||
manufacturer: camera.productKind,
|
||||
model: camera.name,
|
||||
serialNumber: camera.uuidString,
|
||||
usbVendorID: vendorID != 0 ? vendorID : nil,
|
||||
usbProductID: productID != 0 ? productID : nil
|
||||
)
|
||||
OTGLog.debug(
|
||||
.connection,
|
||||
"device info: \(info.name), manufacturer=\(info.manufacturer ?? "nil"), usbVendorID=\(info.usbVendorID.map { String(format: "0x%04X", $0) } ?? "nil"), serial=\(info.serialNumber ?? "nil")"
|
||||
)
|
||||
return info
|
||||
}
|
||||
|
||||
/// 检测品牌、创建 Driver 并进入 connected 状态。
|
||||
func createDriver(for camera: ICCameraDevice) {
|
||||
let info = buildDeviceInfo(from: camera)
|
||||
let platform = PlatformDetector.detect(from: info)
|
||||
|
||||
guard platform != .unknown else {
|
||||
OTGLog.error(.connection, "unsupported platform for device: \(info.name)")
|
||||
state = .failed(message: "不支持的相机品牌")
|
||||
delegate?.connectionManager(self, didFail: CameraError.unsupportedPlatform)
|
||||
return
|
||||
}
|
||||
|
||||
guard let driver = CameraFactory.makeDriver(
|
||||
platform: platform,
|
||||
device: camera,
|
||||
deviceInfo: info
|
||||
) else {
|
||||
OTGLog.error(.connection, "failed to create driver for platform: \(platform.rawValue)")
|
||||
state = .failed(message: "无法创建相机驱动")
|
||||
return
|
||||
}
|
||||
|
||||
currentDriver = driver
|
||||
state = .connected(platform: platform, deviceName: info.name)
|
||||
OTGLog.info(.connection, "driver ready: \(platform.rawValue), device=\(info.name)")
|
||||
delegate?.connectionManager(self, didCreate: driver)
|
||||
}
|
||||
|
||||
/// Session 打开后按品牌配置 PTP:索尼 SDIO + 边拍边传;佳能仅 Probe(远程模式等 catalog 就绪后启动)。
|
||||
private func configurePTPAfterSession(_ camera: ICCameraDevice) {
|
||||
let platform = PlatformDetector.detect(from: buildDeviceInfo(from: camera))
|
||||
|
||||
Task {
|
||||
// 等待 Session 稳定后再发 PTP,避免首包超时
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
|
||||
switch platform {
|
||||
case .canon:
|
||||
await runGetDeviceInfoProbe(on: camera)
|
||||
|
||||
case .sony:
|
||||
let transactionID: UInt32 = 1
|
||||
let result = await SonyPTPHelper.initializeRemoteSession(
|
||||
on: camera,
|
||||
startingTransactionID: transactionID
|
||||
)
|
||||
if result.success {
|
||||
let service = SonyRemoteCaptureService()
|
||||
service.onShotSaved = makeLiveShotSavedHandler()
|
||||
sonyRemoteCapture = service
|
||||
service.start(
|
||||
camera: camera,
|
||||
startingTransactionID: result.nextTransactionID,
|
||||
albumID: liveTransferAlbumID
|
||||
)
|
||||
OTGLog.info(.sony, "Sony remote session ready")
|
||||
} else {
|
||||
OTGLog.error(.sony, "Sony remote session setup failed")
|
||||
}
|
||||
|
||||
default:
|
||||
await runGetDeviceInfoProbe(on: camera)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runGetDeviceInfoProbe(on camera: ICCameraDevice) async {
|
||||
OTGLog.info(.ptp, "PTP probe starting...")
|
||||
|
||||
var transactionID: UInt32 = 1
|
||||
let result = await PTPHelper.sendCommand(
|
||||
on: camera,
|
||||
code: PTPStandardCommand.getDeviceInfo,
|
||||
transactionID: &transactionID,
|
||||
label: "GetDeviceInfo(probe)"
|
||||
)
|
||||
|
||||
if result.success {
|
||||
OTGLog.info(.ptp, "PTP probe passed, deviceInfo=\(result.inData.count) bytes")
|
||||
} else {
|
||||
OTGLog.error(.ptp, "PTP probe failed")
|
||||
}
|
||||
}
|
||||
|
||||
/// catalog 就绪后再初始化 Canon 远程模式,避免 SetRemoteMode 打断 SD 卡枚举。
|
||||
private func startCanonLiveTransferIfNeeded(for camera: ICCameraDevice) {
|
||||
guard canonRemoteCapture == nil else { return }
|
||||
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .canon else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
|
||||
let transactionID: UInt32 = 1
|
||||
let result = await CanonPTPHelper.initializeRemoteSession(
|
||||
on: camera,
|
||||
startingTransactionID: transactionID
|
||||
)
|
||||
guard result.success else {
|
||||
OTGLog.error(.canon, "Canon remote session setup failed")
|
||||
return
|
||||
}
|
||||
|
||||
let service = CanonRemoteCaptureService()
|
||||
service.onShotSaved = makeLiveShotSavedHandler()
|
||||
canonRemoteCapture = service
|
||||
service.start(
|
||||
camera: camera,
|
||||
startingTransactionID: result.nextTransactionID,
|
||||
albumID: liveTransferAlbumID
|
||||
)
|
||||
OTGLog.info(.canon, "Canon remote capture ready")
|
||||
}
|
||||
}
|
||||
|
||||
/// catalog 就绪后启动尼康边拍边传(didAdd + catalog 轮询,无需 PTP 远程会话)。
|
||||
private func startNikonLiveTransferIfNeeded(for camera: ICCameraDevice) {
|
||||
guard nikonLiveCapture == nil else { return }
|
||||
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .nikon else { return }
|
||||
|
||||
let service = NikonCatalogLiveCaptureService()
|
||||
service.onShotSaved = makeLiveShotSavedHandler()
|
||||
nikonLiveCapture = service
|
||||
service.start(camera: camera, albumID: liveTransferAlbumID)
|
||||
OTGLog.info(.nikon, "Nikon catalog live capture ready")
|
||||
}
|
||||
|
||||
private func makeLiveShotSavedHandler() -> (String) -> Void {
|
||||
{ [weak self] filename in
|
||||
guard let self, let albumID = self.liveTransferAlbumID else { return }
|
||||
self.notify {
|
||||
self.delegate?.connectionManager(
|
||||
self,
|
||||
didSaveLiveShot: filename,
|
||||
albumID: albumID
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ConnectionManager: ICCameraDeviceDelegate {
|
||||
|
||||
func device(_ device: ICDevice, didOpenSessionWithError error: (any Error)?) {
|
||||
guard device.uuidString == cachedDevice?.uuidString else { return }
|
||||
|
||||
if let error {
|
||||
OTGLog.error(.connection, "open session failed: \(error.localizedDescription)")
|
||||
state = .failed(message: error.localizedDescription)
|
||||
delegate?.connectionManager(self, didFail: error)
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "session opened: \(device.name ?? "nil")")
|
||||
guard let camera = device as? ICCameraDevice else { return }
|
||||
cachedDevice = camera
|
||||
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "session-opened")
|
||||
configurePTPAfterSession(camera)
|
||||
createDriver(for: camera)
|
||||
}
|
||||
|
||||
func device(_ device: ICDevice, didCloseSessionWithError error: (any Error)?) {
|
||||
guard device.uuidString == cachedDevice?.uuidString else { return }
|
||||
|
||||
if let error {
|
||||
OTGLog.warning(.connection, "session closed with error: \(error.localizedDescription)")
|
||||
} else {
|
||||
OTGLog.info(.connection, "session closed: \(device.name ?? "nil")")
|
||||
}
|
||||
|
||||
isClosingSession = false
|
||||
sonyRemoteCapture?.stop()
|
||||
sonyRemoteCapture = nil
|
||||
canonRemoteCapture?.stop()
|
||||
canonRemoteCapture = nil
|
||||
nikonLiveCapture?.stop()
|
||||
nikonLiveCapture = nil
|
||||
currentDriver = nil
|
||||
isContentCatalogReady = false
|
||||
if let camera = device as? ICCameraDevice {
|
||||
cachedDevice = camera
|
||||
}
|
||||
state = .disconnected
|
||||
|
||||
if pendingStartAfterClose {
|
||||
pendingStartAfterClose = false
|
||||
OTGLog.info(.connection, "session close finished, resuming start")
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
|
||||
OTGLog.debug(.connection, "camera items added: \(items.count)")
|
||||
canonRemoteCapture?.handleCatalogItemsAdded(items)
|
||||
nikonLiveCapture?.handleCatalogItemsAdded(items)
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
|
||||
OTGLog.debug(.connection, "camera items removed: \(items.count)")
|
||||
}
|
||||
|
||||
func cameraDevice(
|
||||
_ camera: ICCameraDevice,
|
||||
didReceiveThumbnail thumbnail: CGImage?,
|
||||
for item: ICCameraItem,
|
||||
error: (any Error)?
|
||||
) {
|
||||
if let error {
|
||||
OTGLog.warning(.connection, "thumbnail failed for \(item.name ?? "nil"): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDevice(
|
||||
_ camera: ICCameraDevice,
|
||||
didReceiveMetadata metadata: [AnyHashable: Any]?,
|
||||
for item: ICCameraItem,
|
||||
error: (any Error)?
|
||||
) {
|
||||
if let error {
|
||||
OTGLog.warning(.connection, "metadata failed for \(item.name ?? "nil"): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) {
|
||||
OTGLog.debug(.connection, "camera items renamed: \(items.count)")
|
||||
}
|
||||
|
||||
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
|
||||
OTGLog.debug(.connection, "camera capability changed: \(camera.name ?? "nil")")
|
||||
}
|
||||
|
||||
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent event: Data) {
|
||||
guard cachedDevice?.uuidString == camera.uuidString else { return }
|
||||
sonyRemoteCapture?.handlePTPEvent(event)
|
||||
canonRemoteCapture?.handlePTPEvent(event)
|
||||
}
|
||||
|
||||
/// ImageCaptureCore 内容目录就绪后可安全调用 `listObjects`(MTP 历史导入)。
|
||||
func deviceDidBecomeReady(withCompleteContentCatalog camera: ICCameraDevice) {
|
||||
guard camera.uuidString == cachedDevice?.uuidString else {
|
||||
OTGLog.warning(.connection, "content catalog ready ignored, unknown camera: \(camera.name ?? "nil")")
|
||||
return
|
||||
}
|
||||
|
||||
OTGLog.info(.connection, "camera ready with content catalog: \(camera.name ?? "nil")")
|
||||
ICCameraDeviceDebugLogger.logNonEmptyProperties(of: camera, context: "content-catalog-ready")
|
||||
isContentCatalogReady = true
|
||||
|
||||
guard let driver = currentDriver else {
|
||||
OTGLog.warning(.connection, "content catalog ready but no driver available")
|
||||
return
|
||||
}
|
||||
|
||||
notify { self.delegate?.connectionManager(self, contentCatalogDidBecomeReady: driver) }
|
||||
startCanonLiveTransferIfNeeded(for: camera)
|
||||
startNikonLiveTransferIfNeeded(for: camera)
|
||||
}
|
||||
|
||||
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
|
||||
OTGLog.info(.connection, "access restriction removed: \(device.name ?? "nil")")
|
||||
}
|
||||
|
||||
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
|
||||
OTGLog.warning(.connection, "access restriction enabled: \(device.name ?? "nil")")
|
||||
}
|
||||
|
||||
func didRemove(_ device: ICDevice) {
|
||||
OTGLog.info(.connection, "device removed delegate callback: \(device.name ?? "nil")")
|
||||
}
|
||||
}
|
||||
@ -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 "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)]")
|
||||
}
|
||||
}
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
suixinkan/Features/TravelAlbum/OTG/Core/OTGLog.swift
Normal file
112
suixinkan/Features/TravelAlbum/OTG/Core/OTGLog.swift
Normal 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 {
|
||||
/// ConnectionManager、ICCamera 扫描与 Session
|
||||
case connection = "Connection"
|
||||
/// 通用 PTP 命令发送与解析
|
||||
case ptp = "PTP"
|
||||
/// PlatformDetector 品牌识别
|
||||
case detector = "Detector"
|
||||
/// CameraFactory Driver 创建
|
||||
case factory = "Factory"
|
||||
/// NikonCameraDriver
|
||||
case nikon = "Nikon"
|
||||
/// CanonCameraDriver
|
||||
case canon = "Canon"
|
||||
/// Sony Driver、SDIO 与边拍边传
|
||||
case sony = "Sony"
|
||||
}
|
||||
|
||||
private static let osLog = Logger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "otg_swift",
|
||||
category: "OTG"
|
||||
)
|
||||
|
||||
static func debug(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.debug, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
static func info(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.info, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
static func warning(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.warning, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
static func error(
|
||||
_ category: Category,
|
||||
_ message: @autoclosure () -> String,
|
||||
file: String = #file,
|
||||
line: Int = #line
|
||||
) {
|
||||
log(.error, category: category, message: message(), file: file, line: line)
|
||||
}
|
||||
|
||||
private static func log(
|
||||
_ level: Level,
|
||||
category: Category,
|
||||
message: String,
|
||||
file: String,
|
||||
line: Int
|
||||
) {
|
||||
let fileName = (file as NSString).lastPathComponent
|
||||
let formatted = "\(filterPrefix)[\(level.rawValue)][\(category.rawValue)] \(message) (\(fileName):\(line))"
|
||||
|
||||
#if DEBUG
|
||||
// DEBUG 仅用 print,避免与 osLog 在 Xcode Console 重复输出
|
||||
print(formatted)
|
||||
#else
|
||||
switch level {
|
||||
case .debug:
|
||||
osLog.debug("\(formatted)")
|
||||
case .info:
|
||||
osLog.info("\(formatted)")
|
||||
case .warning:
|
||||
osLog.warning("\(formatted)")
|
||||
case .error:
|
||||
osLog.error("\(formatted)")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
111
suixinkan/Features/TravelAlbum/OTG/Core/PlatformDetector.swift
Normal file
111
suixinkan/Features/TravelAlbum/OTG/Core/PlatformDetector.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user