移除 Core 层 OTG 相机实现,保留旅拍有线传图 UI 与本地照片上传能力。
将本地存储与上传编排下沉到 Features/TravelAlbum,删除 USB/PTP 相关代码与测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -64,15 +64,11 @@
|
|||||||
};
|
};
|
||||||
A00000022FE9000000000002 /* suixinkanTests */ = {
|
A00000022FE9000000000002 /* suixinkanTests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkanTests;
|
path = suixinkanTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
B00000022FEF000000000002 /* suixinkanUITests */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
|
||||||
);
|
|
||||||
path = suixinkanUITests;
|
path = suixinkanUITests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@ -328,10 +324,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";
|
||||||
|
|||||||
@ -87,12 +87,5 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {}
|
||||||
let semaphore = DispatchSemaphore(value: 0)
|
|
||||||
Task { @MainActor in
|
|
||||||
await CameraTetheringSession.shutdown()
|
|
||||||
semaphore.signal()
|
|
||||||
}
|
|
||||||
_ = semaphore.wait(timeout: .now() + 2)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,213 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// 自动识别 USB 相机品牌并委托给对应品牌服务。
|
|
||||||
@MainActor
|
|
||||||
final class AutoDetectCameraService: NSObject, CameraServiceProtocol {
|
|
||||||
private(set) var brand: CameraBrand = .sony
|
|
||||||
private(set) var connectionState: CameraConnectionState = .disconnected
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
|
|
||||||
private var activeService: (any CameraServiceProtocol)?
|
|
||||||
private var pendingCamera: ICCameraDevice?
|
|
||||||
private let usbBrowser = SharedUSBCameraBrowserHub.controller
|
|
||||||
private var isConnectingCamera = false
|
|
||||||
private var isDisconnecting = false
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
bindUSBBrowserCallbacks()
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect() async {
|
|
||||||
CameraTetheringLogger.log("AutoDetectCameraService.connect()")
|
|
||||||
if let activeService {
|
|
||||||
CameraTetheringLogger.log("AutoDetect: 已有活动服务,复用连接")
|
|
||||||
connectionState = activeService.connectionState
|
|
||||||
rebindActiveServiceCallbacks()
|
|
||||||
flushPendingAssetsIfNeeded(on: activeService)
|
|
||||||
onConnectionStateChange?(connectionState)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isConnectingCamera = false
|
|
||||||
connectionState = .searching
|
|
||||||
onConnectionStateChange?(.searching)
|
|
||||||
|
|
||||||
let started = await usbBrowser.start()
|
|
||||||
guard started else {
|
|
||||||
connectionState = .error("权限被拒绝")
|
|
||||||
onConnectionStateChange?(.error("权限被拒绝"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 页面离开:清空 UI 回调,保持 ICC/PTP 会话与 Browser。
|
|
||||||
func detachFromUI() {
|
|
||||||
CameraTetheringLogger.log("AutoDetectCameraService.detachFromUI()")
|
|
||||||
onConnectionStateChange = nil
|
|
||||||
onNewAsset = nil
|
|
||||||
activeService?.detachFromUI()
|
|
||||||
}
|
|
||||||
|
|
||||||
func shutdown() async {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
isDisconnecting = true
|
|
||||||
defer { isDisconnecting = false }
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("AutoDetectCameraService.shutdown()")
|
|
||||||
isConnectingCamera = false
|
|
||||||
|
|
||||||
if let activeService {
|
|
||||||
await activeService.shutdown()
|
|
||||||
}
|
|
||||||
activeService = nil
|
|
||||||
pendingCamera = nil
|
|
||||||
|
|
||||||
usbBrowser.stop()
|
|
||||||
USBCameraReconnectHint.markDisconnected()
|
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
connectionState = .disconnected
|
|
||||||
onConnectionStateChange?(.disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新搜索 USB 相机(未连接时可手动触发,或 App 回到前台时调用)。
|
|
||||||
func restartDiscovery() async {
|
|
||||||
CameraTetheringLogger.log("AutoDetectCameraService.restartDiscovery()")
|
|
||||||
guard activeService == nil else {
|
|
||||||
CameraTetheringLogger.log("AutoDetect: 已连接,跳过 restartDiscovery")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isConnectingCamera = false
|
|
||||||
connectionState = .searching
|
|
||||||
onConnectionStateChange?(.searching)
|
|
||||||
await usbBrowser.restart()
|
|
||||||
}
|
|
||||||
|
|
||||||
func listAssets() async throws -> [CameraAsset] {
|
|
||||||
guard let activeService else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
return try await activeService.listAssets()
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
guard let activeService else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
return try await activeService.downloadAsset(asset)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func bindUSBBrowserCallbacks() {
|
|
||||||
usbBrowser.onCameraDiscovered = { [weak self] camera, source in
|
|
||||||
Task { @MainActor in
|
|
||||||
await self?.handleCameraDiscovered(camera, source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usbBrowser.onCameraRemoved = { [weak self] device in
|
|
||||||
Task { @MainActor in
|
|
||||||
await self?.handleCameraRemoved(device)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usbBrowser.onAuthorizationDenied = { [weak self] in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self else { return }
|
|
||||||
self.connectionState = .error("权限被拒绝")
|
|
||||||
self.onConnectionStateChange?(.error("权限被拒绝"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usbBrowser.onScanTimeout = { [weak self] in
|
|
||||||
Task { @MainActor in
|
|
||||||
guard let self, self.activeService == nil, !self.isConnectingCamera else { return }
|
|
||||||
let message = "未发现 USB 相机。请确认:① 相机 USB 设为「照片传输/电脑遥控」② 存储卡内至少有 1 张照片 ③ 先连手机再连相机 ④ 进入页面后重新插拔 USB 线"
|
|
||||||
self.connectionState = .error(message)
|
|
||||||
self.onConnectionStateChange?(.error(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleCameraDiscovered(_ camera: ICCameraDevice, source: String) async {
|
|
||||||
guard activeService == nil, !isConnectingCamera, !isDisconnecting else {
|
|
||||||
CameraTetheringLogger.log("AutoDetect: 忽略重复发现 (\(source))")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let vendorID = Int(camera.usbVendorID)
|
|
||||||
guard let detectedBrand = CameraBrand.detect(from: camera) else {
|
|
||||||
let message = "不支持的相机品牌 (Vendor ID: 0x\(String(vendorID, radix: 16)))"
|
|
||||||
CameraTetheringLogger.log(message)
|
|
||||||
connectionState = .error(message)
|
|
||||||
onConnectionStateChange?(.error(message))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isConnectingCamera = true
|
|
||||||
await activateService(for: camera, brand: detectedBrand)
|
|
||||||
isConnectingCamera = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleCameraRemoved(_ device: ICDevice) async {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
guard activeService != nil else { return }
|
|
||||||
guard device.uuidString == pendingCamera?.uuidString else { return }
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("AutoDetect: 相机已物理移除 \(device.name ?? "?")")
|
|
||||||
pendingCamera = nil
|
|
||||||
if let activeService {
|
|
||||||
await activeService.shutdown()
|
|
||||||
}
|
|
||||||
activeService = nil
|
|
||||||
usbBrowser.resetNotifiedDevices()
|
|
||||||
connectionState = .disconnected
|
|
||||||
onConnectionStateChange?(.disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func activateService(for camera: ICCameraDevice, brand detectedBrand: CameraBrand) async {
|
|
||||||
brand = detectedBrand
|
|
||||||
let service = CameraServiceFactory.make(brand: detectedBrand)
|
|
||||||
|
|
||||||
activeService = service
|
|
||||||
pendingCamera = camera
|
|
||||||
rebindActiveServiceCallbacks()
|
|
||||||
|
|
||||||
connectionState = .connecting
|
|
||||||
onConnectionStateChange?(.connecting)
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("AutoDetect: 挂载 \(detectedBrand.displayName) 服务 (source attach)…")
|
|
||||||
switch detectedBrand {
|
|
||||||
case .sony:
|
|
||||||
if let sony = service as? SonyCameraService {
|
|
||||||
await sony.attach(to: camera)
|
|
||||||
}
|
|
||||||
case .canon:
|
|
||||||
if let canon = service as? CanonCameraService {
|
|
||||||
await canon.attach(to: camera)
|
|
||||||
}
|
|
||||||
case .nikon:
|
|
||||||
connectionState = .error("Nikon 相机尚未实现")
|
|
||||||
onConnectionStateChange?(.error("Nikon 相机尚未实现"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func rebindActiveServiceCallbacks() {
|
|
||||||
guard let activeService else { return }
|
|
||||||
activeService.onConnectionStateChange = { [weak self] state in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.connectionState = state
|
|
||||||
self?.onConnectionStateChange?(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeService.onNewAsset = { [weak self] asset in
|
|
||||||
self?.onNewAsset?(asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func flushPendingAssetsIfNeeded(on service: any CameraServiceProtocol) {
|
|
||||||
if let sony = service as? SonyCameraService {
|
|
||||||
sony.reattachToUI()
|
|
||||||
} else if let canon = service as? CanonCameraService {
|
|
||||||
canon.reattachToUI()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
# CameraTethering
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
|
|
||||||
提供 USB OTG 相机有线连接与 Sony/Canon PTP 边拍边传能力,不包含业务 API 或 OSS 上传。
|
|
||||||
|
|
||||||
## 核心组件
|
|
||||||
|
|
||||||
| 组件 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `CameraServiceProtocol` | 相机服务抽象,上层只依赖此协议 |
|
|
||||||
| `CameraTetheringSession` | 进程级单例,持有共享 `AutoDetectCameraService` |
|
|
||||||
| `AutoDetectCameraService` | 按 USB Vendor ID 自动识别品牌并委托 |
|
|
||||||
| `SonyCameraService` | Sony 相机实现,封装 SDIO/PTP 下载 |
|
|
||||||
| `CanonCameraService` | Canon EOS 相机实现,封装 GetEvent/PTP 下载 |
|
|
||||||
| `ImageCaptureDeviceManager` | Sony ICC 设备管理 |
|
|
||||||
| `CanonImageCaptureDeviceManager` | Canon ICC 设备管理 |
|
|
||||||
| `PTPHelper` | 共享 PTP 编解码与命令发送 |
|
|
||||||
| `CameraDownloadStorage` | 本地下载作用域、目录与文件名消毒 |
|
|
||||||
|
|
||||||
## 品牌识别
|
|
||||||
|
|
||||||
插入 USB 相机后,`AutoDetectCameraService` 读取 `ICCameraDevice.usbVendorID`:
|
|
||||||
|
|
||||||
- `0x054C` → Sony
|
|
||||||
- `0x04A9` → Canon
|
|
||||||
- 其他 → 提示不支持的相机品牌
|
|
||||||
|
|
||||||
## 业务流程
|
|
||||||
|
|
||||||
1. `AutoDetectCameraService.connect()` 启动 USB 设备搜索(或复用已有连接)。
|
|
||||||
2. 发现相机并完成系统授权后,按 Vendor ID 创建品牌 Service 并 attach。
|
|
||||||
3. 品牌 Service 打开 ICC 会话并执行品牌专有 PTP 握手(Sony SDIO / Canon SetRemoteMode)。
|
|
||||||
4. 拍摄事件触发 PTP 下载或 catalog 回退,底层先写入临时目录。
|
|
||||||
5. 通过 `onNewAsset` 回调通知上层有新照片。
|
|
||||||
|
|
||||||
## 相机端前置条件
|
|
||||||
|
|
||||||
- **Sony**:设为电脑遥控(PC Remote)模式
|
|
||||||
- **Canon**:设为电脑遥控 / PC Remote / 照片传输(PTP)模式
|
|
||||||
- 均需关闭「仅充电」模式
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
- ImageCaptureCore(系统框架)
|
|
||||||
- 无第三方库依赖
|
|
||||||
|
|
||||||
## 页面生命周期与资源释放
|
|
||||||
|
|
||||||
OTG 相机连接由 `CameraTetheringSession.sharedService` 进程级持有;`CameraServiceFactory.makeAutoDetect()` 始终返回该单例。
|
|
||||||
|
|
||||||
**进入 OTG 页:**
|
|
||||||
|
|
||||||
1. `.task` 调用 `WiredCameraTransferViewModel.start()`。
|
|
||||||
2. `pipeline.attachToUI(...)` 绑定 UI 回调。
|
|
||||||
3. `connect()` 启动或复用 USB 搜索;若已连接则跳过 attach,同步状态并补发 detach 期间缓冲的新片。
|
|
||||||
4. 发现相机后按 Vendor ID attach 品牌服务,打开 ICC 会话并执行 PTP 握手。
|
|
||||||
|
|
||||||
**离开 OTG 页(detach):**
|
|
||||||
|
|
||||||
1. `.task` 取消或 `onDisappear` 触发 `detachFromUI()`(幂等)。
|
|
||||||
2. 仅清空 `onNewAsset` / `onConnectionStateChange` 等 UI 回调,取消 Pipeline 上传/通知任务。
|
|
||||||
3. **不** PTP CloseSession、**不** stop Browser、**不** nil `activeService`。
|
|
||||||
4. 底层 D215 轮询与 PTP 下载可继续,新片写入 `pendingAssetsWhileDetached`,再次进入时 flush。
|
|
||||||
|
|
||||||
**App 即将终止(shutdown):**
|
|
||||||
|
|
||||||
1. `AppDelegate.applicationWillTerminate` 调用 `CameraTetheringSession.shutdown()`。
|
|
||||||
2. 品牌 Service 完整断开(Sony 先 `Position_Key_Setting=Local` 再 PTP CloseSession)。
|
|
||||||
3. `usbBrowser.stop()` 停止 USB Browser 并释放授权状态。
|
|
||||||
|
|
||||||
进入后台**不**主动 shutdown;系统挂起 USB 时由 `deviceBrowserDidResumeOperations` 处理恢复。
|
|
||||||
|
|
||||||
若返回后再进入无法连接,优先检查日志中是否出现「会话关闭超时」或在非 terminate 场景误调用了 `shutdown()`。
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// 进程级 OTG 相机会话:页面离开仅 detach UI 回调,App 终止时 shutdown 释放全部资源。
|
|
||||||
@MainActor
|
|
||||||
enum CameraTetheringSession {
|
|
||||||
static let sharedService = AutoDetectCameraService()
|
|
||||||
|
|
||||||
/// 页面离开:清空 UI 回调,保持 ICC/PTP 会话与 Browser。
|
|
||||||
static func detachFromUI() {
|
|
||||||
sharedService.detachFromUI()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// App 终止:完整释放相机连接与 USB Browser。
|
|
||||||
static func shutdown() async {
|
|
||||||
await sharedService.shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
# Canon CameraTethering
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
|
|
||||||
提供 Canon EOS 相机 USB OTG 有线连接与边拍边传(PC Remote)能力。
|
|
||||||
|
|
||||||
## 核心组件
|
|
||||||
|
|
||||||
| 组件 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `CanonCameraService` | 实现 `CameraServiceProtocol`,管理基线与去重 |
|
|
||||||
| `CanonImageCaptureDeviceManager` | ImageCaptureCore 会话、GetEvent 轮询、PTP/Catalog 下载 |
|
|
||||||
| `CanonPTPCommands` | Canon EOS PTP 厂商扩展命令 |
|
|
||||||
| `CanonEosEventParser` | GetEvent blob 解析(ObjectAddedEx / ObjectAddedEx2) |
|
|
||||||
|
|
||||||
## PTP 会话时序
|
|
||||||
|
|
||||||
1. ImageCaptureCore 打开 ICC 会话(不发 PTP OpenSession)。
|
|
||||||
2. `SetRemoteMode` (0x9114) — 进入 PC 遥控模式。
|
|
||||||
3. `SetEventMode` (0x9115) — 订阅扩展事件。
|
|
||||||
4. `KeepDeviceOn` (0x911D) — 重置自动关机(部分机身不支持,非致命)。
|
|
||||||
5. 每 500ms 轮询 `GetEvent` (0x9116)。
|
|
||||||
6. 收到 `ObjectAddedEx` / `ObjectAddedEx2` 后通过 `EOS_GetObject` (0x9104) 下载。
|
|
||||||
7. 发送 `TransferComplete` (0x9117) 释放相机缓冲。
|
|
||||||
|
|
||||||
## Catalog 回退
|
|
||||||
|
|
||||||
若 PTP 实时路径不可用,仍可通过 `deviceDidBecomeReady` + `didAddItems` + 1.5s 轮询检测 catalog 新文件,并使用 `requestDownloadFile` 下载。
|
|
||||||
|
|
||||||
## 相机端设置
|
|
||||||
|
|
||||||
- USB 模式设为 **电脑遥控 / PC Remote / 照片传输(PTP)**
|
|
||||||
- 关闭「仅充电」模式
|
|
||||||
- 边拍边传时勿调用 `SetUILock` (0x911B)
|
|
||||||
|
|
||||||
## 依赖
|
|
||||||
|
|
||||||
- ImageCaptureCore(系统框架)
|
|
||||||
- 共享 `PTPHelper` / `CameraDownloadStorage`
|
|
||||||
@ -1,181 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// Canon EOS 相机有线连接服务,实现边拍边传与目录回退下载。
|
|
||||||
@MainActor
|
|
||||||
final class CanonCameraService: CameraServiceProtocol {
|
|
||||||
let brand: CameraBrand = .canon
|
|
||||||
|
|
||||||
private(set) var connectionState: CameraConnectionState = .disconnected
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
|
|
||||||
private let deviceManager = CanonImageCaptureDeviceManager()
|
|
||||||
private var knownAssetIDs: Set<String> = []
|
|
||||||
private var baselineEstablished = false
|
|
||||||
private var pendingAssetsWhileDetached: [CameraAsset] = []
|
|
||||||
|
|
||||||
init() {
|
|
||||||
deviceManager.onConnectionStateChange = { [weak self] state in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.connectionState = state
|
|
||||||
self?.onConnectionStateChange?(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onNewItems = { [weak self] items in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleNewItems(items, source: "didAddItems")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onBaselineCatalogReady = { [weak self] in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.establishBaseline()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onPollTick = { [weak self] in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.scanForNewAssets(source: "poll")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onPTPAssetReady = { [weak self] asset, _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handlePTPAsset(asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onError = { [weak self] message in
|
|
||||||
Task { @MainActor in
|
|
||||||
CameraTetheringLogger.log("Canon 相机错误: \(message)")
|
|
||||||
self?.connectionState = .error(message)
|
|
||||||
self?.onConnectionStateChange?(.error(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect() async {
|
|
||||||
CameraTetheringLogger.log("CanonCameraService.connect()")
|
|
||||||
let shouldResetBaseline = !deviceManager.isBrowsing
|
|
||||||
deviceManager.startBrowsing()
|
|
||||||
if shouldResetBaseline {
|
|
||||||
knownAssetIDs.removeAll()
|
|
||||||
baselineEstablished = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 由 AutoDetect 在已完成授权后直接挂载设备。
|
|
||||||
func attach(to camera: ICCameraDevice) async {
|
|
||||||
CameraTetheringLogger.log("CanonCameraService.attach()")
|
|
||||||
knownAssetIDs.removeAll()
|
|
||||||
baselineEstablished = false
|
|
||||||
pendingAssetsWhileDetached.removeAll()
|
|
||||||
deviceManager.attach(to: camera)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 页面离开:仅清空 UI 回调,底层 PTP 会话与轮询继续。
|
|
||||||
func detachFromUI() {
|
|
||||||
CameraTetheringLogger.log("CanonCameraService.detachFromUI()")
|
|
||||||
onConnectionStateChange = nil
|
|
||||||
onNewAsset = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 再次进入页面:补发 detach 期间缓冲的新照片。
|
|
||||||
func reattachToUI() {
|
|
||||||
guard !pendingAssetsWhileDetached.isEmpty else { return }
|
|
||||||
let pending = pendingAssetsWhileDetached
|
|
||||||
pendingAssetsWhileDetached.removeAll()
|
|
||||||
CameraTetheringLogger.log("CanonCameraService.reattachToUI: 补发 \(pending.count) 张离线新片")
|
|
||||||
for asset in pending {
|
|
||||||
onNewAsset?(asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shutdown() async {
|
|
||||||
CameraTetheringLogger.log("CanonCameraService.shutdown()")
|
|
||||||
await deviceManager.stopBrowsing()
|
|
||||||
knownAssetIDs.removeAll()
|
|
||||||
baselineEstablished = false
|
|
||||||
pendingAssetsWhileDetached.removeAll()
|
|
||||||
connectionState = .disconnected
|
|
||||||
onConnectionStateChange?(.disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listAssets() async throws -> [CameraAsset] {
|
|
||||||
guard connectionState.isConnected else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
return deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
guard connectionState.isConnected else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
if let cachedURL = deviceManager.localURL(forAssetID: asset.id) {
|
|
||||||
return cachedURL
|
|
||||||
}
|
|
||||||
guard let file = deviceManager.file(forAssetID: asset.id) else {
|
|
||||||
throw CameraServiceError.assetNotFound
|
|
||||||
}
|
|
||||||
let directory = CameraDownloadStorage.temporaryDownloadsDirectory
|
|
||||||
return try await deviceManager.download(file: file, to: directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePTPAsset(_ asset: CameraAsset) {
|
|
||||||
deliverNewAsset(asset, source: "Canon PTP")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func establishBaseline() {
|
|
||||||
guard !baselineEstablished else { return }
|
|
||||||
baselineEstablished = true
|
|
||||||
|
|
||||||
let files = deviceManager.allFiles()
|
|
||||||
var skipped = 0
|
|
||||||
var newCount = 0
|
|
||||||
|
|
||||||
for file in files {
|
|
||||||
if file.wasAddedAfterContentCatalogCompleted {
|
|
||||||
emitAssetIfNew(file: file, source: "baseline-new")
|
|
||||||
newCount += 1
|
|
||||||
} else {
|
|
||||||
knownAssetIDs.insert(deviceManager.assetIdentifier(for: file))
|
|
||||||
skipped += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CameraTetheringLogger.log("Canon 基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount) 张")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleNewItems(_ items: [ICCameraItem], source: String) {
|
|
||||||
guard baselineEstablished else { return }
|
|
||||||
for item in items {
|
|
||||||
guard let file = item as? ICCameraFile else { continue }
|
|
||||||
emitAssetIfNew(file: file, source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scanForNewAssets(source: String) {
|
|
||||||
guard baselineEstablished else { return }
|
|
||||||
for file in deviceManager.allFiles() {
|
|
||||||
emitAssetIfNew(file: file, source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emitAssetIfNew(file: ICCameraFile, source: String) {
|
|
||||||
let asset = deviceManager.makeAsset(from: file)
|
|
||||||
deliverNewAsset(asset, source: source)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deliverNewAsset(_ asset: CameraAsset, source: String) {
|
|
||||||
guard !knownAssetIDs.contains(asset.id) else { return }
|
|
||||||
knownAssetIDs.insert(asset.id)
|
|
||||||
CameraTetheringLogger.log("[Canon \(source)] 发现新照片: \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
|
||||||
if let onNewAsset {
|
|
||||||
onNewAsset(asset)
|
|
||||||
} else {
|
|
||||||
pendingAssetsWhileDetached.append(asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Canon EOS GetEvent 返回的事件 blob 解析器。
|
|
||||||
enum CanonEosEventParser {
|
|
||||||
private static let recordHeaderSize = 8
|
|
||||||
private static let objectAddedExMinPayload = 33
|
|
||||||
private static let objectAddedEx2MinPayload = 41
|
|
||||||
|
|
||||||
/// 解析后的 Canon 事件。
|
|
||||||
enum ParsedEvent {
|
|
||||||
case objectAdded(ObjectAdded)
|
|
||||||
case requestObjectTransfer(handle: UInt32)
|
|
||||||
case cameraStatus(status: Int32)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ObjectAddedEx / ObjectAddedEx2 事件载荷。
|
|
||||||
struct ObjectAdded {
|
|
||||||
let objectHandle: UInt32
|
|
||||||
let storageId: UInt32
|
|
||||||
let formatCode: UInt16
|
|
||||||
let parentHandle: UInt32
|
|
||||||
let size: Int64
|
|
||||||
let filename: String
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析 GetEvent 返回的二进制 blob。
|
|
||||||
static func parse(_ blob: Data) -> [ParsedEvent] {
|
|
||||||
guard !blob.isEmpty else { return [] }
|
|
||||||
|
|
||||||
var events: [ParsedEvent] = []
|
|
||||||
var offset = 0
|
|
||||||
|
|
||||||
while offset + recordHeaderSize <= blob.count {
|
|
||||||
let recordStart = offset
|
|
||||||
let recordSizeRaw = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
|
||||||
offset += 4
|
|
||||||
if recordSizeRaw == 0 { break }
|
|
||||||
if recordSizeRaw < Int64(recordHeaderSize) { break }
|
|
||||||
|
|
||||||
let recordSize = Int(recordSizeRaw)
|
|
||||||
let type = Int(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF)
|
|
||||||
offset += 4
|
|
||||||
let payloadSize = recordSize - recordHeaderSize
|
|
||||||
|
|
||||||
if type == 0, payloadSize == 0 { break }
|
|
||||||
if offset + payloadSize > blob.count { break }
|
|
||||||
|
|
||||||
let payloadEnd = offset + payloadSize
|
|
||||||
|
|
||||||
switch UInt16(type) {
|
|
||||||
case CanonPTPCommand.eosObjectAddedEx:
|
|
||||||
if let added = parseObjectAdded(
|
|
||||||
blob,
|
|
||||||
offset: offset,
|
|
||||||
payloadSize: payloadSize,
|
|
||||||
filenameOffset: 32,
|
|
||||||
minPayload: objectAddedExMinPayload
|
|
||||||
) {
|
|
||||||
events.append(.objectAdded(added))
|
|
||||||
}
|
|
||||||
case CanonPTPCommand.eosObjectAddedEx2:
|
|
||||||
if let added = parseObjectAdded(
|
|
||||||
blob,
|
|
||||||
offset: offset,
|
|
||||||
payloadSize: payloadSize,
|
|
||||||
filenameOffset: 40,
|
|
||||||
minPayload: objectAddedEx2MinPayload
|
|
||||||
) {
|
|
||||||
events.append(.objectAdded(added))
|
|
||||||
}
|
|
||||||
case CanonPTPCommand.eosRequestObjectTransfer:
|
|
||||||
let handle: UInt32 = payloadSize >= 4 ? PTPHelper.readUInt32LE(blob, offset: offset) : 0
|
|
||||||
events.append(.requestObjectTransfer(handle: handle))
|
|
||||||
case CanonPTPCommand.eosCameraStatusChanged:
|
|
||||||
let status: Int32 = payloadSize >= 4
|
|
||||||
? Int32(bitPattern: PTPHelper.readUInt32LE(blob, offset: offset))
|
|
||||||
: 0
|
|
||||||
events.append(.cameraStatus(status: status))
|
|
||||||
case CanonPTPCommand.eosObjectInfoChangedEx,
|
|
||||||
CanonPTPCommand.eosStorageInfoChanged:
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
offset = payloadEnd
|
|
||||||
if offset == recordStart { break }
|
|
||||||
}
|
|
||||||
|
|
||||||
return events
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 将 ObjectAdded 事件转为 CameraAsset。
|
|
||||||
static func makeAsset(from event: ObjectAdded) -> CameraAsset {
|
|
||||||
let filename = event.filename.isEmpty
|
|
||||||
? "IMG_\(event.objectHandle).JPG"
|
|
||||||
: CameraDownloadStorage.sanitizeFilename(event.filename)
|
|
||||||
let isMovie = isVideoFormat(event.formatCode) || isVideoExtension(filename)
|
|
||||||
return CameraAsset(
|
|
||||||
id: CanonPTPHelper.assetID(handle: event.objectHandle, filename: filename, size: event.size),
|
|
||||||
filename: filename,
|
|
||||||
fileSize: event.size,
|
|
||||||
creationDate: Date(),
|
|
||||||
isMovie: isMovie
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseObjectAdded(
|
|
||||||
_ blob: Data,
|
|
||||||
offset payloadStart: Int,
|
|
||||||
payloadSize: Int,
|
|
||||||
filenameOffset: Int,
|
|
||||||
minPayload: Int
|
|
||||||
) -> ObjectAdded? {
|
|
||||||
guard payloadSize >= minPayload else { return nil }
|
|
||||||
|
|
||||||
var offset = payloadStart
|
|
||||||
let objectHandle = PTPHelper.readUInt32LE(blob, offset: offset)
|
|
||||||
offset += 4
|
|
||||||
let storageId = PTPHelper.readUInt32LE(blob, offset: offset)
|
|
||||||
offset += 4
|
|
||||||
let ofc = UInt16(PTPHelper.readUInt32LE(blob, offset: offset) & 0xFFFF)
|
|
||||||
offset += 4
|
|
||||||
|
|
||||||
let size: Int64
|
|
||||||
let parentHandle: UInt32
|
|
||||||
|
|
||||||
if filenameOffset == 32 {
|
|
||||||
offset += 4 // flags
|
|
||||||
parentHandle = PTPHelper.readUInt32LE(blob, offset: offset)
|
|
||||||
offset += 4
|
|
||||||
offset += 4 // reserved
|
|
||||||
size = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
|
||||||
offset += 4
|
|
||||||
offset += 4 // reserved
|
|
||||||
} else {
|
|
||||||
offset += 4 // reserved
|
|
||||||
parentHandle = PTPHelper.readUInt32LE(blob, offset: offset)
|
|
||||||
offset += 4
|
|
||||||
let sizeLow = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
|
||||||
offset += 4
|
|
||||||
let sizeHigh = Int64(PTPHelper.readUInt32LE(blob, offset: offset))
|
|
||||||
offset += 4
|
|
||||||
size = sizeLow | (sizeHigh << 32)
|
|
||||||
offset += 4 // reserved
|
|
||||||
offset += 4 // relatedHandle
|
|
||||||
}
|
|
||||||
|
|
||||||
let expected = payloadStart + filenameOffset
|
|
||||||
if offset != expected { offset = expected }
|
|
||||||
|
|
||||||
let filenameBytesAvailable = payloadSize - filenameOffset
|
|
||||||
guard filenameBytesAvailable > 0, offset + filenameBytesAvailable <= blob.count else { return nil }
|
|
||||||
|
|
||||||
let filenameData = blob.subdata(in: offset..<(offset + filenameBytesAvailable))
|
|
||||||
let filename = extractCString(filenameData)
|
|
||||||
|
|
||||||
return ObjectAdded(
|
|
||||||
objectHandle: objectHandle,
|
|
||||||
storageId: storageId,
|
|
||||||
formatCode: ofc,
|
|
||||||
parentHandle: parentHandle,
|
|
||||||
size: size,
|
|
||||||
filename: filename
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extractCString(_ bytes: Data) -> String {
|
|
||||||
if let nulIndex = bytes.firstIndex(of: 0) {
|
|
||||||
return String(bytes: bytes.prefix(upTo: nulIndex), encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? ""
|
|
||||||
}
|
|
||||||
return String(bytes: bytes, encoding: .ascii)?.trimmingCharacters(in: .whitespaces) ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isVideoFormat(_ ofc: UInt16) -> Bool {
|
|
||||||
switch ofc {
|
|
||||||
case 0x300D, 0x3000, 0xB982, 0xB983, 0x300F:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isVideoExtension(_ filename: String) -> Bool {
|
|
||||||
let lower = filename.lowercased()
|
|
||||||
return [".mov", ".mp4", ".m4v", ".avi", ".mpg", ".mpeg"].contains { lower.hasSuffix($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,597 +0,0 @@
|
|||||||
import CoreGraphics
|
|
||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// Canon 相机 ImageCaptureCore 设备管理:会话、GetEvent 轮询与 PTP/Catalog 双路径下载。
|
|
||||||
final class CanonImageCaptureDeviceManager: NSObject {
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewItems: (([ICCameraItem]) -> Void)?
|
|
||||||
var onBaselineCatalogReady: (() -> Void)?
|
|
||||||
var onPollTick: (() -> Void)?
|
|
||||||
var onPTPAssetReady: ((CameraAsset, URL) -> Void)?
|
|
||||||
var onError: ((String) -> Void)?
|
|
||||||
|
|
||||||
private(set) var cameraDevice: ICCameraDevice?
|
|
||||||
private(set) var isCatalogReady = false
|
|
||||||
private var deviceBrowser: ICDeviceBrowser?
|
|
||||||
private let downloadDelegate = CameraDownloadDelegate()
|
|
||||||
private var transactionID: UInt32 = 1
|
|
||||||
private var pendingDownloads: [String: CheckedContinuation<URL, Error>] = [:]
|
|
||||||
private var ptpAssetURLs: [String: URL] = [:]
|
|
||||||
private var pollTimer: Timer?
|
|
||||||
private var baselineNotified = false
|
|
||||||
private var isRemoteSessionReady = false
|
|
||||||
private var isExternalAttach = false
|
|
||||||
private var eventPollTask: Task<Void, Never>?
|
|
||||||
private var keepDeviceOnSupported = false
|
|
||||||
private var eventPollTick = 0
|
|
||||||
private var seenHandles: Set<UInt32> = []
|
|
||||||
private var isDownloadingPTPObject = false
|
|
||||||
private var sessionSetupTask: Task<Void, Never>?
|
|
||||||
private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
private var sessionCloseTimeoutTask: Task<Void, Never>?
|
|
||||||
private var closingDeviceUUID: String?
|
|
||||||
private var isDisconnecting = false
|
|
||||||
|
|
||||||
var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach }
|
|
||||||
|
|
||||||
/// 跳过 Browser,直接连接 AutoDetect 已发现并授权的设备。
|
|
||||||
func attach(to camera: ICCameraDevice) {
|
|
||||||
guard cameraDevice == nil else {
|
|
||||||
CameraTetheringLogger.log("Canon attach: 已有连接中的相机,忽略")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isExternalAttach = true
|
|
||||||
CameraTetheringLogger.log("Canon attach: \(camera.name ?? "Unknown")")
|
|
||||||
openSession(for: camera)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startBrowsing() {
|
|
||||||
guard deviceBrowser == nil else {
|
|
||||||
CameraTetheringLogger.log("Canon: 已在监听 USB 相机")
|
|
||||||
if cameraDevice == nil {
|
|
||||||
updateState(.searching)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("Canon: 开始搜索 USB 相机…")
|
|
||||||
updateState(.searching)
|
|
||||||
|
|
||||||
let browser = ICDeviceBrowser()
|
|
||||||
browser.delegate = self
|
|
||||||
if #available(iOS 15.2, *) {
|
|
||||||
let mask = ICDeviceTypeMask(
|
|
||||||
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
|
|
||||||
) ?? .camera
|
|
||||||
browser.browsedDeviceTypeMask = mask
|
|
||||||
}
|
|
||||||
deviceBrowser = browser
|
|
||||||
browser.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopBrowsing() async {
|
|
||||||
CameraTetheringLogger.log("Canon: 停止搜索并断开")
|
|
||||||
stopEventPolling()
|
|
||||||
stopPolling()
|
|
||||||
await disconnect()
|
|
||||||
if !isExternalAttach {
|
|
||||||
deviceBrowser?.stop()
|
|
||||||
}
|
|
||||||
deviceBrowser = nil
|
|
||||||
isExternalAttach = false
|
|
||||||
isCatalogReady = false
|
|
||||||
baselineNotified = false
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() async {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
isDisconnecting = true
|
|
||||||
defer { isDisconnecting = false }
|
|
||||||
|
|
||||||
sessionSetupTask?.cancel()
|
|
||||||
sessionSetupTask = nil
|
|
||||||
|
|
||||||
let shouldClosePTP = isRemoteSessionReady
|
|
||||||
isRemoteSessionReady = false
|
|
||||||
isDownloadingPTPObject = false
|
|
||||||
|
|
||||||
stopEventPolling()
|
|
||||||
stopPolling()
|
|
||||||
cancelPendingDownloads()
|
|
||||||
|
|
||||||
guard let camera = cameraDevice else {
|
|
||||||
resetDisconnectLocalState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let deviceUUID = camera.uuidString ?? ""
|
|
||||||
camera.ptpEventHandler = { _ in }
|
|
||||||
|
|
||||||
if shouldClosePTP {
|
|
||||||
var localTransactionID = transactionID
|
|
||||||
await CanonPTPHelper.closeRemoteSession(on: camera, transactionID: &localTransactionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitForSessionClose(on: camera, deviceUUID: deviceUUID)
|
|
||||||
camera.delegate = nil
|
|
||||||
resetDisconnectLocalState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cancelPendingDownloads() {
|
|
||||||
let disconnected = CameraServiceError.notConnected
|
|
||||||
for (_, continuation) in pendingDownloads {
|
|
||||||
continuation.resume(throwing: disconnected)
|
|
||||||
}
|
|
||||||
pendingDownloads.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForSessionClose(on camera: ICCameraDevice, deviceUUID: String) async {
|
|
||||||
sessionCloseTimeoutTask?.cancel()
|
|
||||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
||||||
sessionCloseContinuation = continuation
|
|
||||||
closingDeviceUUID = deviceUUID
|
|
||||||
camera.requestCloseSession()
|
|
||||||
|
|
||||||
sessionCloseTimeoutTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
||||||
guard let self, self.sessionCloseContinuation != nil else { return }
|
|
||||||
CameraTetheringLogger.log("Canon 会话关闭超时,继续 teardown")
|
|
||||||
self.finishSessionClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionCloseTimeoutTask?.cancel()
|
|
||||||
sessionCloseTimeoutTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishSessionClose() {
|
|
||||||
sessionCloseContinuation?.resume()
|
|
||||||
sessionCloseContinuation = nil
|
|
||||||
closingDeviceUUID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetDisconnectLocalState() {
|
|
||||||
cameraDevice = nil
|
|
||||||
transactionID = 1
|
|
||||||
isCatalogReady = false
|
|
||||||
baselineNotified = false
|
|
||||||
isRemoteSessionReady = false
|
|
||||||
keepDeviceOnSupported = false
|
|
||||||
eventPollTick = 0
|
|
||||||
seenHandles.removeAll()
|
|
||||||
ptpAssetURLs.removeAll()
|
|
||||||
isExternalAttach = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func localURL(forAssetID assetID: String) -> URL? {
|
|
||||||
ptpAssetURLs[assetID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func allFiles() -> [ICCameraFile] {
|
|
||||||
guard let camera = cameraDevice else { return [] }
|
|
||||||
if let mediaFiles = camera.mediaFiles, !mediaFiles.isEmpty {
|
|
||||||
return mediaFiles.compactMap { $0 as? ICCameraFile }
|
|
||||||
}
|
|
||||||
return collectFiles(from: camera.contents ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func file(forAssetID assetID: String) -> ICCameraFile? {
|
|
||||||
allFiles().first { assetIdentifier(for: $0) == assetID }
|
|
||||||
}
|
|
||||||
|
|
||||||
func download(file: ICCameraFile, to directory: URL) async throws -> URL {
|
|
||||||
guard let camera = cameraDevice else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = file.name ?? "photo_\(UUID().uuidString).jpg"
|
|
||||||
let assetID = assetIdentifier(for: file)
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("Canon 开始下载 \(filename), handle=\(file.ptpObjectHandle)", logger: CameraTetheringLogger.transfer)
|
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
pendingDownloads[assetID] = continuation
|
|
||||||
|
|
||||||
downloadDelegate.onComplete = { [weak self] completedFile, url, error in
|
|
||||||
guard let self else { return }
|
|
||||||
let completedID = self.assetIdentifier(for: completedFile)
|
|
||||||
guard let pending = self.pendingDownloads.removeValue(forKey: completedID) else { return }
|
|
||||||
|
|
||||||
if let error {
|
|
||||||
pending.resume(throwing: error)
|
|
||||||
} else if let url {
|
|
||||||
pending.resume(returning: url)
|
|
||||||
} else {
|
|
||||||
pending.resume(throwing: CameraServiceError.downloadFailed("未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let options: [ICDownloadOption: Any] = [
|
|
||||||
.downloadsDirectoryURL: directory,
|
|
||||||
.savedFilename: filename,
|
|
||||||
.overwrite: true
|
|
||||||
]
|
|
||||||
|
|
||||||
camera.requestDownloadFile(
|
|
||||||
file,
|
|
||||||
options: options,
|
|
||||||
downloadDelegate: downloadDelegate,
|
|
||||||
didDownloadSelector: #selector(CameraDownloadDelegate.didDownloadFile(_:error:options:contextInfo:)),
|
|
||||||
contextInfo: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assetIdentifier(for file: ICCameraFile) -> String {
|
|
||||||
if file.ptpObjectHandle != 0 {
|
|
||||||
return "ptp_\(file.ptpObjectHandle)"
|
|
||||||
}
|
|
||||||
if let persistentID = file.value(forKey: "persistentID") as? String, !persistentID.isEmpty {
|
|
||||||
return persistentID
|
|
||||||
}
|
|
||||||
let name = file.name ?? "unknown"
|
|
||||||
let size = file.fileSize
|
|
||||||
let date = file.creationDate?.timeIntervalSince1970 ?? 0
|
|
||||||
return "\(name)_\(size)_\(Int(date))"
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeAsset(from file: ICCameraFile) -> CameraAsset {
|
|
||||||
let filename = file.name ?? "unknown"
|
|
||||||
let isMovie = file.duration > 0 || Self.movieExtensions.contains(where: { filename.lowercased().hasSuffix($0) })
|
|
||||||
return CameraAsset(
|
|
||||||
id: assetIdentifier(for: file),
|
|
||||||
filename: filename,
|
|
||||||
fileSize: file.fileSize,
|
|
||||||
creationDate: file.creationDate,
|
|
||||||
isMovie: isMovie
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let movieExtensions = [".mp4", ".mov", ".m4v", ".avi"]
|
|
||||||
|
|
||||||
private func collectFiles(from items: [ICCameraItem]) -> [ICCameraFile] {
|
|
||||||
var files: [ICCameraFile] = []
|
|
||||||
for item in items {
|
|
||||||
if let file = item as? ICCameraFile {
|
|
||||||
files.append(file)
|
|
||||||
} else if let folder = item as? ICCameraFolder {
|
|
||||||
files.append(contentsOf: collectFiles(from: folder.contents ?? []))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateState(_ state: CameraConnectionState) {
|
|
||||||
CameraTetheringLogger.log("Canon 连接状态: \(state.displayText)")
|
|
||||||
onConnectionStateChange?(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestAuthorizations(completion: @escaping (Bool, Bool) -> Void) {
|
|
||||||
guard let browser = deviceBrowser else {
|
|
||||||
completion(false, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var contentsGranted = false
|
|
||||||
var controlGranted = false
|
|
||||||
let group = DispatchGroup()
|
|
||||||
|
|
||||||
group.enter()
|
|
||||||
browser.requestContentsAuthorization { status in
|
|
||||||
contentsGranted = status == .authorized
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.enter()
|
|
||||||
browser.requestControlAuthorization { status in
|
|
||||||
controlGranted = status == .authorized
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.notify(queue: .main) {
|
|
||||||
completion(contentsGranted, controlGranted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openSession(for camera: ICCameraDevice) {
|
|
||||||
updateState(.connecting)
|
|
||||||
camera.delegate = self
|
|
||||||
cameraDevice = camera
|
|
||||||
CameraTetheringLogger.log("Canon 打开相机会务: \(camera.name ?? "Unknown")")
|
|
||||||
camera.requestOpenSession(options: nil) { [weak self] error in
|
|
||||||
guard let self else { return }
|
|
||||||
if let error {
|
|
||||||
CameraTetheringLogger.log("Canon 会话打开失败: \(error.localizedDescription)")
|
|
||||||
self.onError?(error.localizedDescription)
|
|
||||||
self.updateState(.error(error.localizedDescription))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let name = camera.name ?? "Canon Camera"
|
|
||||||
CameraTetheringLogger.log("Canon 会话已打开: \(name)")
|
|
||||||
self.updateState(.connected(deviceName: name))
|
|
||||||
self.configureCameraAfterSession(camera)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureCameraAfterSession(_ camera: ICCameraDevice) {
|
|
||||||
let capabilities = camera.capabilities.joined(separator: ", ")
|
|
||||||
CameraTetheringLogger.log("Canon 相机能力: [\(capabilities)]")
|
|
||||||
|
|
||||||
camera.ptpEventHandler = { [weak self] eventData in
|
|
||||||
self?.handlePTPEvent(eventData)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionSetupTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let session = await CanonPTPHelper.initializeRemoteSession(
|
|
||||||
on: camera,
|
|
||||||
startingTransactionID: transactionID
|
|
||||||
)
|
|
||||||
guard !Task.isCancelled, !self.isDisconnecting else { return }
|
|
||||||
transactionID = session.transactionID
|
|
||||||
keepDeviceOnSupported = session.keepDeviceOnSupported
|
|
||||||
isRemoteSessionReady = true
|
|
||||||
startEventPolling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePTPEvent(_ eventData: Data) {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
||||||
CameraTetheringLogger.log("Canon 收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startEventPolling() {
|
|
||||||
stopEventPolling()
|
|
||||||
CameraTetheringLogger.log("Canon: 启动 GetEvent 轮询 (每 \(CanonPTPCommand.getEventInterval)s)", logger: CameraTetheringLogger.ptp)
|
|
||||||
|
|
||||||
eventPollTask = Task { [weak self] in
|
|
||||||
while !Task.isCancelled {
|
|
||||||
guard let self, !self.isDisconnecting else { return }
|
|
||||||
await self.pollEventsOnce()
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(CanonPTPCommand.getEventInterval * 1_000_000_000))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopEventPolling() {
|
|
||||||
eventPollTask?.cancel()
|
|
||||||
eventPollTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pollEventsOnce() async {
|
|
||||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return }
|
|
||||||
|
|
||||||
eventPollTick += 1
|
|
||||||
if keepDeviceOnSupported, eventPollTick % CanonPTPCommand.keepOnIntervalTicks == 0 {
|
|
||||||
var localID = transactionID
|
|
||||||
_ = await PTPHelper.sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.eosKeepDeviceOn,
|
|
||||||
transactionID: &localID,
|
|
||||||
parameters: [],
|
|
||||||
outData: nil,
|
|
||||||
label: "KeepDeviceOn(poll)"
|
|
||||||
)
|
|
||||||
transactionID = localID
|
|
||||||
}
|
|
||||||
|
|
||||||
var localID = transactionID
|
|
||||||
let blob = await CanonPTPHelper.pollEvents(on: camera, transactionID: &localID)
|
|
||||||
transactionID = localID
|
|
||||||
|
|
||||||
guard !blob.isEmpty else { return }
|
|
||||||
|
|
||||||
let parsed = CanonEosEventParser.parse(blob)
|
|
||||||
let addedCount = parsed.filter {
|
|
||||||
if case .objectAdded = $0 { return true }
|
|
||||||
return false
|
|
||||||
}.count
|
|
||||||
|
|
||||||
if addedCount > 0 {
|
|
||||||
CameraTetheringLogger.log("Canon GetEvent: \(parsed.count) 事件, 新增 \(addedCount)", logger: CameraTetheringLogger.ptp)
|
|
||||||
}
|
|
||||||
|
|
||||||
for event in parsed {
|
|
||||||
switch event {
|
|
||||||
case .objectAdded(let added):
|
|
||||||
await handleObjectAdded(added)
|
|
||||||
case .requestObjectTransfer(let handle):
|
|
||||||
CameraTetheringLogger.log("Canon RequestObjectTransfer handle=\(handle)", logger: CameraTetheringLogger.ptp)
|
|
||||||
case .cameraStatus:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleObjectAdded(_ added: CanonEosEventParser.ObjectAdded) async {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
guard seenHandles.insert(added.objectHandle).inserted else { return }
|
|
||||||
|
|
||||||
let asset = CanonEosEventParser.makeAsset(from: added)
|
|
||||||
guard CanonPTPHelper.isSupportedFilename(asset.filename) else {
|
|
||||||
CameraTetheringLogger.log("Canon: 跳过不支持的文件 \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !isDownloadingPTPObject else {
|
|
||||||
CameraTetheringLogger.log("Canon: 下载进行中,稍后重试 \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
|
||||||
seenHandles.remove(added.objectHandle)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isDownloadingPTPObject = true
|
|
||||||
defer { isDownloadingPTPObject = false }
|
|
||||||
|
|
||||||
guard let camera = cameraDevice else { return }
|
|
||||||
|
|
||||||
var localID = transactionID
|
|
||||||
guard let data = await CanonPTPHelper.downloadObject(
|
|
||||||
handle: added.objectHandle,
|
|
||||||
on: camera,
|
|
||||||
transactionID: &localID
|
|
||||||
) else {
|
|
||||||
seenHandles.remove(added.objectHandle)
|
|
||||||
CameraTetheringLogger.log("Canon: PTP 下载失败 \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
transactionID = localID
|
|
||||||
|
|
||||||
await CanonPTPHelper.transferComplete(
|
|
||||||
handle: added.objectHandle,
|
|
||||||
on: camera,
|
|
||||||
transactionID: &localID
|
|
||||||
)
|
|
||||||
transactionID = localID
|
|
||||||
|
|
||||||
let destination = CameraDownloadStorage.uniqueTemporaryURL(for: asset.filename)
|
|
||||||
do {
|
|
||||||
try data.write(to: destination, options: .atomic)
|
|
||||||
} catch {
|
|
||||||
seenHandles.remove(added.objectHandle)
|
|
||||||
CameraTetheringLogger.log("Canon: 文件写入失败 \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ptpAssetURLs[asset.id] = destination
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"Canon PTP 下载完成: \(asset.filename) -> \(destination.path)",
|
|
||||||
logger: CameraTetheringLogger.transfer
|
|
||||||
)
|
|
||||||
onPTPAssetReady?(asset, destination)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyBaselineIfNeeded() {
|
|
||||||
guard !baselineNotified else { return }
|
|
||||||
baselineNotified = true
|
|
||||||
isCatalogReady = true
|
|
||||||
|
|
||||||
let fileCount = allFiles().count
|
|
||||||
CameraTetheringLogger.log("Canon 目录就绪,当前 \(fileCount) 个文件,建立基线")
|
|
||||||
onBaselineCatalogReady?()
|
|
||||||
startPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startPolling() {
|
|
||||||
stopPolling()
|
|
||||||
CameraTetheringLogger.log("Canon: 启动轮询检测新照片 (每 1.5s)")
|
|
||||||
|
|
||||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
|
|
||||||
guard let self, self.isCatalogReady else { return }
|
|
||||||
self.onPollTick?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopPolling() {
|
|
||||||
pollTimer?.invalidate()
|
|
||||||
pollTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CanonImageCaptureDeviceManager: ICDeviceBrowserDelegate {
|
|
||||||
@objc(deviceBrowser:didAddDevice:moreComing:)
|
|
||||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
|
||||||
CameraTetheringLogger.log("Canon 发现设备: \(device.name ?? "?")")
|
|
||||||
|
|
||||||
guard let camera = device as? ICCameraDevice else { return }
|
|
||||||
guard cameraDevice == nil else { return }
|
|
||||||
|
|
||||||
requestAuthorizations { [weak self] contentsGranted, controlGranted in
|
|
||||||
guard let self else { return }
|
|
||||||
guard contentsGranted else {
|
|
||||||
let message = "未获得相机内容访问权限,请在系统设置中允许"
|
|
||||||
self.onError?(message)
|
|
||||||
self.updateState(.error("权限被拒绝"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !controlGranted {
|
|
||||||
CameraTetheringLogger.log("Canon: 控制授权未通过,仍尝试连接")
|
|
||||||
}
|
|
||||||
self.openSession(for: camera)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
|
|
||||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
if device.uuidString == cameraDevice?.uuidString {
|
|
||||||
Task { await disconnect() }
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CanonImageCaptureDeviceManager: ICDeviceDelegate {
|
|
||||||
func didRemove(_ device: ICDevice) {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
if device.uuidString == cameraDevice?.uuidString {
|
|
||||||
Task { await disconnect() }
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func device(_ device: ICDevice, didOpenSessionWithError error: Error?) {
|
|
||||||
if let error {
|
|
||||||
CameraTetheringLogger.log("Canon delegate didOpenSession 失败: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func device(_ device: ICDevice, didCloseSessionWithError error: Error?) {
|
|
||||||
if device.uuidString == closingDeviceUUID {
|
|
||||||
finishSessionClose()
|
|
||||||
}
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
if device.uuidString == cameraDevice?.uuidString {
|
|
||||||
cameraDevice = nil
|
|
||||||
isCatalogReady = false
|
|
||||||
baselineNotified = false
|
|
||||||
isRemoteSessionReady = false
|
|
||||||
stopPolling()
|
|
||||||
stopEventPolling()
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CanonImageCaptureDeviceManager: ICCameraDeviceDelegate {
|
|
||||||
@objc(cameraDevice:didAddItems:)
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
|
|
||||||
let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name }
|
|
||||||
CameraTetheringLogger.log("Canon didAddItems: \(names.joined(separator: ", "))")
|
|
||||||
onNewItems?(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(cameraDevice:didRemoveItems:)
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(cameraDevice:didRenameItems:)
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didReceiveThumbnail thumbnail: CGImage?, for item: ICCameraItem, error: Error?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didReceiveMetadata metadata: [AnyHashable: Any]?, for item: ICCameraItem, error: Error?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func deviceDidBecomeReady(withCompleteContentCatalog device: ICCameraDevice) {
|
|
||||||
CameraTetheringLogger.log("Canon deviceDidBecomeReady, 文件数=\(allFiles().count)")
|
|
||||||
notifyBaselineIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
|
|
||||||
onError?("设备已锁定,解锁后才能读取媒体文件")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) {
|
|
||||||
handlePTPEvent(eventData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// Canon EOS PTP 厂商扩展命令码。
|
|
||||||
enum CanonPTPCommand {
|
|
||||||
static let eosGetObject: UInt16 = 0x9104
|
|
||||||
static let eosGetStorageIDs: UInt16 = 0x9101
|
|
||||||
static let eosGetObjectInfoEx: UInt16 = 0x9109
|
|
||||||
static let eosGetPartialObject: UInt16 = 0x9107
|
|
||||||
static let eosGetThumbEx: UInt16 = 0x910A
|
|
||||||
static let eosSetRemoteMode: UInt16 = 0x9114
|
|
||||||
static let eosSetEventMode: UInt16 = 0x9115
|
|
||||||
static let eosGetEvent: UInt16 = 0x9116
|
|
||||||
static let eosTransferComplete: UInt16 = 0x9117
|
|
||||||
static let eosCancelTransfer: UInt16 = 0x9118
|
|
||||||
static let eosKeepDeviceOn: UInt16 = 0x911D
|
|
||||||
|
|
||||||
static let eosObjectAddedEx: UInt16 = 0xC181
|
|
||||||
static let eosObjectRemoved: UInt16 = 0xC182
|
|
||||||
static let eosRequestGetObjectInfoEx: UInt16 = 0xC183
|
|
||||||
static let eosStorageStatusChanged: UInt16 = 0xC184
|
|
||||||
static let eosStorageInfoChanged: UInt16 = 0xC185
|
|
||||||
static let eosObjectInfoChangedEx: UInt16 = 0xC189
|
|
||||||
static let eosCameraStatusChanged: UInt16 = 0xC18A
|
|
||||||
static let eosRequestObjectTransfer: UInt16 = 0xC18B
|
|
||||||
static let eosObjectAddedEx2: UInt16 = 0xC1A7
|
|
||||||
|
|
||||||
static let getObjectInfo: UInt16 = 0x1008
|
|
||||||
static let getObject: UInt16 = 0x1009
|
|
||||||
static let getThumb: UInt16 = 0x100A
|
|
||||||
static let closeSession: UInt16 = 0x1003
|
|
||||||
|
|
||||||
static let getEventInterval: TimeInterval = 0.5
|
|
||||||
static let keepOnIntervalTicks: Int = 60
|
|
||||||
|
|
||||||
static let supportedExtensions: Set<String> = [
|
|
||||||
"jpg", "jpeg", "png", "cr2", "cr3", "mp4", "mov"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canon EOS PTP 命令封装与会话初始化。
|
|
||||||
enum CanonPTPHelper {
|
|
||||||
/// ImageCaptureCore 已打开会话后,进入 Canon PC Remote 模式并启用事件推送。
|
|
||||||
static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> (transactionID: UInt32, keepDeviceOnSupported: Bool) {
|
|
||||||
var transactionID = startingTransactionID
|
|
||||||
|
|
||||||
guard camera.capabilities.contains("ICCameraDeviceCanAcceptPTPCommands") else {
|
|
||||||
CameraTetheringLogger.log("Canon: 相机不支持 PTP 指令", logger: CameraTetheringLogger.ptp)
|
|
||||||
return (transactionID, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("开始 Canon EOS 远程会话初始化", logger: CameraTetheringLogger.ptp)
|
|
||||||
|
|
||||||
let remoteMode = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.eosSetRemoteMode,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [1],
|
|
||||||
outData: nil,
|
|
||||||
label: "SetRemoteMode"
|
|
||||||
)
|
|
||||||
CameraTetheringLogger.log("SetRemoteMode: \(remoteMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp)
|
|
||||||
|
|
||||||
let eventMode = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.eosSetEventMode,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [1],
|
|
||||||
outData: nil,
|
|
||||||
label: "SetEventMode"
|
|
||||||
)
|
|
||||||
CameraTetheringLogger.log("SetEventMode: \(eventMode.success ? "OK" : "failed")", logger: CameraTetheringLogger.ptp)
|
|
||||||
|
|
||||||
let keepOn = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.eosKeepDeviceOn,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [],
|
|
||||||
outData: nil,
|
|
||||||
label: "KeepDeviceOn"
|
|
||||||
)
|
|
||||||
let keepOnSupported = keepOn.success
|
|
||||||
CameraTetheringLogger.log("KeepDeviceOn: \(keepOnSupported ? "OK" : "skipped")", logger: CameraTetheringLogger.ptp)
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("Canon EOS 远程会话初始化完成", logger: CameraTetheringLogger.ptp)
|
|
||||||
return (transactionID, keepOnSupported)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 关闭 PTP 远程会话,释放相机 PC Remote 占用。
|
|
||||||
static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
|
||||||
_ = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.closeSession,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [],
|
|
||||||
outData: nil,
|
|
||||||
label: "CloseSession"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 轮询 Canon 事件队列。
|
|
||||||
static func pollEvents(
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
transactionID: inout UInt32
|
|
||||||
) async -> Data {
|
|
||||||
let result = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.eosGetEvent,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [],
|
|
||||||
outData: nil,
|
|
||||||
label: "GetEvent"
|
|
||||||
)
|
|
||||||
return result.success ? result.inData : Data()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通过 Canon 扩展 GetObject 下载文件。
|
|
||||||
static func downloadObject(
|
|
||||||
handle: UInt32,
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
transactionID: inout UInt32
|
|
||||||
) async -> Data? {
|
|
||||||
let result = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.eosGetObject,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [handle],
|
|
||||||
outData: nil,
|
|
||||||
label: "EOS_GetObject(0x\(String(format: "%08X", handle)))"
|
|
||||||
)
|
|
||||||
guard result.success, !result.inData.isEmpty else { return nil }
|
|
||||||
return result.inData
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通知相机传输完成,释放缓冲。
|
|
||||||
static func transferComplete(
|
|
||||||
handle: UInt32,
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
transactionID: inout UInt32
|
|
||||||
) async {
|
|
||||||
_ = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: CanonPTPCommand.eosTransferComplete,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [handle],
|
|
||||||
outData: nil,
|
|
||||||
label: "TransferComplete"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isSupportedFilename(_ filename: String) -> Bool {
|
|
||||||
let ext = (filename as NSString).pathExtension.lowercased()
|
|
||||||
return CanonPTPCommand.supportedExtensions.contains(ext)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func assetID(handle: UInt32, filename: String, size: Int64) -> String {
|
|
||||||
"canon_\(handle)_\(filename)_\(size)"
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sendCommand(
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
code: UInt16,
|
|
||||||
transactionID: inout UInt32,
|
|
||||||
parameters: [UInt32],
|
|
||||||
outData: Data?,
|
|
||||||
label: String
|
|
||||||
) async -> PTPCommandResult {
|
|
||||||
await PTPHelper.sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: code,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: parameters,
|
|
||||||
outData: outData,
|
|
||||||
label: label
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum CameraServiceFactory {
|
|
||||||
static func make(brand: CameraBrand) -> any CameraServiceProtocol {
|
|
||||||
switch brand {
|
|
||||||
case .sony:
|
|
||||||
SonyCameraService()
|
|
||||||
case .canon:
|
|
||||||
CanonCameraService()
|
|
||||||
case .nikon:
|
|
||||||
preconditionFailure("\(brand.displayName) 相机尚未实现")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 自动识别 USB 相机品牌;返回进程级共享实例以支持页面 detach / 再进入复连。
|
|
||||||
static func makeAutoDetect() -> any CameraServiceProtocol {
|
|
||||||
CameraTetheringSession.sharedService
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct CameraAsset: Identifiable, Equatable, Hashable {
|
|
||||||
let id: String
|
|
||||||
let filename: String
|
|
||||||
let fileSize: Int64
|
|
||||||
let creationDate: Date?
|
|
||||||
let isMovie: Bool
|
|
||||||
|
|
||||||
init(
|
|
||||||
id: String,
|
|
||||||
filename: String,
|
|
||||||
fileSize: Int64,
|
|
||||||
creationDate: Date? = nil,
|
|
||||||
isMovie: Bool = false
|
|
||||||
) {
|
|
||||||
self.id = id
|
|
||||||
self.filename = filename
|
|
||||||
self.fileSize = fileSize
|
|
||||||
self.creationDate = creationDate
|
|
||||||
self.isMovie = isMovie
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum CameraBrand: String, CaseIterable, Identifiable {
|
|
||||||
case sony
|
|
||||||
case canon
|
|
||||||
case nikon
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
|
||||||
|
|
||||||
var displayName: String {
|
|
||||||
switch self {
|
|
||||||
case .sony: "Sony"
|
|
||||||
case .canon: "Canon"
|
|
||||||
case .nikon: "Nikon"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// USB Vendor ID 常量,用于自动识别相机品牌。
|
|
||||||
enum CameraUSBVendorID {
|
|
||||||
static let sony: Int = 0x054C
|
|
||||||
static let canon: Int = 0x04A9
|
|
||||||
static let nikon: Int = 0x04B0
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CameraBrand {
|
|
||||||
/// 根据 ICCameraDevice 的 USB Vendor ID 识别品牌。
|
|
||||||
static func detect(from camera: ICCameraDevice) -> CameraBrand? {
|
|
||||||
switch Int(camera.usbVendorID) {
|
|
||||||
case CameraUSBVendorID.sony:
|
|
||||||
return .sony
|
|
||||||
case CameraUSBVendorID.canon:
|
|
||||||
return .canon
|
|
||||||
case CameraUSBVendorID.nikon:
|
|
||||||
return .nikon
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum CameraServiceError: LocalizedError {
|
|
||||||
case notConnected
|
|
||||||
case assetNotFound
|
|
||||||
case downloadFailed(String)
|
|
||||||
case permissionDenied
|
|
||||||
|
|
||||||
var errorDescription: String? {
|
|
||||||
switch self {
|
|
||||||
case .notConnected:
|
|
||||||
"相机未连接"
|
|
||||||
case .assetNotFound:
|
|
||||||
"找不到相机内的文件"
|
|
||||||
case .downloadFailed(let reason):
|
|
||||||
"下载失败:\(reason)"
|
|
||||||
case .permissionDenied:
|
|
||||||
"未获得相机访问权限"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol CameraServiceProtocol: AnyObject {
|
|
||||||
var brand: CameraBrand { get }
|
|
||||||
var connectionState: CameraConnectionState { get }
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)? { get set }
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)? { get set }
|
|
||||||
|
|
||||||
func connect() async
|
|
||||||
/// 页面离开:仅清空 UI 回调,不关闭相机会话。
|
|
||||||
func detachFromUI()
|
|
||||||
/// App 终止或物理断开:完整释放相机资源。
|
|
||||||
func shutdown() async
|
|
||||||
func listAssets() async throws -> [CameraAsset]
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL
|
|
||||||
}
|
|
||||||
|
|
||||||
extension CameraServiceProtocol {
|
|
||||||
/// 兼容旧调用,语义等同 `shutdown()`。
|
|
||||||
func disconnect() async {
|
|
||||||
await shutdown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,886 +0,0 @@
|
|||||||
import CoreGraphics
|
|
||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
final class ImageCaptureDeviceManager: NSObject {
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewItems: (([ICCameraItem]) -> Void)?
|
|
||||||
var onBaselineCatalogReady: (() -> Void)?
|
|
||||||
var onPollTick: (() -> Void)?
|
|
||||||
var onPTPAssetReady: ((CameraAsset, URL) -> Void)?
|
|
||||||
var onError: ((String) -> Void)?
|
|
||||||
|
|
||||||
private(set) var cameraDevice: ICCameraDevice?
|
|
||||||
private(set) var isCatalogReady = false
|
|
||||||
private var deviceBrowser: ICDeviceBrowser?
|
|
||||||
private let downloadDelegate = CameraDownloadDelegate()
|
|
||||||
private var transactionID: UInt32 = 1
|
|
||||||
private var pendingDownloads: [String: CheckedContinuation<URL, Error>] = [:]
|
|
||||||
private var ptpAssetURLs: [String: URL] = [:]
|
|
||||||
private var pollTimer: Timer?
|
|
||||||
private var baselineNotified = false
|
|
||||||
private var isRemoteSessionReady = false
|
|
||||||
private var isDownloadingPTPObject = false
|
|
||||||
private var propertyCheckTask: Task<Void, Never>?
|
|
||||||
private var shotBufferPollTimer: Timer?
|
|
||||||
private var lastDownloadedObjectSignature: String?
|
|
||||||
private var lastShootingFileInfoValue: Int64?
|
|
||||||
private var shotDownloadQueue: [(handle: UInt32, reason: String)] = []
|
|
||||||
private var isDrainingShotQueue = false
|
|
||||||
private var isEvaluatingShotBuffer = false
|
|
||||||
private var propertyCheckCoalesceScheduled = false
|
|
||||||
private var isExternalAttach = false
|
|
||||||
private var sessionSetupTask: Task<Void, Never>?
|
|
||||||
private var sessionCloseContinuation: CheckedContinuation<Void, Never>?
|
|
||||||
private var sessionCloseTimeoutTask: Task<Void, Never>?
|
|
||||||
private var closingDeviceUUID: String?
|
|
||||||
private var shotDownloadDrainTask: Task<Void, Never>?
|
|
||||||
private var coalescedCheckTask: Task<Void, Never>?
|
|
||||||
private var isDisconnecting = false
|
|
||||||
|
|
||||||
var isBrowsing: Bool { deviceBrowser != nil || isExternalAttach }
|
|
||||||
|
|
||||||
/// 跳过 Browser,直接连接 AutoDetect 已发现并授权的设备。
|
|
||||||
func attach(to camera: ICCameraDevice) {
|
|
||||||
guard cameraDevice == nil else {
|
|
||||||
CameraTetheringLogger.log("Sony attach: 已有连接中的相机,忽略")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isExternalAttach = true
|
|
||||||
CameraTetheringLogger.log("Sony attach: \(camera.name ?? "Unknown")")
|
|
||||||
openSession(for: camera)
|
|
||||||
}
|
|
||||||
|
|
||||||
func startBrowsing() {
|
|
||||||
guard deviceBrowser == nil else {
|
|
||||||
CameraTetheringLogger.log("已在监听 USB 相机")
|
|
||||||
if cameraDevice == nil {
|
|
||||||
updateState(.searching)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("开始搜索 USB 相机…")
|
|
||||||
updateState(.searching)
|
|
||||||
|
|
||||||
let browser = ICDeviceBrowser()
|
|
||||||
browser.delegate = self
|
|
||||||
if #available(iOS 15.2, *) {
|
|
||||||
let mask = ICDeviceTypeMask(
|
|
||||||
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
|
|
||||||
) ?? .camera
|
|
||||||
browser.browsedDeviceTypeMask = mask
|
|
||||||
CameraTetheringLogger.log("已设置 browsedDeviceTypeMask = Camera | Local USB")
|
|
||||||
}
|
|
||||||
deviceBrowser = browser
|
|
||||||
browser.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stopBrowsing() async {
|
|
||||||
CameraTetheringLogger.log("停止搜索并断开")
|
|
||||||
propertyCheckTask?.cancel()
|
|
||||||
shotDownloadQueue.removeAll()
|
|
||||||
isDrainingShotQueue = false
|
|
||||||
isEvaluatingShotBuffer = false
|
|
||||||
propertyCheckCoalesceScheduled = false
|
|
||||||
propertyCheckTask = nil
|
|
||||||
stopShotBufferPolling()
|
|
||||||
stopPolling()
|
|
||||||
await disconnect()
|
|
||||||
if !isExternalAttach {
|
|
||||||
deviceBrowser?.stop()
|
|
||||||
}
|
|
||||||
deviceBrowser = nil
|
|
||||||
isExternalAttach = false
|
|
||||||
isCatalogReady = false
|
|
||||||
baselineNotified = false
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func disconnect() async {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
isDisconnecting = true
|
|
||||||
defer { isDisconnecting = false }
|
|
||||||
|
|
||||||
sessionSetupTask?.cancel()
|
|
||||||
sessionSetupTask = nil
|
|
||||||
shotDownloadDrainTask?.cancel()
|
|
||||||
shotDownloadDrainTask = nil
|
|
||||||
coalescedCheckTask?.cancel()
|
|
||||||
coalescedCheckTask = nil
|
|
||||||
propertyCheckTask?.cancel()
|
|
||||||
propertyCheckTask = nil
|
|
||||||
|
|
||||||
let shouldClosePTP = isRemoteSessionReady
|
|
||||||
isRemoteSessionReady = false
|
|
||||||
isDownloadingPTPObject = false
|
|
||||||
|
|
||||||
shotDownloadQueue.removeAll()
|
|
||||||
isDrainingShotQueue = false
|
|
||||||
isEvaluatingShotBuffer = false
|
|
||||||
propertyCheckCoalesceScheduled = false
|
|
||||||
stopShotBufferPolling()
|
|
||||||
stopPolling()
|
|
||||||
cancelPendingDownloads()
|
|
||||||
|
|
||||||
guard let camera = cameraDevice else {
|
|
||||||
resetDisconnectLocalState()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let deviceUUID = camera.uuidString ?? ""
|
|
||||||
camera.ptpEventHandler = { _ in }
|
|
||||||
|
|
||||||
if shouldClosePTP {
|
|
||||||
var localTransactionID = transactionID
|
|
||||||
await SonyPTPHelper.teardownRemoteSession(on: camera, transactionID: &localTransactionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
await waitForSessionClose(on: camera, deviceUUID: deviceUUID)
|
|
||||||
camera.delegate = nil
|
|
||||||
resetDisconnectLocalState()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cancelPendingDownloads() {
|
|
||||||
let disconnected = CameraServiceError.notConnected
|
|
||||||
for (_, continuation) in pendingDownloads {
|
|
||||||
continuation.resume(throwing: disconnected)
|
|
||||||
}
|
|
||||||
pendingDownloads.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func waitForSessionClose(on camera: ICCameraDevice, deviceUUID: String) async {
|
|
||||||
sessionCloseTimeoutTask?.cancel()
|
|
||||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
|
||||||
sessionCloseContinuation = continuation
|
|
||||||
closingDeviceUUID = deviceUUID
|
|
||||||
camera.requestCloseSession()
|
|
||||||
|
|
||||||
sessionCloseTimeoutTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
||||||
guard let self, self.sessionCloseContinuation != nil else { return }
|
|
||||||
CameraTetheringLogger.log("会话关闭超时,继续 teardown")
|
|
||||||
self.finishSessionClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sessionCloseTimeoutTask?.cancel()
|
|
||||||
sessionCloseTimeoutTask = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func finishSessionClose() {
|
|
||||||
sessionCloseContinuation?.resume()
|
|
||||||
sessionCloseContinuation = nil
|
|
||||||
closingDeviceUUID = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func resetDisconnectLocalState() {
|
|
||||||
cameraDevice = nil
|
|
||||||
transactionID = 1
|
|
||||||
isCatalogReady = false
|
|
||||||
baselineNotified = false
|
|
||||||
isRemoteSessionReady = false
|
|
||||||
ptpAssetURLs.removeAll()
|
|
||||||
lastDownloadedObjectSignature = nil
|
|
||||||
lastShootingFileInfoValue = nil
|
|
||||||
shotDownloadQueue.removeAll()
|
|
||||||
isDrainingShotQueue = false
|
|
||||||
isEvaluatingShotBuffer = false
|
|
||||||
propertyCheckCoalesceScheduled = false
|
|
||||||
isExternalAttach = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func localURL(forAssetID assetID: String) -> URL? {
|
|
||||||
ptpAssetURLs[assetID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func allFiles() -> [ICCameraFile] {
|
|
||||||
guard let camera = cameraDevice else { return [] }
|
|
||||||
|
|
||||||
if let mediaFiles = camera.mediaFiles, !mediaFiles.isEmpty {
|
|
||||||
return mediaFiles.compactMap { $0 as? ICCameraFile }
|
|
||||||
}
|
|
||||||
return collectFiles(from: camera.contents ?? [])
|
|
||||||
}
|
|
||||||
|
|
||||||
func file(forAssetID assetID: String) -> ICCameraFile? {
|
|
||||||
allFiles().first { assetIdentifier(for: $0) == assetID }
|
|
||||||
}
|
|
||||||
|
|
||||||
func download(file: ICCameraFile, to directory: URL) async throws -> URL {
|
|
||||||
guard let camera = cameraDevice else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = file.name ?? "photo_\(UUID().uuidString).jpg"
|
|
||||||
let assetID = assetIdentifier(for: file)
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("开始下载 \(filename), handle=\(file.ptpObjectHandle)", logger: CameraTetheringLogger.transfer)
|
|
||||||
|
|
||||||
return try await withCheckedThrowingContinuation { continuation in
|
|
||||||
pendingDownloads[assetID] = continuation
|
|
||||||
|
|
||||||
downloadDelegate.onComplete = { [weak self] completedFile, url, error in
|
|
||||||
guard let self else { return }
|
|
||||||
let completedID = self.assetIdentifier(for: completedFile)
|
|
||||||
guard let pending = self.pendingDownloads.removeValue(forKey: completedID) else { return }
|
|
||||||
|
|
||||||
if let error {
|
|
||||||
pending.resume(throwing: error)
|
|
||||||
} else if let url {
|
|
||||||
pending.resume(returning: url)
|
|
||||||
} else {
|
|
||||||
pending.resume(throwing: CameraServiceError.downloadFailed("未知错误"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let options: [ICDownloadOption: Any] = [
|
|
||||||
.downloadsDirectoryURL: directory,
|
|
||||||
.savedFilename: filename,
|
|
||||||
.overwrite: true
|
|
||||||
]
|
|
||||||
|
|
||||||
camera.requestDownloadFile(
|
|
||||||
file,
|
|
||||||
options: options,
|
|
||||||
downloadDelegate: downloadDelegate,
|
|
||||||
didDownloadSelector: #selector(CameraDownloadDelegate.didDownloadFile(_:error:options:contextInfo:)),
|
|
||||||
contextInfo: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assetIdentifier(for file: ICCameraFile) -> String {
|
|
||||||
if file.ptpObjectHandle != 0 {
|
|
||||||
return "ptp_\(file.ptpObjectHandle)"
|
|
||||||
}
|
|
||||||
if let persistentID = file.value(forKey: "persistentID") as? String, !persistentID.isEmpty {
|
|
||||||
return persistentID
|
|
||||||
}
|
|
||||||
let name = file.name ?? "unknown"
|
|
||||||
let size = file.fileSize
|
|
||||||
let date = file.creationDate?.timeIntervalSince1970 ?? 0
|
|
||||||
return "\(name)_\(size)_\(Int(date))"
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeAsset(from file: ICCameraFile) -> CameraAsset {
|
|
||||||
let filename = file.name ?? "unknown"
|
|
||||||
let isMovie = file.duration > 0 || Self.movieExtensions.contains(where: { filename.lowercased().hasSuffix($0) })
|
|
||||||
return CameraAsset(
|
|
||||||
id: assetIdentifier(for: file),
|
|
||||||
filename: filename,
|
|
||||||
fileSize: file.fileSize,
|
|
||||||
creationDate: file.creationDate,
|
|
||||||
isMovie: isMovie
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static let movieExtensions = [".mp4", ".mov", ".m4v", ".avi"]
|
|
||||||
|
|
||||||
private func collectFiles(from items: [ICCameraItem]) -> [ICCameraFile] {
|
|
||||||
var files: [ICCameraFile] = []
|
|
||||||
for item in items {
|
|
||||||
if let file = item as? ICCameraFile {
|
|
||||||
files.append(file)
|
|
||||||
} else if let folder = item as? ICCameraFolder {
|
|
||||||
files.append(contentsOf: collectFiles(from: folder.contents ?? []))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateState(_ state: CameraConnectionState) {
|
|
||||||
CameraTetheringLogger.log("连接状态: \(state.displayText)")
|
|
||||||
onConnectionStateChange?(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestAuthorizations(completion: @escaping (Bool, Bool) -> Void) {
|
|
||||||
guard let browser = deviceBrowser else {
|
|
||||||
completion(false, false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var contentsGranted = false
|
|
||||||
var controlGranted = false
|
|
||||||
let group = DispatchGroup()
|
|
||||||
|
|
||||||
group.enter()
|
|
||||||
browser.requestContentsAuthorization { status in
|
|
||||||
contentsGranted = status == .authorized
|
|
||||||
CameraTetheringLogger.log("内容授权: \(String(describing: status))")
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.enter()
|
|
||||||
browser.requestControlAuthorization { status in
|
|
||||||
controlGranted = status == .authorized
|
|
||||||
CameraTetheringLogger.log("控制授权: \(String(describing: status))")
|
|
||||||
group.leave()
|
|
||||||
}
|
|
||||||
|
|
||||||
group.notify(queue: .main) {
|
|
||||||
completion(contentsGranted, controlGranted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func openSession(for camera: ICCameraDevice) {
|
|
||||||
updateState(.connecting)
|
|
||||||
camera.delegate = self
|
|
||||||
cameraDevice = camera
|
|
||||||
CameraTetheringLogger.log("打开相机会话: \(camera.name ?? "Unknown")")
|
|
||||||
camera.requestOpenSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureCameraAfterSession(_ camera: ICCameraDevice) {
|
|
||||||
let capabilities = camera.capabilities.joined(separator: ", ")
|
|
||||||
CameraTetheringLogger.log("相机能力: [\(capabilities)]")
|
|
||||||
CameraTetheringLogger.log("tetheredCaptureEnabled=\(camera.tetheredCaptureEnabled)")
|
|
||||||
|
|
||||||
camera.ptpEventHandler = { [weak self] eventData in
|
|
||||||
self?.handlePTPEvent(eventData)
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionSetupTask = Task { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
let updatedID = await SonyPTPHelper.initializeRemoteSession(
|
|
||||||
on: camera,
|
|
||||||
startingTransactionID: transactionID
|
|
||||||
)
|
|
||||||
guard !Task.isCancelled, !self.isDisconnecting else { return }
|
|
||||||
transactionID = updatedID
|
|
||||||
isRemoteSessionReady = true
|
|
||||||
startShotBufferPolling()
|
|
||||||
schedulePropertyBasedShotCheck(reason: "PostHandshake", delay: 0.2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePTPEvent(_ eventData: Data) {
|
|
||||||
guard !isDisconnecting, isRemoteSessionReady else { return }
|
|
||||||
let hex = eventData.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " ")
|
|
||||||
CameraTetheringLogger.log("收到 PTP 事件: \(hex)", logger: CameraTetheringLogger.ptp)
|
|
||||||
|
|
||||||
guard let container = try? SonyPTPHelper.parseContainer(eventData) else {
|
|
||||||
CameraTetheringLogger.log("PTP 事件解析失败", logger: CameraTetheringLogger.ptp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"PTP 事件码: 0x\(String(format: "%04X", container.code)), params=\(container.params)",
|
|
||||||
logger: CameraTetheringLogger.ptp
|
|
||||||
)
|
|
||||||
|
|
||||||
switch container.code {
|
|
||||||
case SonyPTPCommand.objectAdded, SonyPTPCommand.sdieObjectAdded:
|
|
||||||
guard isRemoteSessionReady else { break }
|
|
||||||
let handle = container.params.first ?? SonyPTPCommand.shotObjectHandle
|
|
||||||
enqueueShotDownload(handle: handle, reason: "ObjectAdded")
|
|
||||||
case SonyPTPCommand.sdieCapturedEvent:
|
|
||||||
guard isRemoteSessionReady else { break }
|
|
||||||
enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: "CapturedEvent")
|
|
||||||
case SonyPTPCommand.sdieObjectRemoved:
|
|
||||||
lastShootingFileInfoValue = nil
|
|
||||||
case SonyPTPCommand.sdieDevicePropChanged:
|
|
||||||
guard isRemoteSessionReady else { break }
|
|
||||||
schedulePropertyBasedShotCheck(reason: "DevicePropChanged")
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func schedulePropertyBasedShotCheck(reason: String, delay: TimeInterval = 0.12) {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
if isEvaluatingShotBuffer {
|
|
||||||
propertyCheckCoalesceScheduled = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
propertyCheckTask?.cancel()
|
|
||||||
propertyCheckTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await self?.runPropertyBasedShotCheck(reason: reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func runPropertyBasedShotCheck(reason: String) async {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
if isEvaluatingShotBuffer {
|
|
||||||
propertyCheckCoalesceScheduled = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isEvaluatingShotBuffer = true
|
|
||||||
defer {
|
|
||||||
isEvaluatingShotBuffer = false
|
|
||||||
if propertyCheckCoalesceScheduled, !isDisconnecting {
|
|
||||||
propertyCheckCoalesceScheduled = false
|
|
||||||
coalescedCheckTask?.cancel()
|
|
||||||
coalescedCheckTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 80_000_000)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await self?.runPropertyBasedShotCheck(reason: "Coalesced")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
propertyCheckCoalesceScheduled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await evaluateShotBufferFromProperties(reason: reason)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func evaluateShotBufferFromProperties(reason: String) async {
|
|
||||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return }
|
|
||||||
|
|
||||||
var localTransactionID = transactionID
|
|
||||||
var properties = await SonyPTPHelper.fetchExtDeviceProperties(
|
|
||||||
on: camera,
|
|
||||||
transactionID: &localTransactionID,
|
|
||||||
onlyDiff: true
|
|
||||||
)
|
|
||||||
transactionID = localTransactionID
|
|
||||||
|
|
||||||
var shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
|
|
||||||
|
|
||||||
// diff 响应常不含 D215(仅其它属性变化),需 fallback 全量读取或直接 Probe
|
|
||||||
if shootingFileInfo == nil, shouldFallbackFullPropertyFetch(for: reason) {
|
|
||||||
properties = await SonyPTPHelper.fetchExtDeviceProperties(
|
|
||||||
on: camera,
|
|
||||||
transactionID: &localTransactionID,
|
|
||||||
onlyDiff: false
|
|
||||||
)
|
|
||||||
transactionID = localTransactionID
|
|
||||||
shootingFileInfo = properties[SonyPTPCommand.shootingFileInfo]
|
|
||||||
CameraTetheringLogger.log("\(reason): diff 无 D215,已读取全量属性", logger: CameraTetheringLogger.ptp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if let shootingFileInfo {
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"\(reason): D215(Shooting_File_Info)=0x\(String(format: "%04X", shootingFileInfo))",
|
|
||||||
logger: CameraTetheringLogger.ptp
|
|
||||||
)
|
|
||||||
lastShootingFileInfoValue = shootingFileInfo
|
|
||||||
} else {
|
|
||||||
CameraTetheringLogger.log("\(reason): 属性包无 D215,改用 Probe 检测缓冲", logger: CameraTetheringLogger.ptp)
|
|
||||||
}
|
|
||||||
|
|
||||||
if SonyPTPHelper.isShotBufferReady(shootingFileInfo) {
|
|
||||||
_ = await attemptFetchPendingShot(reason: "\(reason)-D215")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if shouldProbeWithoutD215Ready(for: reason) {
|
|
||||||
_ = await attemptFetchPendingShot(reason: "\(reason)-Probe")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldFallbackFullPropertyFetch(for reason: String) -> Bool {
|
|
||||||
reason.contains("DevicePropChanged")
|
|
||||||
|| reason.hasPrefix("D215Poll")
|
|
||||||
|| reason.hasPrefix("Coalesced")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func shouldProbeWithoutD215Ready(for reason: String) -> Bool {
|
|
||||||
reason.contains("DevicePropChanged")
|
|
||||||
|| reason.hasPrefix("D215Poll")
|
|
||||||
|| reason.hasPrefix("Coalesced")
|
|
||||||
|| reason.hasPrefix("BufferDrain")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Probe 缓冲区内对象;若 filename+size 与上次不同则入队下载。
|
|
||||||
@discardableResult
|
|
||||||
private func attemptFetchPendingShot(reason: String) async -> Bool {
|
|
||||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { return false }
|
|
||||||
|
|
||||||
var localTransactionID = transactionID
|
|
||||||
guard let probe = await SonyPTPHelper.probeShotObject(
|
|
||||||
on: camera,
|
|
||||||
handle: SonyPTPCommand.shotObjectHandle,
|
|
||||||
transactionID: &localTransactionID
|
|
||||||
) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
transactionID = localTransactionID
|
|
||||||
|
|
||||||
let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size)
|
|
||||||
if signature == lastDownloadedObjectSignature {
|
|
||||||
CameraTetheringLogger.log("\(reason): 缓冲无新片 signature=\(signature)", logger: CameraTetheringLogger.ptp)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueueShotDownload(handle: SonyPTPCommand.shotObjectHandle, reason: reason)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startShotBufferPolling() {
|
|
||||||
stopShotBufferPolling()
|
|
||||||
CameraTetheringLogger.log("启动 D215 缓冲轮询 (连拍时 0.25s / 空闲 1.0s)", logger: CameraTetheringLogger.ptp)
|
|
||||||
refreshShotBufferPollInterval()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshShotBufferPollInterval() {
|
|
||||||
guard !isDisconnecting, isRemoteSessionReady else { return }
|
|
||||||
let interval = (isDrainingShotQueue || !shotDownloadQueue.isEmpty) ? 0.25 : 1.0
|
|
||||||
stopShotBufferPolling()
|
|
||||||
shotBufferPollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
||||||
guard let self, !self.isDisconnecting, self.isRemoteSessionReady else { return }
|
|
||||||
Task { await self.evaluateShotBufferFromProperties(reason: "D215Poll") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopShotBufferPolling() {
|
|
||||||
shotBufferPollTimer?.invalidate()
|
|
||||||
shotBufferPollTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 连拍时多个 PTP 事件会连续到达;入队串行处理,避免「下载进行中直接丢弃」。
|
|
||||||
private func enqueueShotDownload(handle: UInt32, reason: String) {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
shotDownloadQueue.append((handle: handle, reason: reason))
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"\(reason): 入队 PTP 下载 (队列=\(shotDownloadQueue.count))",
|
|
||||||
logger: CameraTetheringLogger.ptp
|
|
||||||
)
|
|
||||||
refreshShotBufferPollInterval()
|
|
||||||
startShotDownloadDrainIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startShotDownloadDrainIfNeeded() {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
guard !isDrainingShotQueue else { return }
|
|
||||||
guard !shotDownloadQueue.isEmpty else { return }
|
|
||||||
|
|
||||||
isDrainingShotQueue = true
|
|
||||||
shotDownloadDrainTask?.cancel()
|
|
||||||
shotDownloadDrainTask = Task { [weak self] in
|
|
||||||
await self?.drainShotDownloadQueue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drainShotDownloadQueue() async {
|
|
||||||
defer {
|
|
||||||
isDrainingShotQueue = false
|
|
||||||
shotDownloadDrainTask = nil
|
|
||||||
if !isDisconnecting {
|
|
||||||
refreshShotBufferPollInterval()
|
|
||||||
if !shotDownloadQueue.isEmpty {
|
|
||||||
startShotDownloadDrainIfNeeded()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while !shotDownloadQueue.isEmpty, !isDisconnecting {
|
|
||||||
let item = shotDownloadQueue.removeFirst()
|
|
||||||
_ = await downloadPTPObject(
|
|
||||||
handle: item.handle,
|
|
||||||
reason: item.reason,
|
|
||||||
maxAttempts: 8,
|
|
||||||
tryDirectGetObject: true
|
|
||||||
)
|
|
||||||
try? await Task.sleep(nanoseconds: 120_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
await drainRemainingBuffer(maxExtraDownloads: 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 连拍时部分事件可能在队列排空期间才写入缓冲;按 D215/Probe 继续拉取直到无新片。
|
|
||||||
private func drainRemainingBuffer(maxExtraDownloads: Int) async {
|
|
||||||
for round in 1...maxExtraDownloads {
|
|
||||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else { break }
|
|
||||||
|
|
||||||
var localTransactionID = transactionID
|
|
||||||
guard let probe = await SonyPTPHelper.probeShotObject(
|
|
||||||
on: camera,
|
|
||||||
handle: SonyPTPCommand.shotObjectHandle,
|
|
||||||
transactionID: &localTransactionID
|
|
||||||
) else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
transactionID = localTransactionID
|
|
||||||
|
|
||||||
let signature = SonyPTPHelper.shotObjectSignature(filename: probe.filename, size: probe.size)
|
|
||||||
if signature == lastDownloadedObjectSignature {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloaded = await downloadPTPObject(
|
|
||||||
handle: SonyPTPCommand.shotObjectHandle,
|
|
||||||
reason: "BufferDrain-\(round)",
|
|
||||||
maxAttempts: 6,
|
|
||||||
tryDirectGetObject: true
|
|
||||||
)
|
|
||||||
guard downloaded else { break }
|
|
||||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
private func downloadPTPObject(
|
|
||||||
handle: UInt32,
|
|
||||||
reason: String,
|
|
||||||
maxAttempts: Int = 3,
|
|
||||||
tryDirectGetObject: Bool = false
|
|
||||||
) async -> Bool {
|
|
||||||
guard !isDisconnecting, isRemoteSessionReady, let camera = cameraDevice else {
|
|
||||||
CameraTetheringLogger.log("\(reason): Sony 远程会话尚未就绪", logger: CameraTetheringLogger.ptp)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
guard !isDownloadingPTPObject else {
|
|
||||||
CameraTetheringLogger.log("\(reason): 下载重入,重新入队", logger: CameraTetheringLogger.ptp)
|
|
||||||
shotDownloadQueue.insert((handle: handle, reason: reason), at: 0)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
isDownloadingPTPObject = true
|
|
||||||
defer { isDownloadingPTPObject = false }
|
|
||||||
|
|
||||||
var localTransactionID = transactionID
|
|
||||||
|
|
||||||
for attempt in 1...maxAttempts {
|
|
||||||
guard !isDisconnecting else { return false }
|
|
||||||
if attempt > 1 {
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 350_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
var probeFilename = "DSC_\(Int(Date().timeIntervalSince1970)).JPG"
|
|
||||||
var probeSize: UInt32 = 0
|
|
||||||
|
|
||||||
if let probe = await SonyPTPHelper.probeShotObject(
|
|
||||||
on: camera,
|
|
||||||
handle: handle,
|
|
||||||
transactionID: &localTransactionID
|
|
||||||
) {
|
|
||||||
probeFilename = probe.filename
|
|
||||||
probeSize = probe.size
|
|
||||||
} else if !tryDirectGetObject {
|
|
||||||
if attempt == maxAttempts {
|
|
||||||
CameraTetheringLogger.log("\(reason): 相机缓冲区内暂无可用照片", logger: CameraTetheringLogger.ptp)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let signature: String
|
|
||||||
if probeSize > 0 {
|
|
||||||
signature = SonyPTPHelper.shotObjectSignature(filename: probeFilename, size: probeSize)
|
|
||||||
if signature == lastDownloadedObjectSignature {
|
|
||||||
CameraTetheringLogger.log("\(reason): 照片已下载过 \(probeFilename),跳过", logger: CameraTetheringLogger.ptp)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
signature = "\(probeFilename)_attempt\(attempt)_\(Int(Date().timeIntervalSince1970))"
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let result = await SonyPTPHelper.downloadObject(
|
|
||||||
handle: handle,
|
|
||||||
on: camera,
|
|
||||||
transactionID: &localTransactionID,
|
|
||||||
skipObjectInfo: tryDirectGetObject && probeSize == 0
|
|
||||||
) else {
|
|
||||||
if attempt == maxAttempts {
|
|
||||||
CameraTetheringLogger.log("\(reason): PTP 下载失败 handle=0x\(String(format: "%08X", handle))", logger: CameraTetheringLogger.ptp)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
transactionID = localTransactionID
|
|
||||||
lastDownloadedObjectSignature = probeSize > 0 ? signature : "\(result.filename)_\(result.data.count)"
|
|
||||||
|
|
||||||
let assetID = "ptp_\(handle)_\(result.filename)_\(result.data.count)"
|
|
||||||
let destination = CameraDownloadStorage.uniqueTemporaryURL(for: result.filename)
|
|
||||||
do {
|
|
||||||
try result.data.write(to: destination, options: .atomic)
|
|
||||||
} catch {
|
|
||||||
CameraTetheringLogger.log("PTP 文件写入失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let asset = CameraAsset(
|
|
||||||
id: assetID,
|
|
||||||
filename: result.filename,
|
|
||||||
fileSize: Int64(result.data.count),
|
|
||||||
creationDate: Date(),
|
|
||||||
isMovie: false
|
|
||||||
)
|
|
||||||
ptpAssetURLs[assetID] = destination
|
|
||||||
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"[\(reason)] PTP 下载完成: \(result.filename) -> \(destination.path)",
|
|
||||||
logger: CameraTetheringLogger.transfer
|
|
||||||
)
|
|
||||||
onPTPAssetReady?(asset, destination)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyBaselineOrPoll(reason: String) {
|
|
||||||
guard isCatalogReady else {
|
|
||||||
CameraTetheringLogger.log("\(reason): 目录尚未就绪,等待 catalog ready")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onPollTick?()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func notifyBaselineIfNeeded() {
|
|
||||||
guard !baselineNotified else { return }
|
|
||||||
baselineNotified = true
|
|
||||||
isCatalogReady = true
|
|
||||||
|
|
||||||
let fileCount = allFiles().count
|
|
||||||
CameraTetheringLogger.log("目录就绪,当前 \(fileCount) 个文件,建立基线")
|
|
||||||
onBaselineCatalogReady?()
|
|
||||||
startPolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startPolling() {
|
|
||||||
stopPolling()
|
|
||||||
CameraTetheringLogger.log("启动轮询检测新照片 (每 1.5s)")
|
|
||||||
|
|
||||||
pollTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
|
|
||||||
guard let self, self.isCatalogReady else { return }
|
|
||||||
self.onPollTick?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopPolling() {
|
|
||||||
pollTimer?.invalidate()
|
|
||||||
pollTimer = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ImageCaptureDeviceManager: ICDeviceBrowserDelegate {
|
|
||||||
@objc(deviceBrowser:didAddDevice:moreComing:)
|
|
||||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
|
||||||
CameraTetheringLogger.log("发现设备: \(device.name ?? "?"), type=\(device.type), transport=\(device.transportType ?? "?")")
|
|
||||||
|
|
||||||
guard let camera = device as? ICCameraDevice else {
|
|
||||||
CameraTetheringLogger.log("非相机设备,忽略")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard cameraDevice == nil else {
|
|
||||||
CameraTetheringLogger.log("已有连接中的相机,忽略")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAuthorizations { [weak self] contentsGranted, controlGranted in
|
|
||||||
guard let self else { return }
|
|
||||||
guard contentsGranted else {
|
|
||||||
let message = "未获得相机内容访问权限,请在系统设置中允许"
|
|
||||||
CameraTetheringLogger.log(message)
|
|
||||||
self.onError?(message)
|
|
||||||
self.updateState(.error("权限被拒绝"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !controlGranted {
|
|
||||||
CameraTetheringLogger.log("警告: 控制授权未通过,仍尝试连接(下载通常只需内容授权)")
|
|
||||||
}
|
|
||||||
self.openSession(for: camera)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
|
|
||||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
CameraTetheringLogger.log("设备移除: \(device.name ?? "?")")
|
|
||||||
if device.uuidString == cameraDevice?.uuidString {
|
|
||||||
Task { await disconnect() }
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ImageCaptureDeviceManager: ICDeviceDelegate {
|
|
||||||
func didRemove(_ device: ICDevice) {
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
CameraTetheringLogger.log("didRemove: \(device.name ?? "?")")
|
|
||||||
if device.uuidString == cameraDevice?.uuidString {
|
|
||||||
Task { await disconnect() }
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func device(_ device: ICDevice, didOpenSessionWithError error: Error?) {
|
|
||||||
if let error {
|
|
||||||
CameraTetheringLogger.log("会话打开失败: \(error.localizedDescription)")
|
|
||||||
onError?(error.localizedDescription)
|
|
||||||
updateState(.error(error.localizedDescription))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let camera = device as? ICCameraDevice else { return }
|
|
||||||
|
|
||||||
let name = camera.name ?? "Camera"
|
|
||||||
CameraTetheringLogger.log("会话已打开: \(name)")
|
|
||||||
updateState(.connected(deviceName: name))
|
|
||||||
configureCameraAfterSession(camera)
|
|
||||||
}
|
|
||||||
|
|
||||||
func device(_ device: ICDevice, didCloseSessionWithError error: Error?) {
|
|
||||||
if let error {
|
|
||||||
CameraTetheringLogger.log("会话关闭: \(error.localizedDescription)")
|
|
||||||
}
|
|
||||||
if device.uuidString == closingDeviceUUID {
|
|
||||||
finishSessionClose()
|
|
||||||
}
|
|
||||||
guard !isDisconnecting else { return }
|
|
||||||
if device.uuidString == cameraDevice?.uuidString {
|
|
||||||
cameraDevice = nil
|
|
||||||
isCatalogReady = false
|
|
||||||
baselineNotified = false
|
|
||||||
isRemoteSessionReady = false
|
|
||||||
stopPolling()
|
|
||||||
stopShotBufferPolling()
|
|
||||||
updateState(.disconnected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ImageCaptureDeviceManager: ICCameraDeviceDelegate {
|
|
||||||
@objc(cameraDevice:didAddItems:)
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didAdd items: [ICCameraItem]) {
|
|
||||||
let names = items.compactMap { ($0 as? ICCameraFile)?.name ?? $0.name }
|
|
||||||
CameraTetheringLogger.log("didAddItems: \(names.joined(separator: ", "))")
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
if let file = item as? ICCameraFile {
|
|
||||||
CameraTetheringLogger.log(" + \(file.name ?? "?") addedAfterCatalog=\(file.wasAddedAfterContentCatalogCompleted)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onNewItems?(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(cameraDevice:didRemoveItems:)
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didRemove items: [ICCameraItem]) {
|
|
||||||
let names = items.compactMap { $0.name }
|
|
||||||
CameraTetheringLogger.log("didRemoveItems: \(names.joined(separator: ", "))")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(cameraDevice:didRenameItems:)
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didRenameItems items: [ICCameraItem]) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didReceiveThumbnail thumbnail: CGImage?, for item: ICCameraItem, error: Error?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didReceiveMetadata metadata: [AnyHashable: Any]?, for item: ICCameraItem, error: Error?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
func deviceDidBecomeReady(withCompleteContentCatalog device: ICCameraDevice) {
|
|
||||||
CameraTetheringLogger.log("deviceDidBecomeReadyWithCompleteContentCatalog, 文件数=\(allFiles().count)")
|
|
||||||
notifyBaselineIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDeviceDidChangeCapability(_ camera: ICCameraDevice) {
|
|
||||||
CameraTetheringLogger.log("cameraDeviceDidChangeCapability")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDeviceDidRemoveAccessRestriction(_ device: ICDevice) {
|
|
||||||
CameraTetheringLogger.log("cameraDeviceDidRemoveAccessRestriction")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDeviceDidEnableAccessRestriction(_ device: ICDevice) {
|
|
||||||
CameraTetheringLogger.log("cameraDeviceDidEnableAccessRestriction")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cameraDevice(_ camera: ICCameraDevice, didReceivePTPEvent eventData: Data) {
|
|
||||||
handlePTPEvent(eventData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,195 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class SonyCameraService: CameraServiceProtocol {
|
|
||||||
let brand: CameraBrand = .sony
|
|
||||||
|
|
||||||
private(set) var connectionState: CameraConnectionState = .disconnected
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
|
|
||||||
private let deviceManager = ImageCaptureDeviceManager()
|
|
||||||
private var knownAssetIDs: Set<String> = []
|
|
||||||
private var baselineEstablished = false
|
|
||||||
private var pendingAssetsWhileDetached: [CameraAsset] = []
|
|
||||||
|
|
||||||
init() {
|
|
||||||
deviceManager.onConnectionStateChange = { [weak self] state in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.connectionState = state
|
|
||||||
self?.onConnectionStateChange?(state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onNewItems = { [weak self] items in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handleNewItems(items, source: "didAddItems")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onBaselineCatalogReady = { [weak self] in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.establishBaseline()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onPollTick = { [weak self] in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.scanForNewAssets(source: "poll")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onPTPAssetReady = { [weak self] asset, _ in
|
|
||||||
Task { @MainActor in
|
|
||||||
self?.handlePTPAsset(asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceManager.onError = { [weak self] message in
|
|
||||||
Task { @MainActor in
|
|
||||||
CameraTetheringLogger.log("相机错误: \(message)")
|
|
||||||
self?.connectionState = .error(message)
|
|
||||||
self?.onConnectionStateChange?(.error(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func connect() async {
|
|
||||||
CameraTetheringLogger.log("SonyCameraService.connect()")
|
|
||||||
let shouldResetBaseline = !deviceManager.isBrowsing
|
|
||||||
deviceManager.startBrowsing()
|
|
||||||
if shouldResetBaseline {
|
|
||||||
knownAssetIDs.removeAll()
|
|
||||||
baselineEstablished = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 由 AutoDetect 在已完成授权后直接挂载设备。
|
|
||||||
func attach(to camera: ICCameraDevice) async {
|
|
||||||
CameraTetheringLogger.log("SonyCameraService.attach()")
|
|
||||||
knownAssetIDs.removeAll()
|
|
||||||
baselineEstablished = false
|
|
||||||
pendingAssetsWhileDetached.removeAll()
|
|
||||||
deviceManager.attach(to: camera)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 页面离开:仅清空 UI 回调,底层 PTP 会话与轮询继续。
|
|
||||||
func detachFromUI() {
|
|
||||||
CameraTetheringLogger.log("SonyCameraService.detachFromUI()")
|
|
||||||
onConnectionStateChange = nil
|
|
||||||
onNewAsset = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 再次进入页面:补发 detach 期间缓冲的新照片。
|
|
||||||
func reattachToUI() {
|
|
||||||
guard !pendingAssetsWhileDetached.isEmpty else { return }
|
|
||||||
let pending = pendingAssetsWhileDetached
|
|
||||||
pendingAssetsWhileDetached.removeAll()
|
|
||||||
CameraTetheringLogger.log("SonyCameraService.reattachToUI: 补发 \(pending.count) 张离线新片")
|
|
||||||
for asset in pending {
|
|
||||||
onNewAsset?(asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func shutdown() async {
|
|
||||||
CameraTetheringLogger.log("SonyCameraService.shutdown()")
|
|
||||||
await deviceManager.stopBrowsing()
|
|
||||||
knownAssetIDs.removeAll()
|
|
||||||
baselineEstablished = false
|
|
||||||
pendingAssetsWhileDetached.removeAll()
|
|
||||||
connectionState = .disconnected
|
|
||||||
onConnectionStateChange?(.disconnected)
|
|
||||||
}
|
|
||||||
|
|
||||||
func listAssets() async throws -> [CameraAsset] {
|
|
||||||
guard connectionState.isConnected else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
let assets = deviceManager.allFiles().map { deviceManager.makeAsset(from: $0) }
|
|
||||||
CameraTetheringLogger.log("listAssets: \(assets.count) 个文件")
|
|
||||||
return assets
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
guard connectionState.isConnected else {
|
|
||||||
throw CameraServiceError.notConnected
|
|
||||||
}
|
|
||||||
if let cachedURL = deviceManager.localURL(forAssetID: asset.id) {
|
|
||||||
CameraTetheringLogger.log("downloadAsset: 返回 PTP 缓存 \(asset.filename)", logger: CameraTetheringLogger.transfer)
|
|
||||||
return cachedURL
|
|
||||||
}
|
|
||||||
guard let file = deviceManager.file(forAssetID: asset.id) else {
|
|
||||||
CameraTetheringLogger.log("downloadAsset: 找不到文件 \(asset.filename) id=\(asset.id)", logger: CameraTetheringLogger.transfer)
|
|
||||||
throw CameraServiceError.assetNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
let directory = CameraDownloadStorage.temporaryDownloadsDirectory
|
|
||||||
return try await deviceManager.download(file: file, to: directory)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePTPAsset(_ asset: CameraAsset) {
|
|
||||||
deliverNewAsset(asset, source: "PTP")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 连接后首次目录就绪:已有照片记入基线;拍摄于枚举期间的新照片立即传输。
|
|
||||||
private func establishBaseline() {
|
|
||||||
guard !baselineEstablished else { return }
|
|
||||||
baselineEstablished = true
|
|
||||||
|
|
||||||
let files = deviceManager.allFiles()
|
|
||||||
var skipped = 0
|
|
||||||
var newCount = 0
|
|
||||||
|
|
||||||
for file in files {
|
|
||||||
if file.wasAddedAfterContentCatalogCompleted {
|
|
||||||
emitAssetIfNew(file: file, source: "baseline-new")
|
|
||||||
newCount += 1
|
|
||||||
} else {
|
|
||||||
knownAssetIDs.insert(deviceManager.assetIdentifier(for: file))
|
|
||||||
skipped += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
CameraTetheringLogger.log("基线已建立: 跳过已有 \(skipped) 张, 立即传输新拍 \(newCount) 张")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleNewItems(_ items: [ICCameraItem], source: String) {
|
|
||||||
guard baselineEstablished else {
|
|
||||||
CameraTetheringLogger.log("\(source): 基线未建立,暂存处理")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for item in items {
|
|
||||||
guard let file = item as? ICCameraFile else { continue }
|
|
||||||
emitAssetIfNew(file: file, source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scanForNewAssets(source: String) {
|
|
||||||
guard baselineEstablished else { return }
|
|
||||||
|
|
||||||
let files = deviceManager.allFiles()
|
|
||||||
for file in files {
|
|
||||||
emitAssetIfNew(file: file, source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emitAssetIfNew(file: ICCameraFile, source: String) {
|
|
||||||
let asset = deviceManager.makeAsset(from: file)
|
|
||||||
deliverNewAsset(asset, source: source)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func deliverNewAsset(_ asset: CameraAsset, source: String) {
|
|
||||||
guard !knownAssetIDs.contains(asset.id) else { return }
|
|
||||||
knownAssetIDs.insert(asset.id)
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"[\(source)] 发现新照片: \(asset.filename) id=\(asset.id) size=\(asset.fileSize)",
|
|
||||||
logger: CameraTetheringLogger.transfer
|
|
||||||
)
|
|
||||||
if let onNewAsset {
|
|
||||||
onNewAsset(asset)
|
|
||||||
} else {
|
|
||||||
pendingAssetsWhileDetached.append(asset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,444 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
enum SonyPTPCommand {
|
|
||||||
static let getDeviceInfo: UInt16 = 0x1001
|
|
||||||
static let openSession: UInt16 = 0x1002
|
|
||||||
static let closeSession: UInt16 = 0x1003
|
|
||||||
static let getObjectHandles: UInt16 = 0x1007
|
|
||||||
static let getObjectInfo: UInt16 = 0x1008
|
|
||||||
static let getObject: UInt16 = 0x1009
|
|
||||||
static let objectAdded: UInt16 = 0x4002
|
|
||||||
static let sdieObjectAdded: UInt16 = 0xC201
|
|
||||||
static let sdieObjectRemoved: UInt16 = 0xC202
|
|
||||||
static let sdieDevicePropChanged: UInt16 = 0xC203
|
|
||||||
static let sdieCapturedEvent: UInt16 = 0xC206
|
|
||||||
static let sdioConnect: UInt16 = 0x9201
|
|
||||||
static let sdioGetExtDeviceInfo: UInt16 = 0x9202
|
|
||||||
static let sdioSetExtDevicePropValue: UInt16 = 0x9205
|
|
||||||
static let sdioGetAllExtDevicePropInfo: UInt16 = 0x9209
|
|
||||||
static let positionKeySetting: UInt16 = 0xD25A
|
|
||||||
static let stillImageSaveDestination: UInt16 = 0xD222
|
|
||||||
static let stillImageTransSize: UInt16 = 0xD268
|
|
||||||
static let shootingFileInfo: UInt16 = 0xD215
|
|
||||||
static let shotObjectHandle: UInt32 = 0xFFFF_C001
|
|
||||||
static let ptpResponseOk: UInt16 = PTPResponseCode.ok.rawValue
|
|
||||||
static let saveDestinationHostPC: UInt16 = 1
|
|
||||||
static let saveDestinationHostAndCamera: UInt16 = 2
|
|
||||||
static let shotBufferReadyThreshold: Int64 = 0x8000
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum PTPDataType: UInt16 {
|
|
||||||
case int8 = 0x0001
|
|
||||||
case uint8 = 0x0002
|
|
||||||
case int16 = 0x0003
|
|
||||||
case uint16 = 0x0004
|
|
||||||
case int32 = 0x0005
|
|
||||||
case uint32 = 0x0006
|
|
||||||
case int64 = 0x0007
|
|
||||||
case uint64 = 0x0008
|
|
||||||
case string = 0xFFFF
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SonyPTPHelper {
|
|
||||||
static func buildCommand(
|
|
||||||
code: UInt16,
|
|
||||||
transactionID: UInt32,
|
|
||||||
parameters: [UInt32] = []
|
|
||||||
) -> Data {
|
|
||||||
PTPHelper.buildCommand(code: code, transactionID: transactionID, parameters: parameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseContainer(_ data: Data) throws -> PTPContainer {
|
|
||||||
try PTPHelper.parseContainer(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseEventCode(from eventData: Data) -> UInt16? {
|
|
||||||
PTPHelper.parseEventCode(from: eventData)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
|
|
||||||
PTPHelper.parseObjectCompressedSize(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseObjectInfoFilename(_ data: Data) -> String? {
|
|
||||||
PTPHelper.parseObjectInfoFilename(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parsePTPString(_ data: Data, offset start: Int) -> (string: String, nextOffset: Int)? {
|
|
||||||
PTPHelper.parsePTPString(data, offset: start)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readUInt16LE(_ data: Data, offset: Int) -> UInt16 {
|
|
||||||
PTPHelper.readUInt16LE(data, offset: offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readUInt32LE(_ data: Data, offset: Int) -> UInt32 {
|
|
||||||
PTPHelper.readUInt32LE(data, offset: offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ImageCaptureCore 已建立 PTP 会话,此处执行 Sony SDIO 握手并进入 PC Remote 模式。
|
|
||||||
static func initializeRemoteSession(on camera: ICCameraDevice, startingTransactionID: UInt32) async -> UInt32 {
|
|
||||||
var transactionID = startingTransactionID
|
|
||||||
|
|
||||||
CameraTetheringLogger.log("开始 Sony SDIO 握手", logger: CameraTetheringLogger.ptp)
|
|
||||||
|
|
||||||
let step1 = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.sdioConnect,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [1, 0, 0],
|
|
||||||
outData: nil,
|
|
||||||
label: "SDIO Connect(1)"
|
|
||||||
)
|
|
||||||
guard step1.success else { return transactionID }
|
|
||||||
|
|
||||||
let step2 = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.sdioConnect,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [2, 0, 0],
|
|
||||||
outData: nil,
|
|
||||||
label: "SDIO Connect(2)"
|
|
||||||
)
|
|
||||||
guard step2.success else { return transactionID }
|
|
||||||
|
|
||||||
let step3 = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.sdioGetExtDeviceInfo,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [0x012C, 1],
|
|
||||||
outData: nil,
|
|
||||||
label: "SDIO GetExtDeviceInfo"
|
|
||||||
)
|
|
||||||
guard step3.success else { return transactionID }
|
|
||||||
|
|
||||||
let step4 = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.sdioConnect,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [3, 0, 0],
|
|
||||||
outData: nil,
|
|
||||||
label: "SDIO Connect(3)"
|
|
||||||
)
|
|
||||||
guard step4.success else { return transactionID }
|
|
||||||
|
|
||||||
_ = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.sdioGetAllExtDevicePropInfo,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [1],
|
|
||||||
outData: nil,
|
|
||||||
label: "SDIO GetAllExtDevicePropInfo"
|
|
||||||
)
|
|
||||||
|
|
||||||
await configureRemoteCapture(on: camera, transactionID: &transactionID)
|
|
||||||
CameraTetheringLogger.log("Sony SDIO 握手完成", logger: CameraTetheringLogger.ptp)
|
|
||||||
return transactionID
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 关闭 Sony PC Remote 并释放 PTP 会话。
|
|
||||||
static func teardownRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
|
||||||
_ = await setDeviceProperty(
|
|
||||||
on: camera,
|
|
||||||
propertyCode: SonyPTPCommand.positionKeySetting,
|
|
||||||
value: 0,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
label: "Position_Key_Setting=Local"
|
|
||||||
)
|
|
||||||
await closeRemoteSession(on: camera, transactionID: &transactionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 关闭 PTP 远程会话,释放相机 PC Remote 占用。
|
|
||||||
static func closeRemoteSession(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
|
||||||
_ = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.closeSession,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [],
|
|
||||||
outData: nil,
|
|
||||||
label: "CloseSession"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func probeShotObject(
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
handle: UInt32 = SonyPTPCommand.shotObjectHandle,
|
|
||||||
transactionID: inout UInt32
|
|
||||||
) async -> (filename: String, size: UInt32)? {
|
|
||||||
let infoResult = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.getObjectInfo,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [handle],
|
|
||||||
outData: nil,
|
|
||||||
label: "ProbeObjectInfo(0x\(String(format: "%08X", handle)))"
|
|
||||||
)
|
|
||||||
guard infoResult.success else { return nil }
|
|
||||||
|
|
||||||
guard let size = parseObjectCompressedSize(infoResult.inData), size > 0 else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
let filename = parseObjectInfoFilename(infoResult.inData)
|
|
||||||
?? CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG")
|
|
||||||
return (filename, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func downloadObject(
|
|
||||||
handle: UInt32,
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
transactionID: inout UInt32,
|
|
||||||
skipObjectInfo: Bool = false
|
|
||||||
) async -> (filename: String, data: Data)? {
|
|
||||||
var filename = CameraDownloadStorage.sanitizeFilename("DSC_\(Int(Date().timeIntervalSince1970)).JPG")
|
|
||||||
|
|
||||||
if !skipObjectInfo {
|
|
||||||
let infoResult = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.getObjectInfo,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: [handle],
|
|
||||||
outData: nil,
|
|
||||||
label: "GetObjectInfo(0x\(String(format: "%08X", handle)))"
|
|
||||||
)
|
|
||||||
if infoResult.success, let parsedName = parseObjectInfoFilename(infoResult.inData) {
|
|
||||||
filename = parsedName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let objectResult = await sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: SonyPTPCommand.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 (filename, objectResult.inData)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isLikelyImageData(_ data: Data) -> Bool {
|
|
||||||
PTPHelper.isLikelyImageData(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isResponseOK(_ response: Data) -> Bool {
|
|
||||||
PTPHelper.isResponseOK(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = readUInt16LE(data, offset: offset)
|
|
||||||
offset += 2
|
|
||||||
let dataType = readUInt16LE(data, offset: offset)
|
|
||||||
offset += 2
|
|
||||||
offset += 2 // getset, isenabled
|
|
||||||
|
|
||||||
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
|
|
||||||
guard skipFormPayload(formFlag: formFlag, dataType: dataType, data: data, offset: &offset) else { break }
|
|
||||||
}
|
|
||||||
|
|
||||||
return properties
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isShotBufferReady(_ shootingFileInfo: Int64?) -> Bool {
|
|
||||||
guard let shootingFileInfo else { return false }
|
|
||||||
return shootingFileInfo > SonyPTPCommand.shotBufferReadyThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
static func shotObjectSignature(filename: String, size: UInt32) -> String {
|
|
||||||
"\(filename)_\(size)"
|
|
||||||
}
|
|
||||||
|
|
||||||
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: readUInt16LE(data, offset: offset)))
|
|
||||||
offset += 2
|
|
||||||
return value
|
|
||||||
case .uint16:
|
|
||||||
guard offset + 1 < data.count else { return nil }
|
|
||||||
let value = Int64(readUInt16LE(data, offset: offset))
|
|
||||||
offset += 2
|
|
||||||
return value
|
|
||||||
case .int32:
|
|
||||||
guard offset + 3 < data.count else { return nil }
|
|
||||||
let value = Int64(Int32(bitPattern: readUInt32LE(data, offset: offset)))
|
|
||||||
offset += 4
|
|
||||||
return value
|
|
||||||
case .uint32:
|
|
||||||
guard offset + 3 < data.count else { return nil }
|
|
||||||
let value = Int64(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(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(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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func configureRemoteCapture(on camera: ICCameraDevice, transactionID: inout UInt32) async {
|
|
||||||
_ = await setDeviceProperty(
|
|
||||||
on: camera,
|
|
||||||
propertyCode: SonyPTPCommand.positionKeySetting,
|
|
||||||
value: 1,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
label: "Position_Key_Setting=PC Remote"
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = await setDeviceProperty(
|
|
||||||
on: camera,
|
|
||||||
propertyCode: SonyPTPCommand.stillImageSaveDestination,
|
|
||||||
value: SonyPTPCommand.saveDestinationHostPC,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
label: "Still_Image_Save_Destination=Host PC"
|
|
||||||
)
|
|
||||||
|
|
||||||
_ = await setDevicePropertyUInt32(
|
|
||||||
on: camera,
|
|
||||||
propertyCode: SonyPTPCommand.stillImageTransSize,
|
|
||||||
value: 0xFFFF_FFFF,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
label: "Still_Image_Trans_Size=Full"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 sendCommand(
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
code: UInt16,
|
|
||||||
transactionID: inout UInt32,
|
|
||||||
parameters: [UInt32],
|
|
||||||
outData: Data?,
|
|
||||||
label: String
|
|
||||||
) async -> PTPCommandResult {
|
|
||||||
await PTPHelper.sendCommand(
|
|
||||||
on: camera,
|
|
||||||
code: code,
|
|
||||||
transactionID: &transactionID,
|
|
||||||
parameters: parameters,
|
|
||||||
outData: outData,
|
|
||||||
label: label
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import CoreGraphics
|
|
||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// ImageCaptureCore 文件下载回调代理。
|
|
||||||
final class CameraDownloadDelegate: NSObject, ICCameraDeviceDownloadDelegate {
|
|
||||||
var onComplete: ((ICCameraFile, URL?, Error?) -> Void)?
|
|
||||||
var onProgress: ((String, Double) -> Void)?
|
|
||||||
|
|
||||||
@objc func didDownloadFile(_ file: ICCameraFile, error: Error?, options: [String: Any], contextInfo: UnsafeMutableRawPointer?) {
|
|
||||||
if let error {
|
|
||||||
CameraTetheringLogger.log("下载失败 \(file.name ?? "?"): \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
|
|
||||||
onComplete?(file, nil, error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let downloadOptions = options as? [ICDownloadOption: Any] ?? [:]
|
|
||||||
if let directory = downloadOptions[.downloadsDirectoryURL] as? URL,
|
|
||||||
let filename = downloadOptions[.savedFilename] as? String {
|
|
||||||
let url = directory.appendingPathComponent(filename)
|
|
||||||
CameraTetheringLogger.log("下载完成 \(filename) -> \(url.path)", logger: CameraTetheringLogger.transfer)
|
|
||||||
onComplete?(file, url, nil)
|
|
||||||
} else if let urlString = file.value(forKey: "url") as? String {
|
|
||||||
let url = URL(fileURLWithPath: urlString)
|
|
||||||
CameraTetheringLogger.log("下载完成 \(file.name ?? "?") -> \(url.path)", logger: CameraTetheringLogger.transfer)
|
|
||||||
onComplete?(file, url, nil)
|
|
||||||
} else {
|
|
||||||
CameraTetheringLogger.log("下载完成但无法定位路径: \(file.name ?? "?")", logger: CameraTetheringLogger.transfer)
|
|
||||||
onComplete?(file, nil, CameraServiceError.downloadFailed("无法定位下载文件路径"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc func didReceiveDownloadProgress(for file: ICCameraFile, downloadedBytes: off_t, maxBytes: off_t) {
|
|
||||||
guard maxBytes > 0, let name = file.name else { return }
|
|
||||||
onProgress?(name, Double(downloadedBytes) / Double(maxBytes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
//
|
|
||||||
// CameraTetheringLogger.swift
|
|
||||||
// suixinkan
|
|
||||||
//
|
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import os
|
|
||||||
|
|
||||||
/// 相机有线传输模块日志工具,按 Camera / Transfer / PTP 分类输出。
|
|
||||||
enum CameraTetheringLogger {
|
|
||||||
/// 控制台统一前缀,便于 Xcode / Console.app 过滤:`[OTG]`
|
|
||||||
static let logPrefix = "[OTG]"
|
|
||||||
|
|
||||||
private static let subsystem = Bundle.main.bundleIdentifier ?? "com.suixinkan"
|
|
||||||
|
|
||||||
static let camera = Logger(subsystem: subsystem, category: "Camera")
|
|
||||||
static let transfer = Logger(subsystem: subsystem, category: "Transfer")
|
|
||||||
static let ptp = Logger(subsystem: subsystem, category: "PTP")
|
|
||||||
|
|
||||||
/// 写入指定分类的 info 日志,消息前自动附加 `[OTG]` 前缀。
|
|
||||||
static func log(_ message: String, logger: Logger = camera) {
|
|
||||||
let formatted = prefixed(message)
|
|
||||||
logger.info("\(formatted, privacy: .public)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 为消息附加 OTG 统一前缀;若已含前缀则不再重复添加。
|
|
||||||
static func prefixed(_ message: String) -> String {
|
|
||||||
if message.hasPrefix(logPrefix) {
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
return "\(logPrefix) \(message)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,231 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// PTP 容器类型。
|
|
||||||
enum PTPContainerType: UInt16 {
|
|
||||||
case command = 0x0001
|
|
||||||
case data = 0x0002
|
|
||||||
case response = 0x0003
|
|
||||||
case event = 0x0004
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PTP 命令/响应/事件容器。
|
|
||||||
struct PTPContainer {
|
|
||||||
let length: UInt32
|
|
||||||
let type: UInt16
|
|
||||||
let code: UInt16
|
|
||||||
let transactionID: UInt32
|
|
||||||
let params: [UInt32]
|
|
||||||
}
|
|
||||||
|
|
||||||
enum PTPParseError: Error {
|
|
||||||
case tooShort
|
|
||||||
case invalidLength
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 标准 PTP 响应码。
|
|
||||||
enum PTPResponseCode: UInt16 {
|
|
||||||
case ok = 0x2001
|
|
||||||
case generalError = 0x2002
|
|
||||||
case sessionNotOpen = 0x2003
|
|
||||||
case invalidTransaction = 0x2004
|
|
||||||
case operationNotSupported = 0x2005
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PTP 命令发送结果。
|
|
||||||
struct PTPCommandResult {
|
|
||||||
let success: Bool
|
|
||||||
let response: Data
|
|
||||||
let inData: Data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 各品牌共用的 PTP 编解码与命令发送工具。
|
|
||||||
enum PTPHelper {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseEventCode(from eventData: Data) -> UInt16? {
|
|
||||||
guard let container = try? parseContainer(eventData) else { return nil }
|
|
||||||
return container.code
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseObjectCompressedSize(_ data: Data) -> UInt32? {
|
|
||||||
guard data.count >= 12 else { return nil }
|
|
||||||
return readUInt32LE(data, offset: 8)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func parseObjectInfoFilename(_ data: Data) -> String? {
|
|
||||||
guard data.count >= 53 else { return nil }
|
|
||||||
|
|
||||||
if let filename = parsePTPString(data, offset: 52)?.string, !filename.isEmpty {
|
|
||||||
return CameraDownloadStorage.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 CameraDownloadStorage.sanitizeFilename(raw)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
codeUnits.append(readUInt16LE(data, offset: offset + index * 2))
|
|
||||||
}
|
|
||||||
offset += charCount * 2
|
|
||||||
|
|
||||||
let string = String(utf16CodeUnits: codeUnits, count: codeUnits.count)
|
|
||||||
return (string, offset)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isResponseOK(_ response: Data) -> Bool {
|
|
||||||
guard !response.isEmpty else { return true }
|
|
||||||
guard let parsed = try? parseContainer(response) else { return false }
|
|
||||||
return parsed.code == PTPResponseCode.ok.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通过 ImageCaptureCore 发送 PTP 命令。
|
|
||||||
static func sendCommand(
|
|
||||||
on camera: ICCameraDevice,
|
|
||||||
code: UInt16,
|
|
||||||
transactionID: inout UInt32,
|
|
||||||
parameters: [UInt32],
|
|
||||||
outData: Data?,
|
|
||||||
label: String
|
|
||||||
) async -> PTPCommandResult {
|
|
||||||
let currentID = transactionID
|
|
||||||
transactionID += 1
|
|
||||||
|
|
||||||
let commandData = buildCommand(
|
|
||||||
code: code,
|
|
||||||
transactionID: currentID,
|
|
||||||
parameters: parameters
|
|
||||||
)
|
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
|
||||||
camera.requestSendPTPCommand(
|
|
||||||
commandData,
|
|
||||||
outData: outData,
|
|
||||||
completion: { inData, response, error in
|
|
||||||
if let error {
|
|
||||||
CameraTetheringLogger.log("PTP \(label) 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.ptp)
|
|
||||||
continuation.resume(returning: PTPCommandResult(success: false, response: response, inData: inData))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var commandSuccess = true
|
|
||||||
if !response.isEmpty {
|
|
||||||
do {
|
|
||||||
let parsed = try parseContainer(response)
|
|
||||||
if parsed.code != PTPResponseCode.ok.rawValue {
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"PTP \(label) 响应码: 0x\(String(format: "%04X", parsed.code)) (非 OK)",
|
|
||||||
logger: CameraTetheringLogger.ptp
|
|
||||||
)
|
|
||||||
commandSuccess = false
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
CameraTetheringLogger.log("PTP \(label) 响应解析失败", logger: CameraTetheringLogger.ptp)
|
|
||||||
commandSuccess = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if commandSuccess {
|
|
||||||
CameraTetheringLogger.log(
|
|
||||||
"PTP \(label) 成功, inData=\(inData.count) bytes",
|
|
||||||
logger: CameraTetheringLogger.ptp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
continuation.resume(returning: PTPCommandResult(success: commandSuccess, response: response, inData: inData))
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// 进程内共享 USB Browser,避免页面离开后 stop Browser 导致 iOS 无法再次枚举仍连接的相机。
|
|
||||||
enum SharedUSBCameraBrowserHub {
|
|
||||||
static let controller = USBDeviceBrowserController()
|
|
||||||
}
|
|
||||||
@ -1,393 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ImageCaptureCore
|
|
||||||
|
|
||||||
/// 记录最近一次 USB 相机会话断开,用于再次进入页面时触发 Browser 热重启重新枚举。
|
|
||||||
enum USBCameraReconnectHint {
|
|
||||||
private(set) static var lastDisconnectedAt: Date?
|
|
||||||
|
|
||||||
/// 标记 USB 相机已断开,供下次 start 判断是否需热重启。
|
|
||||||
static func markDisconnected() {
|
|
||||||
lastDisconnectedAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 近期是否刚断开(60s 内),需要主动 re-enumerate。
|
|
||||||
static var shouldReEnumerate: Bool {
|
|
||||||
guard let lastDisconnectedAt else { return false }
|
|
||||||
return Date().timeIntervalSince(lastDisconnectedAt) < 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 非 MainActor 的 USB 设备浏览器。
|
|
||||||
/// ICDeviceBrowserDelegate 不应挂在 @MainActor 类型上,否则部分系统版本上 didAdd 可能永不触发。
|
|
||||||
final class USBDeviceBrowserController: NSObject {
|
|
||||||
var onCameraDiscovered: ((ICCameraDevice, String) -> Void)?
|
|
||||||
var onCameraRemoved: ((ICDevice) -> Void)?
|
|
||||||
var onAuthorizationDenied: (() -> Void)?
|
|
||||||
var onScanTimeout: (() -> Void)?
|
|
||||||
|
|
||||||
private let browser = ICDeviceBrowser()
|
|
||||||
private var pollTimer: Timer?
|
|
||||||
private var scanTimeoutTask: Task<Void, Never>?
|
|
||||||
private var notifiedUUIDs: Set<String> = []
|
|
||||||
private var pollTick = 0
|
|
||||||
private var isAuthorized = false
|
|
||||||
private var emptyPollStreak = 0
|
|
||||||
private var isRestarting = false
|
|
||||||
private var isStopped = false
|
|
||||||
private var prefersCombinedBrowseMask = true
|
|
||||||
private var isReEnumerating = false
|
|
||||||
|
|
||||||
/// Browser 是否仍在运行(未 stop)。
|
|
||||||
var isSessionBrowsing: Bool {
|
|
||||||
browser.isBrowsing && !isStopped
|
|
||||||
}
|
|
||||||
|
|
||||||
override init() {
|
|
||||||
super.init()
|
|
||||||
browser.delegate = self
|
|
||||||
configureBrowseMask()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 请求授权并启动设备搜索;若 Browser 已在运行则直接扫描已连接设备。
|
|
||||||
func start() async -> Bool {
|
|
||||||
isStopped = false
|
|
||||||
browser.delegate = self
|
|
||||||
|
|
||||||
if browser.isBrowsing {
|
|
||||||
log("Browser 已在运行,扫描已连接 USB 相机…")
|
|
||||||
prepareForNewSession()
|
|
||||||
startScanTimeout()
|
|
||||||
let found = await waitForDiscovery(maxAttempts: 8, intervalNanoseconds: 300_000_000)
|
|
||||||
if !found, USBCameraReconnectHint.shouldReEnumerate {
|
|
||||||
await reEnumerateConnectedDevices(reason: "再次进入")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
stopPolling()
|
|
||||||
scanTimeoutTask?.cancel()
|
|
||||||
pollTick = 0
|
|
||||||
emptyPollStreak = 0
|
|
||||||
notifiedUUIDs.removeAll()
|
|
||||||
|
|
||||||
if !isAuthorized {
|
|
||||||
let granted = await requestAuthorizations(on: browser)
|
|
||||||
guard granted else {
|
|
||||||
onAuthorizationDenied?()
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
isAuthorized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
await beginBrowsing(label: "首次启动")
|
|
||||||
startPolling()
|
|
||||||
startScanTimeout()
|
|
||||||
|
|
||||||
let foundImmediately = await waitForDiscovery(maxAttempts: 4, intervalNanoseconds: 300_000_000)
|
|
||||||
if !foundImmediately {
|
|
||||||
await reEnumerateConnectedDevices(reason: "首次空结果")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 页面离开:保持 Browser 运行,仅重置发现状态供下次 attach。
|
|
||||||
func pauseForSessionEnd() {
|
|
||||||
guard !isStopped else { return }
|
|
||||||
scanTimeoutTask?.cancel()
|
|
||||||
scanTimeoutTask = nil
|
|
||||||
notifiedUUIDs.removeAll()
|
|
||||||
emptyPollStreak = 0
|
|
||||||
pollTick = 0
|
|
||||||
isReEnumerating = false
|
|
||||||
isRestarting = false
|
|
||||||
log("会话结束,Browser 保持运行等待再次 attach")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 立即扫描 browser.devices,用于 Browser 已运行时的再次进入。
|
|
||||||
func discoverAlreadyConnectedDevices() {
|
|
||||||
guard !isStopped, browser.isBrowsing else { return }
|
|
||||||
pollDiscoveredDevices()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func prepareForNewSession() {
|
|
||||||
notifiedUUIDs.removeAll()
|
|
||||||
emptyPollStreak = 0
|
|
||||||
pollTick = 0
|
|
||||||
if pollTimer == nil {
|
|
||||||
startPolling()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 在未发现设备时重启 Browser(热插拔 / 用户手动触发)。
|
|
||||||
func restart() async {
|
|
||||||
guard !isRestarting, !isReEnumerating else { return }
|
|
||||||
isRestarting = true
|
|
||||||
defer { isRestarting = false }
|
|
||||||
|
|
||||||
guard isAuthorized else {
|
|
||||||
_ = await start()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log("手动/自动重启 Browser 搜索…")
|
|
||||||
if browser.isBrowsing {
|
|
||||||
browser.stop()
|
|
||||||
}
|
|
||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
notifiedUUIDs.removeAll()
|
|
||||||
pollTick = 0
|
|
||||||
emptyPollStreak = 0
|
|
||||||
scanTimeoutTask?.cancel()
|
|
||||||
toggleBrowseMask()
|
|
||||||
await beginBrowsing(label: "重启")
|
|
||||||
startScanTimeout()
|
|
||||||
_ = await waitForDiscovery(maxAttempts: 6, intervalNanoseconds: 300_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 停止搜索。
|
|
||||||
func stop() {
|
|
||||||
isStopped = true
|
|
||||||
stopPolling()
|
|
||||||
scanTimeoutTask?.cancel()
|
|
||||||
scanTimeoutTask = nil
|
|
||||||
pollTick = 0
|
|
||||||
emptyPollStreak = 0
|
|
||||||
if browser.isBrowsing {
|
|
||||||
browser.stop()
|
|
||||||
}
|
|
||||||
browser.delegate = nil
|
|
||||||
notifiedUUIDs.removeAll()
|
|
||||||
isAuthorized = false
|
|
||||||
isRestarting = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试用:当前是否已缓存 USB 授权结果。
|
|
||||||
var isAuthorizationGrantedForTesting: Bool { isAuthorized }
|
|
||||||
/// 测试用:Browser 是否已停止。
|
|
||||||
var isStoppedForTesting: Bool { isStopped }
|
|
||||||
|
|
||||||
func resetNotifiedDevices() {
|
|
||||||
notifiedUUIDs.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func configureBrowseMask() {
|
|
||||||
applyBrowseMask(combined: prefersCombinedBrowseMask)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 切换 Camera|LocalUSB 与仅 Camera 掩码,提高 re-enumerate 成功率。
|
|
||||||
private func toggleBrowseMask() {
|
|
||||||
prefersCombinedBrowseMask.toggle()
|
|
||||||
applyBrowseMask(combined: prefersCombinedBrowseMask)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func applyBrowseMask(combined: Bool) {
|
|
||||||
guard #available(iOS 15.2, *) else { return }
|
|
||||||
let mask: ICDeviceTypeMask
|
|
||||||
if combined {
|
|
||||||
mask = ICDeviceTypeMask(
|
|
||||||
rawValue: ICDeviceTypeMask.camera.rawValue | ICDeviceLocationTypeMask.local.rawValue
|
|
||||||
) ?? .camera
|
|
||||||
log("browsedDeviceTypeMask=0x\(String(mask.rawValue, radix: 16)) (Camera|LocalUSB)")
|
|
||||||
} else {
|
|
||||||
mask = .camera
|
|
||||||
log("browsedDeviceTypeMask=0x\(String(mask.rawValue, radix: 16)) (CameraOnly)")
|
|
||||||
}
|
|
||||||
browser.browsedDeviceTypeMask = mask
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 轮询等待 didAdd / devices 列表出现相机。
|
|
||||||
private func waitForDiscovery(maxAttempts: Int, intervalNanoseconds: UInt64) async -> Bool {
|
|
||||||
for _ in 0 ..< maxAttempts {
|
|
||||||
guard !isStopped else { return !notifiedUUIDs.isEmpty }
|
|
||||||
try? await Task.sleep(nanoseconds: intervalNanoseconds)
|
|
||||||
pollDiscoveredDevices()
|
|
||||||
if !notifiedUUIDs.isEmpty { return true }
|
|
||||||
}
|
|
||||||
return !notifiedUUIDs.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 会话关闭后 iOS 常不触发 didAdd;stop → wait → start 强制重新枚举仍连接的 USB 相机。
|
|
||||||
private func reEnumerateConnectedDevices(reason: String) async {
|
|
||||||
guard !isStopped, notifiedUUIDs.isEmpty, !isReEnumerating else { return }
|
|
||||||
isReEnumerating = true
|
|
||||||
defer { isReEnumerating = false }
|
|
||||||
|
|
||||||
for attempt in 1 ... 2 {
|
|
||||||
guard !isStopped, notifiedUUIDs.isEmpty else { return }
|
|
||||||
log("\(reason):第 \(attempt) 次热重启 Browser 以重新枚举 USB 相机…")
|
|
||||||
|
|
||||||
if browser.isBrowsing {
|
|
||||||
browser.stop()
|
|
||||||
}
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000)
|
|
||||||
toggleBrowseMask()
|
|
||||||
notifiedUUIDs.removeAll()
|
|
||||||
pollTick = 0
|
|
||||||
emptyPollStreak = 0
|
|
||||||
|
|
||||||
await beginBrowsing(label: "\(reason)-热重启\(attempt)")
|
|
||||||
if await waitForDiscovery(maxAttempts: 6, intervalNanoseconds: 300_000_000) {
|
|
||||||
log("\(reason):热重启第 \(attempt) 次成功发现相机")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log("\(reason):热重启后仍未发现 USB 相机")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func beginBrowsing(label: String) async {
|
|
||||||
log("\(label) ICDeviceBrowser.start() suspended=\(browser.isSuspended)")
|
|
||||||
browser.start()
|
|
||||||
log("isBrowsing=\(browser.isBrowsing) devices.count=\(browser.devices?.count ?? 0)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopPolling() {
|
|
||||||
pollTimer?.invalidate()
|
|
||||||
pollTimer = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startPolling() {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.stopPolling()
|
|
||||||
self.pollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
|
||||||
self?.pollDiscoveredDevices()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startScanTimeout() {
|
|
||||||
scanTimeoutTask?.cancel()
|
|
||||||
scanTimeoutTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: 45_000_000_000)
|
|
||||||
guard !Task.isCancelled, let self else { return }
|
|
||||||
guard self.browser.isBrowsing else { return }
|
|
||||||
guard self.notifiedUUIDs.isEmpty else { return }
|
|
||||||
self.log("45s 内未发现 USB 相机")
|
|
||||||
self.onScanTimeout?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pollDiscoveredDevices() {
|
|
||||||
guard !isStopped else { return }
|
|
||||||
pollTick += 1
|
|
||||||
|
|
||||||
let devices = browser.devices ?? []
|
|
||||||
if devices.isEmpty {
|
|
||||||
emptyPollStreak += 1
|
|
||||||
} else {
|
|
||||||
emptyPollStreak = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if pollTick == 1 || pollTick % 10 == 0 {
|
|
||||||
log(
|
|
||||||
"轮询 tick=\(pollTick) devices.count=\(devices.count) " +
|
|
||||||
"isBrowsing=\(browser.isBrowsing) suspended=\(browser.isSuspended)"
|
|
||||||
)
|
|
||||||
for device in devices {
|
|
||||||
logDeviceSummary(device, prefix: " devices[]")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 连续 6 次(约 3s)无设备时自动重启 Browser;不与 reEnumerate 并发。
|
|
||||||
if emptyPollStreak > 0, emptyPollStreak % 6 == 0, notifiedUUIDs.isEmpty, !isRestarting, !isReEnumerating {
|
|
||||||
Task { await self.restart() }
|
|
||||||
}
|
|
||||||
|
|
||||||
for device in devices {
|
|
||||||
handleDiscoveredDevice(device, source: "poll")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleDiscoveredDevice(_ device: ICDevice, source: String) {
|
|
||||||
guard !isStopped else { return }
|
|
||||||
guard let camera = device as? ICCameraDevice else {
|
|
||||||
log(
|
|
||||||
"[\(source)] 非 ICCameraDevice: \(device.name ?? "?") " +
|
|
||||||
"type=\(device.type) transport=\(device.transportType ?? "?")"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let uuid = camera.uuidString ?? "\(camera.usbVendorID)-\(camera.usbLocationID)"
|
|
||||||
guard notifiedUUIDs.insert(uuid).inserted else { return }
|
|
||||||
|
|
||||||
let vendorID = Int(camera.usbVendorID)
|
|
||||||
log(
|
|
||||||
"[\(source)] 发现相机: \(camera.name ?? "?") vid=0x\(String(vendorID, radix: 16)) " +
|
|
||||||
"transport=\(camera.transportType ?? "?")"
|
|
||||||
)
|
|
||||||
scanTimeoutTask?.cancel()
|
|
||||||
scanTimeoutTask = nil
|
|
||||||
onCameraDiscovered?(camera, source)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestAuthorizations(on browser: ICDeviceBrowser) async -> Bool {
|
|
||||||
log(
|
|
||||||
"contentsAuthorizationStatus=\(browser.contentsAuthorizationStatus), " +
|
|
||||||
"controlAuthorizationStatus=\(browser.controlAuthorizationStatus)"
|
|
||||||
)
|
|
||||||
|
|
||||||
let contentsGranted = await withCheckedContinuation { continuation in
|
|
||||||
browser.requestContentsAuthorization { status in
|
|
||||||
CameraTetheringLogger.log("AutoDetect 内容授权: \(String(describing: status))")
|
|
||||||
continuation.resume(returning: status == .authorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
guard contentsGranted else { return false }
|
|
||||||
|
|
||||||
let controlGranted = await withCheckedContinuation { continuation in
|
|
||||||
browser.requestControlAuthorization { status in
|
|
||||||
CameraTetheringLogger.log("AutoDetect 控制授权: \(String(describing: status))")
|
|
||||||
continuation.resume(returning: status == .authorized)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !controlGranted {
|
|
||||||
log("控制授权未通过,仍尝试连接")
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private func logDeviceSummary(_ device: ICDevice, prefix: String) {
|
|
||||||
if let camera = device as? ICCameraDevice {
|
|
||||||
log(
|
|
||||||
"\(prefix): \(camera.name ?? "?") vid=0x\(String(Int(camera.usbVendorID), radix: 16)) " +
|
|
||||||
"transport=\(camera.transportType ?? "?")"
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
log("\(prefix): \(device.name ?? "?") type=\(device.type) transport=\(device.transportType ?? "?")")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func log(_ message: String) {
|
|
||||||
CameraTetheringLogger.log("AutoDetect \(message)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension USBDeviceBrowserController: ICDeviceBrowserDelegate {
|
|
||||||
@objc(deviceBrowser:didAddDevice:moreComing:)
|
|
||||||
func deviceBrowser(_ browser: ICDeviceBrowser, didAdd device: ICDevice, moreComing: Bool) {
|
|
||||||
guard !isStopped else { return }
|
|
||||||
log("didAdd 回调 name=\(device.name ?? "?") moreComing=\(moreComing)")
|
|
||||||
handleDiscoveredDevice(device, source: "didAdd")
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc(deviceBrowser:didRemoveDevice:moreGoing:)
|
|
||||||
func deviceBrowser(_ browser: ICDeviceBrowser, didRemove device: ICDevice, moreGoing: Bool) {
|
|
||||||
guard !isStopped else { return }
|
|
||||||
log("didRemove 回调 name=\(device.name ?? "?") moreGoing=\(moreGoing)")
|
|
||||||
if let uuid = device.uuidString {
|
|
||||||
notifiedUUIDs.remove(uuid)
|
|
||||||
}
|
|
||||||
onCameraRemoved?(device)
|
|
||||||
}
|
|
||||||
|
|
||||||
func deviceBrowserDidSuspendOperations(_ browser: ICDeviceBrowser) {
|
|
||||||
log("Browser 操作已挂起(App 进入后台)")
|
|
||||||
}
|
|
||||||
|
|
||||||
func deviceBrowserDidResumeOperations(_ browser: ICDeviceBrowser) {
|
|
||||||
guard !isStopped else { return }
|
|
||||||
log("Browser 操作已恢复(App 回到前台)")
|
|
||||||
Task { await restart() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
# CameraTransfer
|
|
||||||
|
|
||||||
## 职责
|
|
||||||
|
|
||||||
编排相机照片从下载到上传的通用流程,通过 `CameraAssetUploadSink` 与业务层解耦。
|
|
||||||
|
|
||||||
## 核心组件
|
|
||||||
|
|
||||||
| 组件 | 说明 |
|
|
||||||
| --- | --- |
|
|
||||||
| `CameraTransferPipeline` | 监听 `onNewAsset`、管理任务队列与上传 |
|
|
||||||
| `CameraTransferTask` | 单条传输任务及状态 |
|
|
||||||
| `CameraAssetUploadSink` | 上传协议,由业务模块实现 |
|
|
||||||
| `CameraDownloadStorage.Scope` | 账号 + 相册维度的本地文件隔离作用域 |
|
|
||||||
|
|
||||||
## 业务流程
|
|
||||||
|
|
||||||
1. 相机服务回调 `onNewAsset`。
|
|
||||||
2. Pipeline 下载文件到当前 scope 的 `originals` 目录并更新任务状态。
|
|
||||||
3. 若照片是在开启自动上传时由 `onNewAsset` 新发现,加入自动上传资格队列并调用 `CameraAssetUploadSink.upload`。
|
|
||||||
4. 上传成功后任务状态变为 `uploaded`。
|
|
||||||
|
|
||||||
`syncExistingPhotos()` 仅同步相机历史照片到本地任务列表,不授予自动上传资格。拍后传输模式下已下载的 `downloaded` 任务,切换到边拍边传后仍保持待上传,只有业务层显式调用 `uploadAssets(withIDs:)` 或 `retryUpload(...)` 时才会上传。
|
|
||||||
|
|
||||||
本地文件路径按 `Documents/CameraDownloads/<accountKey>/<albumID>/originals/` 存放原图,缩略图由业务层生成到同 scope 的 `thumbnails/`。持久化只记录相对 Documents 的 scoped 路径,不保留未上线旧目录兼容。
|
|
||||||
|
|
||||||
## 与业务层关系
|
|
||||||
|
|
||||||
- 旅拍相册通过 `TravelAlbumMaterialUploader` 实现 Sink:OSS 上传 + `upload-material` 登记。
|
|
||||||
- Pipeline 不依赖任何 HTTP 或 OSS 具体实现。
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// 有线传图页展示的相机连接状态(UI 层使用,OTG 连接能力已移除)。
|
||||||
enum CameraConnectionState: Equatable {
|
enum CameraConnectionState: Equatable {
|
||||||
case disconnected
|
case disconnected
|
||||||
case searching
|
case searching
|
||||||
@ -1,13 +1,11 @@
|
|||||||
//
|
//
|
||||||
// CameraTransferTask.swift
|
// CameraTransferModels.swift
|
||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 相机传输任务状态。
|
/// 有线传图任务状态。
|
||||||
enum CameraTransferStatus: String, Codable, Equatable {
|
enum CameraTransferStatus: String, Codable, Equatable {
|
||||||
case pending
|
case pending
|
||||||
case downloading
|
case downloading
|
||||||
@ -17,19 +15,20 @@ enum CameraTransferStatus: String, Codable, Equatable {
|
|||||||
case failed
|
case failed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机传输任务,表示从相机下载到上传登记的单条记录。
|
/// 有线传图任务,表示单张照片从本地到上传登记的记录。
|
||||||
struct CameraTransferTask: Identifiable, Equatable, Codable {
|
struct CameraTransferTask: Identifiable, Equatable, Codable {
|
||||||
let id: UUID
|
let id: UUID
|
||||||
let assetID: String
|
let assetID: String
|
||||||
let filename: String
|
let filename: String
|
||||||
var localPath: String?
|
var localPath: String?
|
||||||
var remoteURL: String?
|
var remoteURL: String?
|
||||||
/// 相机文件拍摄时间,格式 `yyyy-MM-dd HH:mm:ss`。
|
/// 拍摄时间,格式 `yyyy-MM-dd HH:mm:ss`。
|
||||||
var capturedAt: String?
|
var capturedAt: String?
|
||||||
var status: CameraTransferStatus
|
var status: CameraTransferStatus
|
||||||
var progress: Int
|
var progress: Int
|
||||||
var errorMessage: String?
|
var errorMessage: String?
|
||||||
|
|
||||||
|
/// 初始化传图任务。
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
assetID: String,
|
assetID: String,
|
||||||
@ -2,12 +2,10 @@
|
|||||||
// CameraAssetUploadSink.swift
|
// CameraAssetUploadSink.swift
|
||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 相机文件下载完成后的上传登记入口,业务模块各自实现(如旅拍相册 OSS + 后端登记)。
|
/// 本地照片上传登记入口,由旅拍相册模块实现 OSS 上传与后端登记。
|
||||||
@MainActor
|
@MainActor
|
||||||
protocol CameraAssetUploadSink: AnyObject {
|
protocol CameraAssetUploadSink: AnyObject {
|
||||||
/// 上传本地文件并返回远端 URL。
|
/// 上传本地文件并返回远端 URL。
|
||||||
@ -2,18 +2,16 @@
|
|||||||
// CameraDownloadStorage.swift
|
// CameraDownloadStorage.swift
|
||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// 相机下载文件本地存储工具,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。
|
/// 有线传图本地文件存储,负责按账号和相册隔离目录、相对路径持久化与安全文件名处理。
|
||||||
enum CameraDownloadStorage {
|
enum CameraDownloadStorage {
|
||||||
static let cameraDownloadsFolderName = "CameraDownloads"
|
static let cameraDownloadsFolderName = "CameraDownloads"
|
||||||
|
|
||||||
/// 相机下载文件的业务隔离作用域。
|
/// 有线传图文件的业务隔离作用域。
|
||||||
struct Scope: Equatable {
|
struct Scope: Equatable {
|
||||||
let accountKey: String
|
let accountKey: String
|
||||||
let albumID: Int
|
let albumID: Int
|
||||||
@ -38,21 +36,13 @@ enum CameraDownloadStorage {
|
|||||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机照片下载根目录。
|
/// 有线传图照片下载根目录。
|
||||||
static var downloadsDirectory: URL {
|
static var downloadsDirectory: URL {
|
||||||
let directory = documentsDirectory.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
let directory = documentsDirectory.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
||||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机底层下载临时目录,最终业务记录不会持久化这里的路径。
|
|
||||||
static var temporaryDownloadsDirectory: URL {
|
|
||||||
let directory = FileManager.default.temporaryDirectory
|
|
||||||
.appendingPathComponent(cameraDownloadsFolderName, isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
||||||
return directory
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前账号与相册的下载根目录。
|
/// 当前账号与相册的下载根目录。
|
||||||
static func scopeDirectory(for scope: Scope) -> URL {
|
static func scopeDirectory(for scope: Scope) -> URL {
|
||||||
let directory = downloadsDirectory
|
let directory = downloadsDirectory
|
||||||
@ -76,7 +66,7 @@ enum CameraDownloadStorage {
|
|||||||
return directory
|
return directory
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将绝对路径转为相对 Documents 的路径,如 `CameraDownloads/user_101/6603/originals/1730_DSC.JPG`。
|
/// 将绝对路径转为相对 Documents 的路径。
|
||||||
static func relativePath(for url: URL) -> String {
|
static func relativePath(for url: URL) -> String {
|
||||||
let standardizedURL = url.standardizedFileURL
|
let standardizedURL = url.standardizedFileURL
|
||||||
let documentsPath = documentsDirectory.standardizedFileURL.path
|
let documentsPath = documentsDirectory.standardizedFileURL.path
|
||||||
@ -109,13 +99,6 @@ enum CameraDownloadStorage {
|
|||||||
return documentsDirectory.appendingPathComponent(storedPath)
|
return documentsDirectory.appendingPathComponent(storedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 判断文件是否位于当前账号与相册的原图目录内。
|
|
||||||
static func isInOriginalsDirectory(_ url: URL, scope: Scope) -> Bool {
|
|
||||||
let originalsPath = originalsDirectory(for: scope).standardizedFileURL.path
|
|
||||||
let filePath = url.standardizedFileURL.path
|
|
||||||
return filePath.hasPrefix(originalsPath + "/") || filePath.hasPrefix("/private" + originalsPath + "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。
|
/// 生成当前账号与相册内带时间戳的唯一原图 URL,避免文件名冲突。
|
||||||
static func uniqueLocalURL(for filename: String, scope: Scope) -> URL {
|
static func uniqueLocalURL(for filename: String, scope: Scope) -> URL {
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
let timestamp = Int(Date().timeIntervalSince1970)
|
||||||
@ -123,13 +106,6 @@ enum CameraDownloadStorage {
|
|||||||
return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)")
|
return originalsDirectory(for: scope).appendingPathComponent("\(timestamp)_\(safeName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成相机底层临时下载 URL。
|
|
||||||
static func uniqueTemporaryURL(for filename: String) -> URL {
|
|
||||||
let timestamp = Int(Date().timeIntervalSince1970)
|
|
||||||
let safeName = sanitizeFilename(filename)
|
|
||||||
return temporaryDownloadsDirectory.appendingPathComponent("\(timestamp)_\(safeName)")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。
|
/// 生成当前账号与相册内带 asset ID 的唯一缩略图文件 URL。
|
||||||
static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL {
|
static func uniqueThumbnailURL(for assetID: String, scope: Scope) -> URL {
|
||||||
let safeName = sanitizeFilename(assetID)
|
let safeName = sanitizeFilename(assetID)
|
||||||
@ -145,7 +121,7 @@ enum CameraDownloadStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 将相机 PTP 文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
/// 将文件名转为 iOS 文件系统可安全使用的 ASCII 名称。
|
||||||
static func sanitizeFilename(_ filename: String) -> String {
|
static func sanitizeFilename(_ filename: String) -> String {
|
||||||
let trimmed = filename
|
let trimmed = filename
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@ -199,7 +175,7 @@ enum CameraDownloadStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 相机本地照片缩略图生成器,负责为列表生成小尺寸预览图。
|
/// 本地照片缩略图生成器,负责为列表生成小尺寸预览图。
|
||||||
enum CameraThumbnailGenerator {
|
enum CameraThumbnailGenerator {
|
||||||
private static let maxPixelSize: CGFloat = 160
|
private static let maxPixelSize: CGFloat = 160
|
||||||
|
|
||||||
@ -1,19 +1,16 @@
|
|||||||
//
|
//
|
||||||
// CameraTransferPipeline.swift
|
// WiredCameraTransferPipeline.swift
|
||||||
// suixinkan
|
// suixinkan
|
||||||
//
|
//
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 相机传输编排器,负责监听新照片、下载到本地并按需调用上传 Sink。
|
/// 有线传图上传编排器,负责管理本地照片的上传任务(不含 USB/OTG 相机连接)。
|
||||||
@MainActor
|
@MainActor
|
||||||
final class CameraTransferPipeline {
|
final class WiredCameraTransferPipeline {
|
||||||
var onTasksUpdated: (([CameraTransferTask]) -> Void)?
|
var onTasksUpdated: (([CameraTransferTask]) -> Void)?
|
||||||
|
|
||||||
private(set) var tasks: [CameraTransferTask] = []
|
private(set) var tasks: [CameraTransferTask] = []
|
||||||
private let cameraService: any CameraServiceProtocol
|
|
||||||
private var uploadSink: (any CameraAssetUploadSink)?
|
private var uploadSink: (any CameraAssetUploadSink)?
|
||||||
private var uploadEnabled = true
|
private var uploadEnabled = true
|
||||||
private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0)
|
private var downloadScope = CameraDownloadStorage.Scope(accountKey: "unscoped", albumID: 0)
|
||||||
@ -22,47 +19,30 @@ final class CameraTransferPipeline {
|
|||||||
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
private var pendingDeferredNotifyTask: Task<Void, Never>?
|
||||||
private var lastDeferredNotifyDate = Date.distantPast
|
private var lastDeferredNotifyDate = Date.distantPast
|
||||||
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
|
private var lastNotifiedProgressByAssetID: [String: Int] = [:]
|
||||||
private var inFlightAssetIDs: Set<String> = []
|
|
||||||
private let maxRetries = 3
|
private let maxRetries = 3
|
||||||
private let deferredNotifyInterval: TimeInterval = 0.15
|
private let deferredNotifyInterval: TimeInterval = 0.15
|
||||||
private let maxConcurrentUploads = 3
|
private let maxConcurrentUploads = 3
|
||||||
private var isUIAttached = false
|
private var isUIAttached = false
|
||||||
/// 用户已从列表删除、不应再同步或展示的 asset ID。
|
/// 用户已从列表删除、不应再展示的 asset ID。
|
||||||
private(set) var excludedAssetIDs: Set<String> = []
|
private(set) var excludedAssetIDs: Set<String> = []
|
||||||
|
|
||||||
/// 初始化传输管道,并注入相机服务。
|
/// 绑定 UI 回调。
|
||||||
init(cameraService: any CameraServiceProtocol) {
|
func attachToUI(onTasksUpdated: @escaping ([CameraTransferTask]) -> Void) {
|
||||||
self.cameraService = cameraService
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 绑定 UI 回调;再次进入 OTG 页时重新 attach。
|
|
||||||
func attachToUI(
|
|
||||||
onConnectionStateChange: @escaping (CameraConnectionState) -> Void,
|
|
||||||
onTasksUpdated: @escaping ([CameraTransferTask]) -> Void
|
|
||||||
) {
|
|
||||||
isUIAttached = true
|
isUIAttached = true
|
||||||
self.onTasksUpdated = onTasksUpdated
|
self.onTasksUpdated = onTasksUpdated
|
||||||
cameraService.onConnectionStateChange = onConnectionStateChange
|
|
||||||
cameraService.onNewAsset = { [weak self] asset in
|
|
||||||
Task { @MainActor in
|
|
||||||
await self?.handleNewAsset(asset, skipIfExists: false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 页面离开:取消 UI 回调与进行中的上传通知,保持相机连接。
|
/// 页面离开:取消 UI 回调与进行中的上传通知。
|
||||||
func detachFromUI() {
|
func detachFromUI() {
|
||||||
guard isUIAttached else { return }
|
guard isUIAttached else { return }
|
||||||
isUIAttached = false
|
isUIAttached = false
|
||||||
pendingDeferredNotifyTask?.cancel()
|
pendingDeferredNotifyTask?.cancel()
|
||||||
pendingDeferredNotifyTask = nil
|
pendingDeferredNotifyTask = nil
|
||||||
activeUploadAssetIDs.removeAll()
|
activeUploadAssetIDs.removeAll()
|
||||||
inFlightAssetIDs.removeAll()
|
|
||||||
onTasksUpdated = nil
|
onTasksUpdated = nil
|
||||||
cameraService.detachFromUI()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 配置上传 Sink、自动上传开关与可选下载作用域。
|
/// 配置上传 Sink、自动上传开关与下载作用域。
|
||||||
func configure(
|
func configure(
|
||||||
uploadSink: (any CameraAssetUploadSink)?,
|
uploadSink: (any CameraAssetUploadSink)?,
|
||||||
uploadEnabled: Bool,
|
uploadEnabled: Bool,
|
||||||
@ -75,40 +55,6 @@ final class CameraTransferPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 启动相机连接;已连接时同步已有照片。
|
|
||||||
func connect() async {
|
|
||||||
await cameraService.connect()
|
|
||||||
if cameraService.connectionState.isConnected {
|
|
||||||
await syncExistingPhotos()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 完整释放相机资源(App 终止或测试 teardown)。
|
|
||||||
func shutdown() async {
|
|
||||||
detachFromUI()
|
|
||||||
await cameraService.shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 断开相机连接(兼容旧调用,语义等同 shutdown)。
|
|
||||||
func disconnect() async {
|
|
||||||
await shutdown()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新搜索 USB 相机。
|
|
||||||
func restartCameraDiscovery() async {
|
|
||||||
if let autoDetect = cameraService as? AutoDetectCameraService {
|
|
||||||
await autoDetect.restartDiscovery()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await cameraService.shutdown()
|
|
||||||
await cameraService.connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前相机连接状态。
|
|
||||||
var connectionState: CameraConnectionState {
|
|
||||||
cameraService.connectionState
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重试所有失败的上传任务。
|
/// 重试所有失败的上传任务。
|
||||||
func retryFailedUploads() async {
|
func retryFailedUploads() async {
|
||||||
guard uploadEnabled, uploadSink != nil else { return }
|
guard uploadEnabled, uploadSink != nil else { return }
|
||||||
@ -121,7 +67,7 @@ final class CameraTransferPipeline {
|
|||||||
await uploadAssets(withIDs: failedAssetIDs)
|
await uploadAssets(withIDs: failedAssetIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 手动上传指定 asset 列表(拍完再传模式)。
|
/// 手动上传指定 asset 列表。
|
||||||
func uploadAssets(withIDs assetIDs: [String]) async {
|
func uploadAssets(withIDs assetIDs: [String]) async {
|
||||||
guard uploadSink != nil else { return }
|
guard uploadSink != nil else { return }
|
||||||
var seenAssetIDs: Set<String> = []
|
var seenAssetIDs: Set<String> = []
|
||||||
@ -142,25 +88,6 @@ final class CameraTransferPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uploadAssetIfNeeded(assetID: String) async {
|
|
||||||
if activeUploadAssetIDs.contains(assetID) { return }
|
|
||||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
|
||||||
if tasks[index].status == .uploaded { return }
|
|
||||||
|
|
||||||
activeUploadAssetIDs.insert(assetID)
|
|
||||||
defer { activeUploadAssetIDs.remove(assetID) }
|
|
||||||
|
|
||||||
if tasks[index].localURL == nil {
|
|
||||||
await downloadAsset(assetID: assetID)
|
|
||||||
}
|
|
||||||
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
|
||||||
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
|
||||||
tasks[refreshedIndex].status = .downloaded
|
|
||||||
tasks[refreshedIndex].errorMessage = nil
|
|
||||||
await uploadTask(assetID: assetID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重传单张照片;若管道中无任务则根据本地相对路径恢复。
|
/// 重传单张照片;若管道中无任务则根据本地相对路径恢复。
|
||||||
func retryUpload(assetID: String, filename: String, localPath: String) async {
|
func retryUpload(assetID: String, filename: String, localPath: String) async {
|
||||||
guard uploadSink != nil else { return }
|
guard uploadSink != nil else { return }
|
||||||
@ -195,162 +122,39 @@ final class CameraTransferPipeline {
|
|||||||
await uploadTask(assetID: assetID)
|
await uploadTask(assetID: assetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步相机内已有照片到任务列表(不自动上传)。
|
|
||||||
func syncExistingPhotos() async {
|
|
||||||
guard cameraService.connectionState.isConnected else { return }
|
|
||||||
do {
|
|
||||||
let assets = try await cameraService.listAssets()
|
|
||||||
for asset in assets {
|
|
||||||
await handleNewAsset(asset, skipIfExists: true)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
CameraTetheringLogger.log("syncExistingPhotos 失败: \(error.localizedDescription)", logger: CameraTetheringLogger.transfer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 清空所有传输记录及本地文件。
|
|
||||||
func clearAllTransfers() {
|
|
||||||
for task in tasks {
|
|
||||||
guard let localURL = task.localURL,
|
|
||||||
FileManager.default.fileExists(atPath: localURL.path)
|
|
||||||
else { continue }
|
|
||||||
try? FileManager.default.removeItem(at: localURL)
|
|
||||||
}
|
|
||||||
tasks.removeAll()
|
|
||||||
autoUploadEligibleAssetIDs.removeAll()
|
|
||||||
notify()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 按 assetID 查找任务。
|
/// 按 assetID 查找任务。
|
||||||
func task(forAssetID assetID: String) -> CameraTransferTask? {
|
func task(forAssetID assetID: String) -> CameraTransferTask? {
|
||||||
tasks.first { $0.assetID == assetID }
|
tasks.first { $0.assetID == assetID }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步用户已删除的 asset ID,防止 sync/notify 再次带回。
|
/// 同步用户已删除的 asset ID,防止再次带回。
|
||||||
func setExcludedAssetIDs(_ ids: Set<String>) {
|
func setExcludedAssetIDs(_ ids: Set<String>) {
|
||||||
excludedAssetIDs = ids
|
excludedAssetIDs = ids
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从管道移除 asset,并阻止后续自动同步。
|
/// 从管道移除 asset。
|
||||||
func removeAsset(assetID: String) {
|
func removeAsset(assetID: String) {
|
||||||
guard !assetID.isEmpty else { return }
|
guard !assetID.isEmpty else { return }
|
||||||
excludedAssetIDs.insert(assetID)
|
excludedAssetIDs.insert(assetID)
|
||||||
tasks.removeAll { $0.assetID == assetID }
|
tasks.removeAll { $0.assetID == assetID }
|
||||||
autoUploadEligibleAssetIDs.remove(assetID)
|
autoUploadEligibleAssetIDs.remove(assetID)
|
||||||
activeUploadAssetIDs.remove(assetID)
|
activeUploadAssetIDs.remove(assetID)
|
||||||
inFlightAssetIDs.remove(assetID)
|
|
||||||
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleNewAsset(_ asset: CameraAsset, skipIfExists: Bool) async {
|
private func uploadAssetIfNeeded(assetID: String) async {
|
||||||
if excludedAssetIDs.contains(asset.id) { return }
|
if activeUploadAssetIDs.contains(assetID) { return }
|
||||||
if skipIfExists,
|
|
||||||
let existing = tasks.first(where: { $0.assetID == asset.id }),
|
|
||||||
existing.status == .uploaded || existing.status == .downloaded {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if inFlightAssetIDs.contains(asset.id) { return }
|
|
||||||
if tasks.contains(where: { $0.assetID == asset.id && $0.status == .downloading }) { return }
|
|
||||||
|
|
||||||
let isKnownAsset = tasks.contains { $0.assetID == asset.id }
|
|
||||||
let shouldAutoUpload = !skipIfExists
|
|
||||||
&& uploadEnabled
|
|
||||||
&& uploadSink != nil
|
|
||||||
&& (!isKnownAsset || autoUploadEligibleAssetIDs.contains(asset.id))
|
|
||||||
|
|
||||||
inFlightAssetIDs.insert(asset.id)
|
|
||||||
defer { inFlightAssetIDs.remove(asset.id) }
|
|
||||||
|
|
||||||
let assetID = asset.id
|
|
||||||
if shouldAutoUpload {
|
|
||||||
autoUploadEligibleAssetIDs.insert(assetID)
|
|
||||||
}
|
|
||||||
if let existingIndex = tasks.firstIndex(where: { $0.assetID == assetID }) {
|
|
||||||
tasks[existingIndex].status = .downloading
|
|
||||||
tasks[existingIndex].errorMessage = nil
|
|
||||||
} else {
|
|
||||||
let task = CameraTransferTask(
|
|
||||||
assetID: asset.id,
|
|
||||||
filename: asset.filename,
|
|
||||||
capturedAt: CameraTransferTask.capturedAtString(from: asset.creationDate),
|
|
||||||
status: .downloading
|
|
||||||
)
|
|
||||||
tasks.insert(task, at: 0)
|
|
||||||
}
|
|
||||||
notify()
|
|
||||||
|
|
||||||
await downloadAsset(assetID: assetID, asset: asset)
|
|
||||||
|
|
||||||
if shouldAutoUpload,
|
|
||||||
tasks.first(where: { $0.assetID == assetID })?.status == .downloaded {
|
|
||||||
processUploadQueue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 下载指定 asset 到本地;始终按 assetID 定位任务,避免连拍时 index 失效。
|
|
||||||
private func downloadAsset(assetID: String, asset: CameraAsset? = nil) async {
|
|
||||||
guard tasks.contains(where: { $0.assetID == assetID }) else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
let resolvedAsset: CameraAsset
|
|
||||||
if let asset {
|
|
||||||
resolvedAsset = asset
|
|
||||||
} else if let cached = try? await cameraService.listAssets().first(where: { $0.id == assetID }) {
|
|
||||||
resolvedAsset = cached
|
|
||||||
} else {
|
|
||||||
throw CameraServiceError.assetNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
let localURL = try await cameraService.downloadAsset(resolvedAsset)
|
|
||||||
let destination: URL
|
|
||||||
|
|
||||||
if CameraDownloadStorage.isInOriginalsDirectory(localURL, scope: downloadScope) {
|
|
||||||
destination = localURL
|
|
||||||
} else {
|
|
||||||
destination = CameraDownloadStorage.uniqueLocalURL(for: resolvedAsset.filename, scope: downloadScope)
|
|
||||||
if FileManager.default.fileExists(atPath: destination.path) {
|
|
||||||
try FileManager.default.removeItem(at: destination)
|
|
||||||
}
|
|
||||||
try FileManager.default.moveItem(at: localURL, to: destination)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||||
tasks[index].localPath = CameraDownloadStorage.relativePath(for: destination)
|
if tasks[index].status == .uploaded { return }
|
||||||
if tasks[index].capturedAt?.isEmpty != false {
|
|
||||||
tasks[index].capturedAt = CameraTransferTask.capturedAtString(from: resolvedAsset.creationDate)
|
|
||||||
}
|
|
||||||
tasks[index].status = .downloaded
|
|
||||||
tasks[index].progress = 0
|
|
||||||
notify()
|
|
||||||
} catch {
|
|
||||||
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
|
||||||
tasks[index].status = .failed
|
|
||||||
tasks[index].errorMessage = error.localizedDescription
|
|
||||||
notify()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 并发上传边拍边传新照片;拍后传输留下的待上传任务只能由用户显式上传。
|
|
||||||
private func processUploadQueue() {
|
|
||||||
guard uploadSink != nil else { return }
|
|
||||||
|
|
||||||
while activeUploadAssetIDs.count < maxConcurrentUploads,
|
|
||||||
let task = tasks.first(where: {
|
|
||||||
$0.status == .downloaded
|
|
||||||
&& !activeUploadAssetIDs.contains($0.assetID)
|
|
||||||
&& autoUploadEligibleAssetIDs.contains($0.assetID)
|
|
||||||
}) {
|
|
||||||
startConcurrentUpload(assetID: task.assetID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startConcurrentUpload(assetID: String) {
|
|
||||||
guard !activeUploadAssetIDs.contains(assetID) else { return }
|
|
||||||
activeUploadAssetIDs.insert(assetID)
|
activeUploadAssetIDs.insert(assetID)
|
||||||
Task { @MainActor in
|
defer { activeUploadAssetIDs.remove(assetID) }
|
||||||
await self.uploadTask(assetID: assetID)
|
|
||||||
self.activeUploadAssetIDs.remove(assetID)
|
guard let refreshedIndex = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
|
||||||
self.processUploadQueue()
|
if tasks[refreshedIndex].status == .downloaded || tasks[refreshedIndex].status == .failed {
|
||||||
|
tasks[refreshedIndex].status = .downloaded
|
||||||
|
tasks[refreshedIndex].errorMessage = nil
|
||||||
|
await uploadTask(assetID: assetID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -409,7 +213,7 @@ final class CameraTransferPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notify(_ mode: CameraTransferNotifyMode = .immediate) {
|
private func notify(_ mode: WiredTransferNotifyMode = .immediate) {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .immediate:
|
case .immediate:
|
||||||
pendingDeferredNotifyTask?.cancel()
|
pendingDeferredNotifyTask?.cancel()
|
||||||
@ -435,7 +239,7 @@ final class CameraTransferPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum CameraTransferNotifyMode {
|
private enum WiredTransferNotifyMode {
|
||||||
case immediate
|
case immediate
|
||||||
case deferred
|
case deferred
|
||||||
}
|
}
|
||||||
@ -53,7 +53,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
|
|
||||||
## 有线传图交互流程
|
## 有线传图交互流程
|
||||||
|
|
||||||
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 同步相机历史文件
|
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 刷新本地持久化记录
|
||||||
2. **批量上传三态**:
|
2. **批量上传三态**:
|
||||||
- 首次点击 → 进入勾选模式
|
- 首次点击 → 进入勾选模式
|
||||||
- 有选中项 → 上传选中照片
|
- 有选中项 → 上传选中照片
|
||||||
@ -69,7 +69,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
## 业务流程
|
## 业务流程
|
||||||
|
|
||||||
1. 创建任务:`POST .../travel-album/create`
|
1. 创建任务:`POST .../travel-album/create`
|
||||||
2. 进入有线传图:Sony/Canon USB 有线传图(`Core/CameraTethering`,自动识别品牌)
|
2. 进入有线传图:有线传图 UI 页(USB/OTG 相机连接能力已移除,保留页面与本地照片上传)
|
||||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||||
4. 素材登记:`POST .../travel-album/upload-material`
|
4. 素材登记:`POST .../travel-album/upload-material`
|
||||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
||||||
@ -83,18 +83,18 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
|||||||
|
|
||||||
## 解耦关系
|
## 解耦关系
|
||||||
|
|
||||||
- `CameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
- `WiredCameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
|
||||||
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
|
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
|
||||||
- 与「相册管理」(`Features/Assets`)API、Store、路由完全隔离
|
- 与「相册管理」(`Features/Assets`)API、Store、路由完全隔离
|
||||||
|
|
||||||
## 依赖模块
|
## 依赖模块
|
||||||
|
|
||||||
- `Core/CameraTethering` — USB/PTP 相机
|
- `Features/TravelAlbum/Services` — 本地存储、上传编排
|
||||||
- `Core/CameraTransfer` — 传输编排
|
|
||||||
- `Core/Upload` — OSS STS 上传
|
- `Core/Upload` — OSS STS 上传
|
||||||
|
|
||||||
## 二期待补齐
|
## 二期待补齐
|
||||||
|
|
||||||
- RAW 预览
|
- RAW 预览
|
||||||
- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `CameraTransferPipeline` 联动
|
- USB/OTG 相机有线连接与边拍边传
|
||||||
|
- 格式 Chip 扩展 RAW / JPEG+RAW,并与 `WiredCameraTransferPipeline` 联动
|
||||||
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)
|
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)
|
||||||
|
|||||||
@ -441,7 +441,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
|
|
||||||
let context: WiredTransferContext
|
let context: WiredTransferContext
|
||||||
private let pipeline: CameraTransferPipeline
|
private let pipeline = WiredCameraTransferPipeline()
|
||||||
private var photoStore: WiredTransferPhotoStore?
|
private var photoStore: WiredTransferPhotoStore?
|
||||||
private var userIDProvider: (() -> String)?
|
private var userIDProvider: (() -> String)?
|
||||||
private var downloadScope: CameraDownloadStorage.Scope?
|
private var downloadScope: CameraDownloadStorage.Scope?
|
||||||
@ -457,12 +457,8 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
private var isLeavingPage = false
|
private var isLeavingPage = false
|
||||||
|
|
||||||
/// 初始化有线传图 ViewModel。
|
/// 初始化有线传图 ViewModel。
|
||||||
init(
|
init(context: WiredTransferContext) {
|
||||||
context: WiredTransferContext,
|
|
||||||
cameraService: any CameraServiceProtocol
|
|
||||||
) {
|
|
||||||
self.context = context
|
self.context = context
|
||||||
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 配置上传 Sink 并启动相机连接。
|
/// 配置上传 Sink 并启动相机连接。
|
||||||
@ -473,16 +469,11 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
accountContext: AccountContext
|
accountContext: AccountContext
|
||||||
) async {
|
) async {
|
||||||
isLeavingPage = false
|
isLeavingPage = false
|
||||||
pipeline.attachToUI(
|
pipeline.attachToUI { [weak self] tasks in
|
||||||
onConnectionStateChange: { [weak self] state in
|
|
||||||
self?.handleConnectionStateChange(state)
|
|
||||||
},
|
|
||||||
onTasksUpdated: { [weak self] tasks in
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
await self?.applyPipelineTasks(tasks)
|
await self?.applyPipelineTasks(tasks)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
|
||||||
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
|
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
|
||||||
let currentDownloadScope = CameraDownloadStorage.Scope(
|
let currentDownloadScope = CameraDownloadStorage.Scope(
|
||||||
@ -511,19 +502,10 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||||
refreshDeviceStorageInfo()
|
refreshDeviceStorageInfo()
|
||||||
loadPersistedPhotos()
|
loadPersistedPhotos()
|
||||||
|
connectionState = .disconnected
|
||||||
do {
|
|
||||||
await pipeline.connect()
|
|
||||||
connectionState = pipeline.connectionState
|
|
||||||
wasConnected = connectionState.isConnected
|
|
||||||
|
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
}
|
|
||||||
} catch is CancellationError {
|
|
||||||
// 用户返回或 task 取消。
|
|
||||||
} catch {
|
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
}
|
||||||
detachFromUI()
|
detachFromUI()
|
||||||
}
|
}
|
||||||
@ -600,25 +582,20 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
|||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 刷新相机文件列表;未连接时重新搜索 USB 相机。
|
/// 刷新相机文件列表(OTG 能力已移除,仅刷新本地持久化记录)。
|
||||||
func refreshCameraFiles() async {
|
func refreshCameraFiles() async {
|
||||||
if connectionState.isConnected {
|
|
||||||
guard !isRefreshingCameraFiles else { return }
|
guard !isRefreshingCameraFiles else { return }
|
||||||
isRefreshingCameraFiles = true
|
isRefreshingCameraFiles = true
|
||||||
defer { isRefreshingCameraFiles = false }
|
defer { isRefreshingCameraFiles = false }
|
||||||
await pipeline.syncExistingPhotos()
|
loadPersistedPhotos()
|
||||||
return
|
|
||||||
}
|
|
||||||
await restartCameraSearch()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重新搜索 USB 相机。
|
/// 重新搜索相机(OTG 能力已移除,保持未连接状态)。
|
||||||
func restartCameraSearch() async {
|
func restartCameraSearch() async {
|
||||||
guard !connectionState.isConnected, !isSearchingCamera else { return }
|
guard !isSearchingCamera else { return }
|
||||||
isSearchingCamera = true
|
isSearchingCamera = true
|
||||||
defer { isSearchingCamera = false }
|
defer { isSearchingCamera = false }
|
||||||
await pipeline.restartCameraDiscovery()
|
connectionState = .disconnected
|
||||||
connectionState = pipeline.connectionState
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||||
|
|||||||
@ -24,12 +24,7 @@ struct WiredCameraTransferView: View {
|
|||||||
|
|
||||||
init(context: WiredTransferContext) {
|
init(context: WiredTransferContext) {
|
||||||
self.context = context
|
self.context = context
|
||||||
_viewModel = StateObject(
|
_viewModel = StateObject(wrappedValue: WiredCameraTransferViewModel(context: context))
|
||||||
wrappedValue: WiredCameraTransferViewModel(
|
|
||||||
context: context,
|
|
||||||
cameraService: CameraServiceFactory.makeAutoDetect()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let tabTitles = ["全部", "已上传", "失败"]
|
private let tabTitles = ["全部", "已上传", "失败"]
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
//
|
|
||||||
// CameraBrandDetectionTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
|
|
||||||
import ImageCaptureCore
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
/// USB Vendor ID 品牌识别测试。
|
|
||||||
final class CameraBrandDetectionTests: XCTestCase {
|
|
||||||
func testSonyVendorID() {
|
|
||||||
XCTAssertEqual(CameraUSBVendorID.sony, 0x054C)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testCanonVendorID() {
|
|
||||||
XCTAssertEqual(CameraUSBVendorID.canon, 0x04A9)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
//
|
|
||||||
// CameraDownloadStorageTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
/// CameraDownloadStorage 相对路径持久化测试。
|
|
||||||
final class CameraDownloadStorageTests: XCTestCase {
|
|
||||||
/// 测试 scoped 原图相对路径前缀。
|
|
||||||
func testRelativePathUsesScopedOriginalsPrefix() {
|
|
||||||
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
|
|
||||||
let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG")
|
|
||||||
XCTAssertEqual(
|
|
||||||
CameraDownloadStorage.relativePath(for: fileURL),
|
|
||||||
"CameraDownloads/user_101/6603/originals/1730_test.JPG"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试相对路径解析。
|
|
||||||
func testResolveLocalURLFromRelativePath() {
|
|
||||||
let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG"
|
|
||||||
let resolved = CameraDownloadStorage.resolveLocalURL(from: stored)
|
|
||||||
let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored)
|
|
||||||
XCTAssertEqual(resolved, expected)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试账号或相册不同不会生成相同目录。
|
|
||||||
func testScopedDirectoriesDoNotCollideAcrossAccountOrAlbum() {
|
|
||||||
let accountScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
|
||||||
let otherAccountScope = CameraDownloadStorage.Scope(accountKey: "user_102", albumID: 6603)
|
|
||||||
let otherAlbumScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604)
|
|
||||||
defer {
|
|
||||||
CameraDownloadStorage.removeScopeDirectory(for: accountScope)
|
|
||||||
CameraDownloadStorage.removeScopeDirectory(for: otherAccountScope)
|
|
||||||
CameraDownloadStorage.removeScopeDirectory(for: otherAlbumScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
XCTAssertNotEqual(
|
|
||||||
CameraDownloadStorage.originalsDirectory(for: accountScope).path,
|
|
||||||
CameraDownloadStorage.originalsDirectory(for: otherAccountScope).path
|
|
||||||
)
|
|
||||||
XCTAssertNotEqual(
|
|
||||||
CameraDownloadStorage.originalsDirectory(for: accountScope).path,
|
|
||||||
CameraDownloadStorage.originalsDirectory(for: otherAlbumScope).path
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 scoped 目录清理只影响目标相册。
|
|
||||||
func testRemoveScopeDirectoryOnlyDeletesTargetScope() throws {
|
|
||||||
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
|
||||||
let otherScope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6604)
|
|
||||||
defer {
|
|
||||||
CameraDownloadStorage.removeScopeDirectory(for: scope)
|
|
||||||
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "target.jpg", scope: scope)
|
|
||||||
let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "other.jpg", scope: otherScope)
|
|
||||||
try Data("target".utf8).write(to: fileURL)
|
|
||||||
try Data("other".utf8).write(to: otherFileURL)
|
|
||||||
|
|
||||||
CameraDownloadStorage.removeScopeDirectory(for: scope)
|
|
||||||
|
|
||||||
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
|
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试预览 URL 字符串生成。
|
|
||||||
func testPreviewURLStringFromRelativePath() throws {
|
|
||||||
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.jpg", scope: scope)
|
|
||||||
try Data("jpeg".utf8).write(to: fileURL)
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
|
|
||||||
let stored = CameraDownloadStorage.relativePath(for: fileURL)
|
|
||||||
let preview = CameraDownloadStorage.previewURLString(from: stored)
|
|
||||||
XCTAssertFalse(preview.isEmpty)
|
|
||||||
XCTAssertTrue(preview.hasPrefix("file://"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
//
|
|
||||||
// CameraServiceFactoryTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
/// CameraServiceFactory 测试。
|
|
||||||
@MainActor
|
|
||||||
final class CameraServiceFactoryTests: XCTestCase {
|
|
||||||
func testMakeSonyReturnsSonyService() {
|
|
||||||
let service = CameraServiceFactory.make(brand: .sony)
|
|
||||||
XCTAssertTrue(service is SonyCameraService)
|
|
||||||
XCTAssertEqual(service.brand, .sony)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMakeCanonReturnsCanonService() {
|
|
||||||
let service = CameraServiceFactory.make(brand: .canon)
|
|
||||||
XCTAssertTrue(service is CanonCameraService)
|
|
||||||
XCTAssertEqual(service.brand, .canon)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMakeAutoDetectReturnsAutoDetectService() {
|
|
||||||
let service = CameraServiceFactory.makeAutoDetect()
|
|
||||||
XCTAssertTrue(service is AutoDetectCameraService)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testMakeAutoDetectReturnsSharedInstance() {
|
|
||||||
let first = CameraServiceFactory.makeAutoDetect()
|
|
||||||
let second = CameraServiceFactory.makeAutoDetect()
|
|
||||||
XCTAssertTrue(first === second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
//
|
|
||||||
// CameraTetheringSessionTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
/// CameraTetheringSession 与 detach/shutdown 语义测试。
|
|
||||||
@MainActor
|
|
||||||
final class CameraTetheringSessionTests: XCTestCase {
|
|
||||||
func testSharedServiceIsAutoDetectInstance() {
|
|
||||||
XCTAssertTrue(CameraTetheringSession.sharedService is AutoDetectCameraService)
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDetachFromUIClearsCallbacksWithoutChangingConnectionState() {
|
|
||||||
let service = AutoDetectCameraService()
|
|
||||||
service.onConnectionStateChange = { _ in }
|
|
||||||
service.onNewAsset = { _ in }
|
|
||||||
service.detachFromUI()
|
|
||||||
XCTAssertNil(service.onConnectionStateChange)
|
|
||||||
XCTAssertNil(service.onNewAsset)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
//
|
|
||||||
// CanonPTPCommandsTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
/// Canon PTP 命令与事件解析测试。
|
|
||||||
final class CanonPTPCommandsTests: XCTestCase {
|
|
||||||
/// 测试 SetRemoteMode 命令包 opcode。
|
|
||||||
func testBuildSetRemoteModeCommand() {
|
|
||||||
let data = PTPHelper.buildCommand(
|
|
||||||
code: CanonPTPCommand.eosSetRemoteMode,
|
|
||||||
transactionID: 1,
|
|
||||||
parameters: [1]
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(data.count, 16)
|
|
||||||
XCTAssertEqual(data[6], 0x14)
|
|
||||||
XCTAssertEqual(data[7], 0x91)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 ObjectAddedEx (0xC181) 事件解析。
|
|
||||||
func testParseObjectAddedExEvent() {
|
|
||||||
var blob = Data()
|
|
||||||
let recordSize: UInt32 = 8 + 40
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx.littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x1234).littleEndian) { Data($0) }) // handle
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) }) // storage
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) }) // ofc
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // flags
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // parent
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(1024).littleEndian) { Data($0) }) // size
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) }) // reserved
|
|
||||||
let filename = "IMG_0001.JPG"
|
|
||||||
blob.append(filename.data(using: .ascii)!)
|
|
||||||
blob.append(0)
|
|
||||||
|
|
||||||
let events = CanonEosEventParser.parse(blob)
|
|
||||||
XCTAssertEqual(events.count, 1)
|
|
||||||
guard case .objectAdded(let added) = events[0] else {
|
|
||||||
return XCTFail("expected objectAdded")
|
|
||||||
}
|
|
||||||
XCTAssertEqual(added.objectHandle, 0x1234)
|
|
||||||
XCTAssertEqual(added.filename, "IMG_0001.JPG")
|
|
||||||
XCTAssertEqual(added.size, 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 ObjectAddedEx2 (0xC1A7) 大文件 size 解析。
|
|
||||||
func testParseObjectAddedEx2Event() {
|
|
||||||
var blob = Data()
|
|
||||||
let recordSize: UInt32 = 8 + 48
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: recordSize.littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: CanonPTPCommand.eosObjectAddedEx2.littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x5678).littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x00010001).littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0x3801).littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(512).littleEndian) { Data($0) }) // sizeLow
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(1).littleEndian) { Data($0) }) // sizeHigh
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
|
|
||||||
blob.append(contentsOf: withUnsafeBytes(of: UInt32(0).littleEndian) { Data($0) })
|
|
||||||
blob.append("LARGE.JPG".data(using: .ascii)!)
|
|
||||||
blob.append(0)
|
|
||||||
|
|
||||||
let events = CanonEosEventParser.parse(blob)
|
|
||||||
XCTAssertEqual(events.count, 1)
|
|
||||||
guard case .objectAdded(let added) = events[0] else {
|
|
||||||
return XCTFail("expected objectAdded")
|
|
||||||
}
|
|
||||||
XCTAssertEqual(added.objectHandle, 0x5678)
|
|
||||||
XCTAssertEqual(added.size, 512 | (1 << 32))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试支持的文件名扩展名过滤。
|
|
||||||
func testSupportedFilenameExtensions() {
|
|
||||||
XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.JPG"))
|
|
||||||
XCTAssertTrue(CanonPTPHelper.isSupportedFilename("photo.cr2"))
|
|
||||||
XCTAssertFalse(CanonPTPHelper.isSupportedFilename("photo.tif"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
//
|
|
||||||
// USBDeviceBrowserControllerTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
/// USB 设备浏览器 teardown 行为测试。
|
|
||||||
final class USBDeviceBrowserControllerTests: XCTestCase {
|
|
||||||
/// 测试 stop 后重置授权缓存与 stopped 标记,便于再次进入页面时重新申请权限。
|
|
||||||
func testStopResetsAuthorizationAndStoppedFlag() {
|
|
||||||
let controller = USBDeviceBrowserController()
|
|
||||||
controller.stop()
|
|
||||||
|
|
||||||
XCTAssertTrue(controller.isStoppedForTesting)
|
|
||||||
XCTAssertFalse(controller.isAuthorizationGrantedForTesting)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试断开标记可被再次进入时的 re-enumerate 逻辑读取。
|
|
||||||
func testReconnectHintMarksAndExpires() {
|
|
||||||
USBCameraReconnectHint.markDisconnected()
|
|
||||||
XCTAssertTrue(USBCameraReconnectHint.shouldReEnumerate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,374 +0,0 @@
|
|||||||
//
|
|
||||||
// CameraTransferPipelineTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
/// 相机传输管道测试,使用 Mock 相机与上传 Sink。
|
|
||||||
final class CameraTransferPipelineTests: XCTestCase {
|
|
||||||
/// 测试新照片下载后触发上传 Sink,并持久化相对路径。
|
|
||||||
func testNewAssetTriggersUploadSink() async {
|
|
||||||
let camera = MockCameraService()
|
|
||||||
let sink = MockUploadSink()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("new_asset")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
|
||||||
|
|
||||||
var latestTasks: [CameraTransferTask] = []
|
|
||||||
attachPipeline(pipeline, onTasksUpdated: { latestTasks = $0 })
|
|
||||||
|
|
||||||
let asset = CameraAsset(id: "ptp_1", filename: "DSC_001.JPG", fileSize: 1024)
|
|
||||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_001.JPG")
|
|
||||||
try? Data(repeating: 0xFF, count: 8).write(to: tempURL)
|
|
||||||
|
|
||||||
camera.onNewAsset?(asset)
|
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadCount, 1)
|
|
||||||
let savedTask = latestTasks.first { $0.assetID == asset.id }
|
|
||||||
XCTAssertEqual(
|
|
||||||
savedTask?.localPath?.hasPrefix("CameraDownloads/test_account/\(scope.albumID)/originals/"),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
XCTAssertEqual(savedTask?.localPath?.hasPrefix("/"), false)
|
|
||||||
if let localURL = savedTask?.localURL {
|
|
||||||
try? FileManager.default.removeItem(at: localURL)
|
|
||||||
}
|
|
||||||
try? FileManager.default.removeItem(at: tempURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试重传可从相对路径恢复管道任务并上传。
|
|
||||||
func testRetryUploadRehydratesFromRelativePath() async throws {
|
|
||||||
let camera = MockCameraService()
|
|
||||||
let sink = MockUploadSink()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("retry")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
|
|
||||||
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "retry_pipeline.JPG", scope: scope)
|
|
||||||
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
|
|
||||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
|
||||||
|
|
||||||
let relativePath = CameraDownloadStorage.relativePath(for: fileURL)
|
|
||||||
await pipeline.retryUpload(
|
|
||||||
assetID: "asset_retry_001",
|
|
||||||
filename: "retry_pipeline.JPG",
|
|
||||||
localPath: relativePath
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadCount, 1)
|
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "asset_retry_001")?.status, .uploaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试手动上传多张照片时按并发上限上传,而不是串行一张张上传。
|
|
||||||
func testManualUploadAssetsUsesConcurrentLimit() async throws {
|
|
||||||
let camera = MockCameraService()
|
|
||||||
let sink = MockSlowUploadSink()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("manual")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
|
||||||
camera.listedAssets = (1 ... 6).map { index in
|
|
||||||
CameraAsset(id: "manual_\(index)", filename: "MANUAL_\(index).JPG", fileSize: 1024)
|
|
||||||
}
|
|
||||||
|
|
||||||
await pipeline.syncExistingPhotos()
|
|
||||||
await pipeline.uploadAssets(withIDs: camera.listedAssets.map(\.id))
|
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadCount, 6)
|
|
||||||
XCTAssertGreaterThan(sink.maxActiveUploads, 1)
|
|
||||||
XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试拍后传输下载的旧照片,切到边拍边传后不会被新照片触发自动上传。
|
|
||||||
func testSwitchingToLiveCaptureDoesNotUploadPostShootDownloads() async throws {
|
|
||||||
let camera = MockCameraService()
|
|
||||||
let sink = MockUploadSink()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("mode_switch")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: false, downloadScope: scope)
|
|
||||||
attachPipeline(pipeline)
|
|
||||||
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "post_001", filename: "POST_001.JPG", fileSize: 1024))
|
|
||||||
try await Task.sleep(nanoseconds: 400_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadedFileNames, [])
|
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)
|
|
||||||
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "live_001", filename: "LIVE_001.JPG", fileSize: 1024))
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadedFileNames, ["LIVE_001.JPG"])
|
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "post_001")?.status, .downloaded)
|
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "live_001")?.status, .uploaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试边拍边传模式下刷新历史照片只同步到本地,不授予自动上传资格。
|
|
||||||
func testSyncExistingPhotosDoesNotAutoUploadInLiveCaptureMode() async throws {
|
|
||||||
let camera = MockCameraService()
|
|
||||||
let sink = MockUploadSink()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("sync_existing")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
|
||||||
camera.listedAssets = [
|
|
||||||
CameraAsset(id: "history_001", filename: "HISTORY_001.JPG", fileSize: 1024)
|
|
||||||
]
|
|
||||||
|
|
||||||
await pipeline.syncExistingPhotos()
|
|
||||||
try await Task.sleep(nanoseconds: 200_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadedFileNames, [])
|
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "history_001")?.status, .downloaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试边拍边传连拍时,多张照片会按并发上限同时上传。
|
|
||||||
func testBurstCaptureUploadsAllAssetsWhileFirstUploadInProgress() async throws {
|
|
||||||
let camera = MockBurstCameraService()
|
|
||||||
let sink = MockSlowUploadSink()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("burst")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
|
||||||
attachPipeline(pipeline)
|
|
||||||
|
|
||||||
for index in 1 ... 10 {
|
|
||||||
let filename = "DSC_\(index).JPG"
|
|
||||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
||||||
try Data(repeating: UInt8(index), count: 8).write(to: tempURL)
|
|
||||||
camera.downloadURLs[filename] = tempURL
|
|
||||||
camera.onNewAsset?(
|
|
||||||
CameraAsset(id: "ptp_\(index)", filename: filename, fileSize: 1024)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
try await Task.sleep(nanoseconds: 2_000_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(sink.uploadCount, 10)
|
|
||||||
XCTAssertGreaterThan(sink.maxActiveUploads, 1)
|
|
||||||
XCTAssertLessThanOrEqual(sink.maxActiveUploads, 3)
|
|
||||||
for index in 1 ... 10 {
|
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "ptp_\(index)")?.status, .uploaded)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试高频上传进度会被合并通知,避免连拍时频繁刷新 SwiftUI 列表。
|
|
||||||
func testUploadProgressNotificationsAreThrottled() async throws {
|
|
||||||
let camera = MockCameraService()
|
|
||||||
let sink = MockChattyUploadSink()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("chatty")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
|
|
||||||
|
|
||||||
var updateCount = 0
|
|
||||||
attachPipeline(pipeline, onTasksUpdated: { _ in updateCount += 1 })
|
|
||||||
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_chatty", filename: "chatty.JPG", fileSize: 1024))
|
|
||||||
try await Task.sleep(nanoseconds: 800_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(pipeline.task(forAssetID: "ptp_chatty")?.status, .uploaded)
|
|
||||||
XCTAssertLessThan(updateCount, 18)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 shutdown 会调用相机服务完整释放。
|
|
||||||
func testShutdownCallsCameraService() async {
|
|
||||||
let camera = DisconnectTrackingMockCameraService()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
|
|
||||||
await pipeline.shutdown()
|
|
||||||
|
|
||||||
XCTAssertEqual(camera.shutdownCount, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 detachFromUI 仅清回调,不 shutdown 相机。
|
|
||||||
func testDetachFromUIDoesNotShutdownCameraService() async {
|
|
||||||
let camera = DisconnectTrackingMockCameraService()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
attachPipeline(pipeline)
|
|
||||||
|
|
||||||
pipeline.detachFromUI()
|
|
||||||
|
|
||||||
XCTAssertEqual(camera.detachCount, 1)
|
|
||||||
XCTAssertEqual(camera.shutdownCount, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试删除后管道不会在新片 notify 时再次带回已删 asset。
|
|
||||||
func testRemovedAssetStaysExcludedWhenNewAssetArrives() async throws {
|
|
||||||
let camera = MockCameraService()
|
|
||||||
let pipeline = CameraTransferPipeline(cameraService: camera)
|
|
||||||
let scope = makeScope("removed_asset")
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
pipeline.configure(uploadSink: nil, uploadEnabled: false, downloadScope: scope)
|
|
||||||
attachPipeline(pipeline)
|
|
||||||
|
|
||||||
let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD.JPG")
|
|
||||||
let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW.JPG")
|
|
||||||
try Data(repeating: 0x01, count: 8).write(to: oldURL)
|
|
||||||
try Data(repeating: 0x02, count: 8).write(to: newURL)
|
|
||||||
defer {
|
|
||||||
try? FileManager.default.removeItem(at: oldURL)
|
|
||||||
try? FileManager.default.removeItem(at: newURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_old", filename: "OLD.JPG", fileSize: 8))
|
|
||||||
try await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
pipeline.removeAsset(assetID: "ptp_old")
|
|
||||||
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
|
|
||||||
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_new", filename: "NEW.JPG", fileSize: 8))
|
|
||||||
try await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
|
|
||||||
XCTAssertNil(pipeline.task(forAssetID: "ptp_old"))
|
|
||||||
XCTAssertNotNil(pipeline.task(forAssetID: "ptp_new"))
|
|
||||||
XCTAssertTrue(pipeline.excludedAssetIDs.contains("ptp_old"))
|
|
||||||
}
|
|
||||||
|
|
||||||
private func attachPipeline(
|
|
||||||
_ pipeline: CameraTransferPipeline,
|
|
||||||
onTasksUpdated: @escaping ([CameraTransferTask]) -> Void = { _ in }
|
|
||||||
) {
|
|
||||||
pipeline.attachToUI(onConnectionStateChange: { _ in }, onTasksUpdated: onTasksUpdated)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeScope(_ albumName: String) -> CameraDownloadStorage.Scope {
|
|
||||||
let albumID = albumName.unicodeScalars.reduce(0) { partial, scalar in
|
|
||||||
(partial + Int(scalar.value)) % 10_000
|
|
||||||
}
|
|
||||||
return CameraDownloadStorage.Scope(accountKey: "test_account", albumID: albumID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class DisconnectTrackingMockCameraService: CameraServiceProtocol {
|
|
||||||
let brand: CameraBrand = .sony
|
|
||||||
var connectionState: CameraConnectionState = .disconnected
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
private(set) var detachCount = 0
|
|
||||||
private(set) var shutdownCount = 0
|
|
||||||
|
|
||||||
func connect() async {}
|
|
||||||
func detachFromUI() { detachCount += 1 }
|
|
||||||
func shutdown() async { shutdownCount += 1 }
|
|
||||||
func listAssets() async throws -> [CameraAsset] { [] }
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockCameraService: CameraServiceProtocol {
|
|
||||||
let brand: CameraBrand = .sony
|
|
||||||
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
var listedAssets: [CameraAsset] = []
|
|
||||||
|
|
||||||
func connect() async {}
|
|
||||||
func detachFromUI() {}
|
|
||||||
func shutdown() async {}
|
|
||||||
|
|
||||||
func listAssets() async throws -> [CameraAsset] {
|
|
||||||
listedAssets
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
|
|
||||||
if !FileManager.default.fileExists(atPath: url.path) {
|
|
||||||
try Data(repeating: 0xFF, count: 8).write(to: url)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockBurstCameraService: CameraServiceProtocol {
|
|
||||||
let brand: CameraBrand = .sony
|
|
||||||
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
var downloadURLs: [String: URL] = [:]
|
|
||||||
|
|
||||||
func connect() async {}
|
|
||||||
func detachFromUI() {}
|
|
||||||
func shutdown() async {}
|
|
||||||
|
|
||||||
func listAssets() async throws -> [CameraAsset] { [] }
|
|
||||||
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
try await Task.sleep(nanoseconds: 30_000_000)
|
|
||||||
if let url = downloadURLs[asset.filename] {
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
|
|
||||||
if !FileManager.default.fileExists(atPath: url.path) {
|
|
||||||
try Data(repeating: 0xFF, count: 8).write(to: url)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockSlowUploadSink: CameraAssetUploadSink {
|
|
||||||
var uploadCount = 0
|
|
||||||
var activeUploads = 0
|
|
||||||
var maxActiveUploads = 0
|
|
||||||
|
|
||||||
func upload(
|
|
||||||
localURL: URL,
|
|
||||||
fileName: String,
|
|
||||||
fileType: Int,
|
|
||||||
progress: @escaping @Sendable (Int) -> Void
|
|
||||||
) async throws -> String {
|
|
||||||
uploadCount += 1
|
|
||||||
activeUploads += 1
|
|
||||||
maxActiveUploads = max(maxActiveUploads, activeUploads)
|
|
||||||
try await Task.sleep(nanoseconds: 150_000_000)
|
|
||||||
activeUploads -= 1
|
|
||||||
progress(100)
|
|
||||||
return "https://cdn/mock/\(fileName)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockUploadSink: CameraAssetUploadSink {
|
|
||||||
var uploadCount = 0
|
|
||||||
var uploadedFileNames: [String] = []
|
|
||||||
|
|
||||||
func upload(
|
|
||||||
localURL: URL,
|
|
||||||
fileName: String,
|
|
||||||
fileType: Int,
|
|
||||||
progress: @escaping @Sendable (Int) -> Void
|
|
||||||
) async throws -> String {
|
|
||||||
uploadCount += 1
|
|
||||||
uploadedFileNames.append(fileName)
|
|
||||||
progress(100)
|
|
||||||
return "https://cdn/mock/\(fileName)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockChattyUploadSink: CameraAssetUploadSink {
|
|
||||||
func upload(
|
|
||||||
localURL: URL,
|
|
||||||
fileName: String,
|
|
||||||
fileType: Int,
|
|
||||||
progress: @escaping @Sendable (Int) -> Void
|
|
||||||
) async throws -> String {
|
|
||||||
for value in 0 ... 100 {
|
|
||||||
progress(value)
|
|
||||||
}
|
|
||||||
return "https://cdn/mock/\(fileName)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
//
|
|
||||||
// SonyPTPCommandsTests.swift
|
|
||||||
// suixinkanTests
|
|
||||||
//
|
|
||||||
// Created by Codex on 2026/6/29.
|
|
||||||
//
|
|
||||||
|
|
||||||
import XCTest
|
|
||||||
@testable import suixinkan
|
|
||||||
|
|
||||||
/// Sony PTP 命令构建与解析测试。
|
|
||||||
final class SonyPTPCommandsTests: XCTestCase {
|
|
||||||
/// 测试 OpenSession 命令包长度与 opcode。
|
|
||||||
func testBuildOpenSessionCommand() {
|
|
||||||
let data = SonyPTPHelper.buildCommand(
|
|
||||||
code: SonyPTPCommand.openSession,
|
|
||||||
transactionID: 1,
|
|
||||||
parameters: [1]
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(data.count, 16)
|
|
||||||
XCTAssertEqual(data[6], 0x02)
|
|
||||||
XCTAssertEqual(data[7], 0x10)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 SDIO Connect 命令 opcode。
|
|
||||||
func testBuildSonyConnectCommand() {
|
|
||||||
let data = SonyPTPHelper.buildCommand(
|
|
||||||
code: SonyPTPCommand.sdioConnect,
|
|
||||||
transactionID: 42,
|
|
||||||
parameters: [1, 0, 0]
|
|
||||||
)
|
|
||||||
|
|
||||||
XCTAssertEqual(data.count, 24)
|
|
||||||
XCTAssertEqual(data[6], 0x01)
|
|
||||||
XCTAssertEqual(data[7], 0x92)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 GetObject 压缩大小解析。
|
|
||||||
func testParseObjectCompressedSize() {
|
|
||||||
var data = Data(repeating: 0, count: 52)
|
|
||||||
data[8] = 0x00
|
|
||||||
data[9] = 0x10
|
|
||||||
data[10] = 0x00
|
|
||||||
data[11] = 0x00
|
|
||||||
|
|
||||||
XCTAssertEqual(SonyPTPHelper.parseObjectCompressedSize(data), 0x1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 SDIE ObjectAdded 事件解析。
|
|
||||||
func testParseObjectAddedEvent() throws {
|
|
||||||
var event = Data()
|
|
||||||
event.append(contentsOf: [0x10, 0x00, 0x00, 0x00])
|
|
||||||
event.append(contentsOf: [0x04, 0x00])
|
|
||||||
event.append(contentsOf: [0x01, 0xC2])
|
|
||||||
event.append(contentsOf: [0xFF, 0xFF, 0xFF, 0xFF])
|
|
||||||
event.append(contentsOf: [0x01, 0xC0, 0xFF, 0xFF])
|
|
||||||
|
|
||||||
let container = try SonyPTPHelper.parseContainer(event)
|
|
||||||
XCTAssertEqual(container.code, SonyPTPCommand.sdieObjectAdded)
|
|
||||||
XCTAssertEqual(container.params.first, SonyPTPCommand.shotObjectHandle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
76
suixinkanTests/TravelAlbum/CameraDownloadStorageTests.swift
Normal file
76
suixinkanTests/TravelAlbum/CameraDownloadStorageTests.swift
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
//
|
||||||
|
// CameraDownloadStorageTests.swift
|
||||||
|
// suixinkanTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import suixinkan
|
||||||
|
|
||||||
|
/// CameraDownloadStorage 相对路径持久化测试。
|
||||||
|
final class CameraDownloadStorageTests: XCTestCase {
|
||||||
|
/// 测试 scoped 原图相对路径前缀。
|
||||||
|
func testRelativePathUsesScopedOriginalsPrefix() {
|
||||||
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
|
let fileURL = CameraDownloadStorage.originalsDirectory(for: scope).appendingPathComponent("1730_test.JPG")
|
||||||
|
XCTAssertEqual(
|
||||||
|
CameraDownloadStorage.relativePath(for: fileURL),
|
||||||
|
"CameraDownloads/user_101/6603/originals/1730_test.JPG"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试相对路径解析。
|
||||||
|
func testResolveLocalURLFromRelativePath() {
|
||||||
|
let stored = "CameraDownloads/user_101/6603/originals/1730_test.JPG"
|
||||||
|
let resolved = CameraDownloadStorage.resolveLocalURL(from: stored)
|
||||||
|
let expected = CameraDownloadStorage.documentsDirectory.appendingPathComponent(stored)
|
||||||
|
XCTAssertEqual(resolved, expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试 scoped 目录路径。
|
||||||
|
func testScopeDirectoryPath() {
|
||||||
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
|
let directory = CameraDownloadStorage.scopeDirectory(for: scope)
|
||||||
|
XCTAssertTrue(directory.path.hasSuffix("CameraDownloads/user_101/6603"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试唯一原图 URL 生成。
|
||||||
|
func testUniqueLocalURLUsesScopedOriginalsDirectory() {
|
||||||
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
|
let url = CameraDownloadStorage.uniqueLocalURL(for: "DSC.JPG", scope: scope)
|
||||||
|
XCTAssertTrue(url.path.contains("/CameraDownloads/user_101/6603/originals/"))
|
||||||
|
XCTAssertTrue(url.lastPathComponent.hasSuffix("_DSC.JPG"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试删除 scoped 目录。
|
||||||
|
func testRemoveScopeDirectory() throws {
|
||||||
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "remove_test.JPG", scope: scope)
|
||||||
|
try Data(repeating: 0xFF, count: 8).write(to: fileURL)
|
||||||
|
|
||||||
|
let directory = CameraDownloadStorage.scopeDirectory(for: scope)
|
||||||
|
XCTAssertTrue(FileManager.default.fileExists(atPath: directory.path))
|
||||||
|
|
||||||
|
CameraDownloadStorage.removeScopeDirectory(for: scope)
|
||||||
|
XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 测试预览 URL 字符串。
|
||||||
|
func testPreviewURLStringFromExistingFile() throws {
|
||||||
|
let scope = CameraDownloadStorage.Scope(accountKey: "user_101", albumID: 6603)
|
||||||
|
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
||||||
|
|
||||||
|
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "preview.JPG", scope: scope)
|
||||||
|
try Data(repeating: 0xFF, count: 8).write(to: fileURL)
|
||||||
|
|
||||||
|
let stored = CameraDownloadStorage.relativePath(for: fileURL)
|
||||||
|
let preview = CameraDownloadStorage.previewURLString(from: stored)
|
||||||
|
XCTAssertFalse(preview.isEmpty)
|
||||||
|
XCTAssertTrue(preview.hasPrefix("file://"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,102 +12,15 @@ import UIKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
/// 有线传图 ViewModel 测试。
|
/// 有线传图 ViewModel 测试。
|
||||||
final class WiredCameraTransferViewModelTests: XCTestCase {
|
final class WiredCameraTransferViewModelTests: XCTestCase {
|
||||||
/// 测试相机管道回调的首张新照片应出现在列表中。
|
/// 测试 start 被取消时会 detach UI 回调。
|
||||||
func testFirstNewPipelineTaskAppearsInPhotos() async throws {
|
func testStartCancellationDetachesFromUI() async {
|
||||||
let camera = MockWiredCameraService()
|
|
||||||
let context = WiredTransferContext(
|
let context = WiredTransferContext(
|
||||||
albumId: 6603,
|
albumId: 6603,
|
||||||
albumName: "测试相册",
|
albumName: "测试相册",
|
||||||
phone: "",
|
phone: "",
|
||||||
orderNumber: ""
|
orderNumber: ""
|
||||||
)
|
)
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
let viewModel = WiredCameraTransferViewModel(context: context)
|
||||||
let account = AccountContext()
|
|
||||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
|
||||||
|
|
||||||
await viewModel.start(
|
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
|
||||||
scenicID: 1,
|
|
||||||
accountContext: account
|
|
||||||
)
|
|
||||||
|
|
||||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_NEW.JPG")
|
|
||||||
try Data(repeating: 0xFF, count: 16).write(to: tempURL)
|
|
||||||
defer { try? FileManager.default.removeItem(at: tempURL) }
|
|
||||||
|
|
||||||
let asset = CameraAsset(id: "ptp_new_001", filename: "DSC_NEW.JPG", fileSize: 16)
|
|
||||||
camera.downloadURL = tempURL
|
|
||||||
camera.onNewAsset?(asset)
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.photos.count, 1)
|
|
||||||
XCTAssertEqual(viewModel.photos.first?.id, "ptp_new_001")
|
|
||||||
XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试删除待上传照片后,边拍边传新片不会把已删照片带回列表。
|
|
||||||
func testDeletedPendingPhotoDoesNotReappearAfterLiveCapture() async throws {
|
|
||||||
let camera = MockWiredCameraService()
|
|
||||||
let context = WiredTransferContext(
|
|
||||||
albumId: 7701,
|
|
||||||
albumName: "删除回归测试",
|
|
||||||
phone: "",
|
|
||||||
orderNumber: ""
|
|
||||||
)
|
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
|
||||||
let account = AccountContext()
|
|
||||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
|
||||||
|
|
||||||
await viewModel.start(
|
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
|
||||||
scenicID: 1,
|
|
||||||
accountContext: account
|
|
||||||
)
|
|
||||||
|
|
||||||
let oldURL = FileManager.default.temporaryDirectory.appendingPathComponent("OLD_DELETE.JPG")
|
|
||||||
try Data(repeating: 0x11, count: 16).write(to: oldURL)
|
|
||||||
defer { try? FileManager.default.removeItem(at: oldURL) }
|
|
||||||
|
|
||||||
camera.downloadURL = oldURL
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_delete_me", filename: "OLD_DELETE.JPG", fileSize: 16))
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
XCTAssertEqual(viewModel.photos.count, 1)
|
|
||||||
|
|
||||||
viewModel.deletePhoto(id: "ptp_delete_me")
|
|
||||||
XCTAssertTrue(viewModel.photos.isEmpty)
|
|
||||||
|
|
||||||
viewModel.selectTransferMode(
|
|
||||||
WiredCameraTransferViewModel.modeLiveCapture,
|
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
|
||||||
scenicID: 1
|
|
||||||
)
|
|
||||||
|
|
||||||
let newURL = FileManager.default.temporaryDirectory.appendingPathComponent("NEW_LIVE.JPG")
|
|
||||||
try Data(repeating: 0x22, count: 16).write(to: newURL)
|
|
||||||
defer { try? FileManager.default.removeItem(at: newURL) }
|
|
||||||
|
|
||||||
camera.downloadURL = newURL
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_live_new", filename: "NEW_LIVE.JPG", fileSize: 16))
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.photos.count, 1)
|
|
||||||
XCTAssertEqual(viewModel.photos.first?.id, "ptp_live_new")
|
|
||||||
XCTAssertFalse(viewModel.photos.contains { $0.id == "ptp_delete_me" })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试 start 被取消时 detach UI 回调,但不 shutdown 相机连接。
|
|
||||||
func testStartCancellationDetachesWithoutShutdown() async {
|
|
||||||
let camera = DisconnectTrackingWiredCameraService()
|
|
||||||
let context = WiredTransferContext(
|
|
||||||
albumId: 6603,
|
|
||||||
albumName: "测试相册",
|
|
||||||
phone: "",
|
|
||||||
orderNumber: ""
|
|
||||||
)
|
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
|
||||||
let account = AccountContext()
|
let account = AccountContext()
|
||||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
||||||
|
|
||||||
@ -120,54 +33,11 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _ in 0 ..< 100 {
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
if camera.connectCount > 0 { break }
|
|
||||||
try? await Task.sleep(nanoseconds: 10_000_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
startTask.cancel()
|
startTask.cancel()
|
||||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||||
|
|
||||||
XCTAssertGreaterThanOrEqual(camera.detachCount, 1)
|
XCTAssertEqual(viewModel.connectionState, .disconnected)
|
||||||
XCTAssertEqual(camera.shutdownCount, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试绑定到其他相册的照片不会出现在当前相册列表。
|
|
||||||
func testPhotoBoundToOtherAlbumIsFilteredOut() async throws {
|
|
||||||
let bindingStore = WiredTransferPhotoStore(
|
|
||||||
accountPrefixProvider: { "user_101" },
|
|
||||||
userIDProvider: { "101" }
|
|
||||||
)
|
|
||||||
bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 99)
|
|
||||||
defer { bindingStore.bindPhotoToAlbum(photoID: "ptp_other_album", albumID: 1) }
|
|
||||||
|
|
||||||
let camera = MockWiredCameraService()
|
|
||||||
let context = WiredTransferContext(
|
|
||||||
albumId: 1,
|
|
||||||
albumName: "当前相册",
|
|
||||||
phone: "",
|
|
||||||
orderNumber: ""
|
|
||||||
)
|
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
|
||||||
let account = AccountContext()
|
|
||||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"), accountType: "user")
|
|
||||||
|
|
||||||
await viewModel.start(
|
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
|
||||||
scenicID: 1,
|
|
||||||
accountContext: account
|
|
||||||
)
|
|
||||||
|
|
||||||
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent("DSC_OTHER.JPG")
|
|
||||||
try Data(repeating: 0xAA, count: 8).write(to: tempURL)
|
|
||||||
defer { try? FileManager.default.removeItem(at: tempURL) }
|
|
||||||
|
|
||||||
camera.downloadURL = tempURL
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_other_album", filename: "DSC_OTHER.JPG", fileSize: 8))
|
|
||||||
try await Task.sleep(nanoseconds: 500_000_000)
|
|
||||||
|
|
||||||
XCTAssertTrue(viewModel.photos.isEmpty)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试批量上传按钮三态流转。
|
/// 测试批量上传按钮三态流转。
|
||||||
@ -209,8 +79,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
let today = formatter.string(from: Date())
|
let today = formatter.string(from: Date())
|
||||||
|
|
||||||
let viewModel = WiredCameraTransferViewModel(
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
|
||||||
cameraService: MockWiredCameraService()
|
|
||||||
)
|
)
|
||||||
viewModel.replacePhotos([
|
viewModel.replacePhotos([
|
||||||
makePendingPhoto(id: "today", capturedAt: today),
|
makePendingPhoto(id: "today", capturedAt: today),
|
||||||
@ -226,111 +95,8 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertNil(viewModel.errorMessage)
|
XCTAssertNil(viewModel.errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试上传进度更新时保留 capturedAt,避免列表分组抖动。
|
|
||||||
func testPipelineProgressUpdatePreservesCapturedAt() async throws {
|
|
||||||
let camera = MockWiredCameraService()
|
|
||||||
let context = WiredTransferContext(
|
|
||||||
albumId: 1,
|
|
||||||
albumName: "测试相册",
|
|
||||||
phone: "",
|
|
||||||
orderNumber: ""
|
|
||||||
)
|
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
|
||||||
let account = AccountContext()
|
|
||||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
|
||||||
|
|
||||||
await viewModel.start(
|
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
|
||||||
scenicID: 1,
|
|
||||||
accountContext: account
|
|
||||||
)
|
|
||||||
let originalCapturedAt = "2026-05-20 12:05:18"
|
|
||||||
viewModel.replacePhotos([
|
|
||||||
WiredTransferPhotoItem(
|
|
||||||
id: "ptp_progress",
|
|
||||||
fileName: "DSC_PROGRESS.JPG",
|
|
||||||
thumbnailURL: "",
|
|
||||||
previewURL: "",
|
|
||||||
capturedAt: originalCapturedAt,
|
|
||||||
fileSizeText: "2 MB",
|
|
||||||
status: .uploading,
|
|
||||||
progress: 10
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
let scope = downloadScope(account: account, albumID: context.albumId)
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "DSC_PROGRESS.JPG", scope: scope)
|
|
||||||
try Data(repeating: 0xAB, count: 16).write(to: fileURL)
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
|
|
||||||
camera.downloadURL = fileURL
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "ptp_progress", filename: "DSC_PROGRESS.JPG", fileSize: 16))
|
|
||||||
try await Task.sleep(nanoseconds: 300_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.photos.first?.capturedAt, originalCapturedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试较新拍摄时间的照片排在列表前面。
|
|
||||||
func testNewerCapturedPhotoAppearsFirst() async throws {
|
|
||||||
let camera = MockWiredCameraService()
|
|
||||||
let context = WiredTransferContext(
|
|
||||||
albumId: 6604,
|
|
||||||
albumName: "测试相册",
|
|
||||||
phone: "",
|
|
||||||
orderNumber: ""
|
|
||||||
)
|
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
|
||||||
let account = AccountContext()
|
|
||||||
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
|
|
||||||
|
|
||||||
await viewModel.start(
|
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
|
||||||
scenicID: 1,
|
|
||||||
accountContext: account
|
|
||||||
)
|
|
||||||
|
|
||||||
let fileURL = FileManager.default.temporaryDirectory
|
|
||||||
let olderURL = fileURL.appendingPathComponent("OLDER.JPG")
|
|
||||||
let newerURL = fileURL.appendingPathComponent("NEWER.JPG")
|
|
||||||
try Data(repeating: 0xAA, count: 8).write(to: olderURL)
|
|
||||||
try Data(repeating: 0xBB, count: 8).write(to: newerURL)
|
|
||||||
defer {
|
|
||||||
try? FileManager.default.removeItem(at: olderURL)
|
|
||||||
try? FileManager.default.removeItem(at: newerURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
camera.downloadURL = olderURL
|
|
||||||
camera.onNewAsset?(
|
|
||||||
CameraAsset(
|
|
||||||
id: "ptp_old",
|
|
||||||
filename: "OLDER.JPG",
|
|
||||||
fileSize: 8,
|
|
||||||
creationDate: Date(timeIntervalSince1970: 1_700_000_000)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try await Task.sleep(nanoseconds: 400_000_000)
|
|
||||||
|
|
||||||
camera.downloadURL = newerURL
|
|
||||||
camera.onNewAsset?(
|
|
||||||
CameraAsset(
|
|
||||||
id: "ptp_new",
|
|
||||||
filename: "NEWER.JPG",
|
|
||||||
fileSize: 8,
|
|
||||||
creationDate: Date(timeIntervalSince1970: 1_800_000_000)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
try await Task.sleep(nanoseconds: 400_000_000)
|
|
||||||
|
|
||||||
XCTAssertEqual(viewModel.photos.count, 2)
|
|
||||||
XCTAssertEqual(viewModel.photos[0].id, "ptp_new")
|
|
||||||
XCTAssertEqual(viewModel.photos[1].id, "ptp_old")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试拍后传输模式下重传会从持久化记录恢复上传。
|
/// 测试拍后传输模式下重传会从持久化记录恢复上传。
|
||||||
func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws {
|
func testRetryPhotoInPostShootModeUploadsPersistedFile() async throws {
|
||||||
let camera = MockWiredCameraService()
|
|
||||||
let context = WiredTransferContext(
|
let context = WiredTransferContext(
|
||||||
albumId: 1,
|
albumId: 1,
|
||||||
albumName: "测试相册",
|
albumName: "测试相册",
|
||||||
@ -368,7 +134,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
store.save(albumID: 1, records: [record])
|
store.save(albumID: 1, records: [record])
|
||||||
|
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
let viewModel = WiredCameraTransferViewModel(context: context)
|
||||||
viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer
|
viewModel.transferModeOption = WiredCameraTransferViewModel.modePostShootTransfer
|
||||||
|
|
||||||
let api = MockTravelAlbumAPIForWiredTransfer()
|
let api = MockTravelAlbumAPIForWiredTransfer()
|
||||||
@ -418,8 +184,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
store.save(albumID: albumID, records: records)
|
store.save(albumID: albumID, records: records)
|
||||||
|
|
||||||
let viewModel = WiredCameraTransferViewModel(
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: ""),
|
context: WiredTransferContext(albumId: albumID, albumName: "批量上传", phone: "", orderNumber: "")
|
||||||
cameraService: MockWiredCameraService()
|
|
||||||
)
|
)
|
||||||
let oss = WiredTransferMockOSSUploadService()
|
let oss = WiredTransferMockOSSUploadService()
|
||||||
oss.uploadDelayNanoseconds = 150_000_000
|
oss.uploadDelayNanoseconds = 150_000_000
|
||||||
@ -447,32 +212,36 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
/// 测试切换传输模式后会按账号缓存选项。
|
/// 测试切换传输模式后会按账号缓存选项。
|
||||||
func testSelectTransferModePersistsOption() async {
|
func testSelectTransferModePersistsOption() async {
|
||||||
let account = makeTransferModeTestAccount()
|
let account = makeTransferModeTestAccount()
|
||||||
let context = WiredTransferContext(albumId: 7701, albumName: "模式缓存", phone: "", orderNumber: "")
|
let store = makeTransferModeTestStore(account: account)
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService())
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
let api = MockTravelAlbumAPIForWiredTransfer()
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
|
||||||
let oss = WiredTransferMockOSSUploadService()
|
)
|
||||||
|
await viewModel.start(
|
||||||
|
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||||
|
ossService: WiredTransferMockOSSUploadService(),
|
||||||
|
scenicID: 1,
|
||||||
|
accountContext: account
|
||||||
|
)
|
||||||
|
|
||||||
await viewModel.start(api: api, ossService: oss, scenicID: 1, accountContext: account)
|
|
||||||
viewModel.selectTransferMode(
|
viewModel.selectTransferMode(
|
||||||
WiredCameraTransferViewModel.modePostShootTransfer,
|
WiredCameraTransferViewModel.modePostShootTransfer,
|
||||||
api: api,
|
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||||
ossService: oss,
|
ossService: WiredTransferMockOSSUploadService(),
|
||||||
scenicID: 1
|
scenicID: 1
|
||||||
)
|
)
|
||||||
|
|
||||||
let store = makeTransferModeTestStore(account: account)
|
|
||||||
XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer)
|
XCTAssertEqual(store.loadTransferModeOption(), WiredCameraTransferViewModel.modePostShootTransfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试再次进入有线传图页时会恢复上次选择的传输模式。
|
/// 测试进入页面时会恢复缓存的传输模式。
|
||||||
func testStartRestoresCachedTransferModeOption() async {
|
func testStartRestoresCachedTransferModeOption() async {
|
||||||
let account = makeTransferModeTestAccount()
|
let account = makeTransferModeTestAccount()
|
||||||
let store = makeTransferModeTestStore(account: account)
|
let store = makeTransferModeTestStore(account: account)
|
||||||
store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer)
|
store.saveTransferModeOption(WiredCameraTransferViewModel.modePostShootTransfer)
|
||||||
|
|
||||||
let context = WiredTransferContext(albumId: 7702, albumName: "模式恢复", phone: "", orderNumber: "")
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: MockWiredCameraService())
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
|
||||||
|
)
|
||||||
await viewModel.start(
|
await viewModel.start(
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
ossService: WiredTransferMockOSSUploadService(),
|
||||||
@ -483,37 +252,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer)
|
XCTAssertEqual(viewModel.transferModeOption, WiredCameraTransferViewModel.modePostShootTransfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试下载完成后生成本地缩略图,并优先用于列表展示。
|
|
||||||
func testDownloadedPhotoGeneratesThumbnailForList() async throws {
|
|
||||||
let camera = MockWiredCameraService()
|
|
||||||
let albumID = 6601
|
|
||||||
let context = WiredTransferContext(albumId: albumID, albumName: "缩略图相册", phone: "", orderNumber: "")
|
|
||||||
let viewModel = WiredCameraTransferViewModel(context: context, cameraService: camera)
|
|
||||||
let account = AccountContext()
|
|
||||||
account.applyLogin(profile: AccountProfile(userId: "thumb-user", displayName: "测试"))
|
|
||||||
|
|
||||||
await viewModel.start(
|
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
|
||||||
ossService: WiredTransferMockOSSUploadService(),
|
|
||||||
scenicID: 1,
|
|
||||||
accountContext: account
|
|
||||||
)
|
|
||||||
|
|
||||||
let scope = downloadScope(account: account, albumID: albumID)
|
|
||||||
let imageURL = CameraDownloadStorage.uniqueLocalURL(for: "thumb_source.JPG", scope: scope)
|
|
||||||
try makeJPEGData().write(to: imageURL)
|
|
||||||
defer { CameraDownloadStorage.removeScopeDirectory(for: scope) }
|
|
||||||
|
|
||||||
camera.downloadURL = imageURL
|
|
||||||
camera.onNewAsset?(CameraAsset(id: "thumb_asset_001", filename: "thumb_source.JPG", fileSize: 128))
|
|
||||||
try await Task.sleep(nanoseconds: 900_000_000)
|
|
||||||
|
|
||||||
let photo = try XCTUnwrap(viewModel.photos.first { $0.id == "thumb_asset_001" })
|
|
||||||
XCTAssertTrue(photo.thumbnailURL.contains("/CameraDownloads/\(scope.accountDirectoryName)/\(albumID)/thumbnails/"))
|
|
||||||
XCTAssertTrue(photo.previewURL.contains("/CameraDownloads/"))
|
|
||||||
XCTAssertFalse(photo.previewURL.contains("/thumbnails/"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。
|
/// 测试旧记录没有缩略图时列表先占位,但仍保留原图预览能力。
|
||||||
func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws {
|
func testLegacyRecordWithoutThumbnailKeepsPreviewURL() async throws {
|
||||||
let albumID = 6602
|
let albumID = 6602
|
||||||
@ -547,8 +285,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
store.save(albumID: albumID, records: [record])
|
store.save(albumID: albumID, records: [record])
|
||||||
|
|
||||||
let viewModel = WiredCameraTransferViewModel(
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: ""),
|
context: WiredTransferContext(albumId: albumID, albumName: "旧记录", phone: "", orderNumber: "")
|
||||||
cameraService: MockWiredCameraService()
|
|
||||||
)
|
)
|
||||||
await viewModel.start(
|
await viewModel.start(
|
||||||
api: MockTravelAlbumAPIForWiredTransfer(),
|
api: MockTravelAlbumAPIForWiredTransfer(),
|
||||||
@ -578,10 +315,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
|
CameraDownloadStorage.removeScopeDirectory(for: otherScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_target.JPG", scope: scope)
|
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: scope)
|
||||||
let otherFileURL = CameraDownloadStorage.uniqueLocalURL(for: "clear_other.JPG", scope: otherScope)
|
let otherURL = CameraDownloadStorage.uniqueLocalURL(for: "other_photo.JPG", scope: otherScope)
|
||||||
try Data(repeating: 0x01, count: 8).write(to: fileURL)
|
try makeJPEGData().write(to: storeURL)
|
||||||
try Data(repeating: 0x02, count: 8).write(to: otherFileURL)
|
try makeJPEGData().write(to: otherURL)
|
||||||
|
|
||||||
let store = WiredTransferPhotoStore(
|
let store = WiredTransferPhotoStore(
|
||||||
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
|
accountPrefixProvider: { account.accountCachePrefix ?? "guest_" },
|
||||||
@ -589,40 +326,29 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
store.save(albumID: albumID, records: [
|
store.save(albumID: albumID, records: [
|
||||||
makePhotoRecord(
|
makePhotoRecord(
|
||||||
id: "clear_target",
|
id: "store_photo",
|
||||||
fileName: "clear_target.JPG",
|
fileName: "store_photo.JPG",
|
||||||
localPath: CameraDownloadStorage.relativePath(for: fileURL),
|
localPath: CameraDownloadStorage.relativePath(for: storeURL),
|
||||||
albumID: albumID,
|
albumID: albumID,
|
||||||
userID: userID
|
userID: userID
|
||||||
)
|
)
|
||||||
])
|
])
|
||||||
store.save(albumID: otherAlbumID, records: [
|
store.clearAlbum(albumID: albumID)
|
||||||
makePhotoRecord(
|
|
||||||
id: "clear_other",
|
|
||||||
fileName: "clear_other.JPG",
|
|
||||||
localPath: CameraDownloadStorage.relativePath(for: otherFileURL),
|
|
||||||
albumID: otherAlbumID,
|
|
||||||
userID: userID
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
let deletedIDs = store.clearAlbum(albumID: albumID)
|
|
||||||
|
|
||||||
XCTAssertEqual(deletedIDs, Set(["clear_target"]))
|
|
||||||
XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path))
|
|
||||||
XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath))
|
XCTAssertFalse(FileManager.default.fileExists(atPath: scopeDirectoryPath))
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: otherFileURL.path))
|
|
||||||
XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath))
|
XCTAssertTrue(FileManager.default.fileExists(atPath: otherScopeDirectoryPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试同一用户 ID 在不同账号类型下的有线传图记录与物理目录隔离。
|
/// 测试同手机号不同账号类型的本地记录与物理目录隔离。
|
||||||
func testSameUserIDDifferentAccountTypesAreIsolated() throws {
|
func testSameUserIDDifferentAccountTypesAreIsolated() throws {
|
||||||
let userID = "multi-account-user"
|
let userID = "same-user-id"
|
||||||
let albumID = 8803
|
let albumID = 9901
|
||||||
|
|
||||||
let storeAccount = AccountContext()
|
let storeAccount = AccountContext()
|
||||||
storeAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "门店"), accountType: "store")
|
storeAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "门店"), accountType: "store")
|
||||||
let photographerAccount = AccountContext()
|
let photographerAccount = AccountContext()
|
||||||
photographerAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "摄影师"), accountType: "photographer")
|
photographerAccount.applyLogin(profile: AccountProfile(userId: userID, displayName: "摄影师"), accountType: "photographer")
|
||||||
|
|
||||||
let storeScope = downloadScope(account: storeAccount, albumID: albumID)
|
let storeScope = downloadScope(account: storeAccount, albumID: albumID)
|
||||||
let photographerScope = downloadScope(account: photographerAccount, albumID: albumID)
|
let photographerScope = downloadScope(account: photographerAccount, albumID: albumID)
|
||||||
defer {
|
defer {
|
||||||
@ -632,10 +358,10 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: storeScope)
|
let storeURL = CameraDownloadStorage.uniqueLocalURL(for: "store_photo.JPG", scope: storeScope)
|
||||||
let photographerURL = CameraDownloadStorage.uniqueLocalURL(for: "photographer_photo.JPG", scope: photographerScope)
|
let photographerURL = CameraDownloadStorage.uniqueLocalURL(for: "photographer_photo.JPG", scope: photographerScope)
|
||||||
try Data(repeating: 0x03, count: 8).write(to: storeURL)
|
try makeJPEGData().write(to: storeURL)
|
||||||
try Data(repeating: 0x04, count: 8).write(to: photographerURL)
|
try makeJPEGData().write(to: photographerURL)
|
||||||
|
|
||||||
let store = WiredTransferPhotoStore(
|
let storeStore = WiredTransferPhotoStore(
|
||||||
accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" },
|
accountPrefixProvider: { storeAccount.accountCachePrefix ?? "guest_" },
|
||||||
userIDProvider: { userID }
|
userIDProvider: { userID }
|
||||||
)
|
)
|
||||||
@ -643,7 +369,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
accountPrefixProvider: { photographerAccount.accountCachePrefix ?? "guest_" },
|
accountPrefixProvider: { photographerAccount.accountCachePrefix ?? "guest_" },
|
||||||
userIDProvider: { userID }
|
userIDProvider: { userID }
|
||||||
)
|
)
|
||||||
store.save(albumID: albumID, records: [
|
storeStore.save(albumID: albumID, records: [
|
||||||
makePhotoRecord(
|
makePhotoRecord(
|
||||||
id: "store_photo",
|
id: "store_photo",
|
||||||
fileName: "store_photo.JPG",
|
fileName: "store_photo.JPG",
|
||||||
@ -662,7 +388,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
XCTAssertEqual(store.load(albumID: albumID).map(\.id), ["store_photo"])
|
XCTAssertEqual(storeStore.load(albumID: albumID).map(\.id), ["store_photo"])
|
||||||
XCTAssertEqual(photographerStore.load(albumID: albumID).map(\.id), ["photographer_photo"])
|
XCTAssertEqual(photographerStore.load(albumID: albumID).map(\.id), ["photographer_photo"])
|
||||||
XCTAssertNotEqual(
|
XCTAssertNotEqual(
|
||||||
CameraDownloadStorage.scopeDirectory(for: storeScope).path,
|
CameraDownloadStorage.scopeDirectory(for: storeScope).path,
|
||||||
@ -672,8 +398,7 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
|
private func makeViewModelWithPendingPhotos() -> WiredCameraTransferViewModel {
|
||||||
let viewModel = WiredCameraTransferViewModel(
|
let viewModel = WiredCameraTransferViewModel(
|
||||||
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: ""),
|
context: WiredTransferContext(albumId: 1, albumName: "相册", phone: "", orderNumber: "")
|
||||||
cameraService: MockWiredCameraService()
|
|
||||||
)
|
)
|
||||||
viewModel.replacePhotos([
|
viewModel.replacePhotos([
|
||||||
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
|
makePendingPhoto(id: "pending-1", capturedAt: "2026-05-20 12:05:18"),
|
||||||
@ -741,63 +466,6 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class DisconnectTrackingWiredCameraService: CameraServiceProtocol {
|
|
||||||
let brand: CameraBrand = .sony
|
|
||||||
var connectionState: CameraConnectionState = .disconnected
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
private(set) var connectCount = 0
|
|
||||||
private(set) var detachCount = 0
|
|
||||||
private(set) var shutdownCount = 0
|
|
||||||
|
|
||||||
func connect() async {
|
|
||||||
connectCount += 1
|
|
||||||
connectionState = .connected(deviceName: "Mock")
|
|
||||||
onConnectionStateChange?(.connected(deviceName: "Mock"))
|
|
||||||
while !Task.isCancelled {
|
|
||||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func detachFromUI() { detachCount += 1 }
|
|
||||||
func shutdown() async { shutdownCount += 1 }
|
|
||||||
|
|
||||||
func listAssets() async throws -> [CameraAsset] { [] }
|
|
||||||
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private final class MockWiredCameraService: CameraServiceProtocol {
|
|
||||||
let brand: CameraBrand = .sony
|
|
||||||
var connectionState: CameraConnectionState = .connected(deviceName: "Mock")
|
|
||||||
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
|
|
||||||
var onNewAsset: ((CameraAsset) -> Void)?
|
|
||||||
var downloadURL: URL?
|
|
||||||
|
|
||||||
func connect() async {}
|
|
||||||
func detachFromUI() {}
|
|
||||||
func shutdown() async {}
|
|
||||||
|
|
||||||
func listAssets() async throws -> [CameraAsset] {
|
|
||||||
[]
|
|
||||||
}
|
|
||||||
|
|
||||||
func downloadAsset(_ asset: CameraAsset) async throws -> URL {
|
|
||||||
if let downloadURL {
|
|
||||||
return downloadURL
|
|
||||||
}
|
|
||||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(asset.filename)
|
|
||||||
if !FileManager.default.fileExists(atPath: url.path) {
|
|
||||||
try Data(repeating: 0xFF, count: 8).write(to: url)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
|
private final class MockTravelAlbumAPIForWiredTransfer: TravelAlbumServing {
|
||||||
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }
|
func availableOrders() async throws -> [TravelAlbumAvailableOrder] { [] }
|
||||||
|
|||||||
Reference in New Issue
Block a user