完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。
引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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] {
|
||||
[]
|
||||
|
||||
Reference in New Issue
Block a user