完善 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

@ -46,6 +46,40 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(viewModel.photos.first?.fileName, "DSC_NEW.JPG")
}
/// 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()
account.applyLogin(profile: AccountProfile(userId: "101", displayName: "测试"))
let startTask = Task {
await viewModel.start(
api: MockTravelAlbumAPIForWiredTransfer(),
ossService: WiredTransferMockOSSUploadService(),
scenicID: 1,
accountContext: account
)
}
for _ in 0 ..< 100 {
if camera.connectCount > 0 { break }
try? await Task.sleep(nanoseconds: 10_000_000)
}
startTask.cancel()
try? await Task.sleep(nanoseconds: 300_000_000)
XCTAssertGreaterThanOrEqual(camera.detachCount, 1)
XCTAssertEqual(camera.shutdownCount, 0)
}
///
func testPhotoBoundToOtherAlbumIsFilteredOut() async throws {
let bindingStore = WiredTransferPhotoStore(
@ -655,6 +689,35 @@ 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
@ -664,7 +727,8 @@ private final class MockWiredCameraService: CameraServiceProtocol {
var downloadURL: URL?
func connect() async {}
func disconnect() async {}
func detachFromUI() {}
func shutdown() async {}
func listAssets() async throws -> [CameraAsset] {
[]