完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。

引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 15:45:35 +08:00
parent 5f7ef24683
commit 90d135b5ac
34 changed files with 2870 additions and 310 deletions

View File

@ -21,7 +21,7 @@ final class CameraTransferPipelineTests: XCTestCase {
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
var latestTasks: [CameraTransferTask] = []
pipeline.onTasksUpdated = { latestTasks = $0 }
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")
@ -95,6 +95,7 @@ final class CameraTransferPipelineTests: XCTestCase {
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)
@ -138,6 +139,7 @@ final class CameraTransferPipelineTests: XCTestCase {
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"
@ -169,7 +171,7 @@ final class CameraTransferPipelineTests: XCTestCase {
pipeline.configure(uploadSink: sink, uploadEnabled: true, downloadScope: scope)
var updateCount = 0
pipeline.onTasksUpdated = { _ in updateCount += 1 }
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)
@ -178,6 +180,35 @@ final class CameraTransferPipelineTests: XCTestCase {
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)
}
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
@ -186,6 +217,24 @@ final class CameraTransferPipelineTests: XCTestCase {
}
}
@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
@ -195,7 +244,8 @@ private final class MockCameraService: CameraServiceProtocol {
var listedAssets: [CameraAsset] = []
func connect() async {}
func disconnect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] {
listedAssets
@ -219,7 +269,8 @@ private final class MockBurstCameraService: CameraServiceProtocol {
var downloadURLs: [String: URL] = [:]
func connect() async {}
func disconnect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] { [] }