From e9f593643f9e5b11468dfba9166f9828b77656db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Wed, 8 Jul 2026 09:32:35 +0800 Subject: [PATCH] fix: suspend OTG live transfer on exit --- .../OTG/Core/ConnectionManager.swift | 107 ++++++++++++------ .../WiredCameraTransferViewModel.swift | 3 +- .../WiredCameraTransferViewController.swift | 7 +- .../TravelAlbumViewModelTests.swift | 15 +++ 4 files changed, 94 insertions(+), 38 deletions(-) diff --git a/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift b/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift index 564923b..f55e8d0 100644 --- a/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift +++ b/suixinkan/Features/TravelAlbum/OTG/Core/ConnectionManager.swift @@ -36,6 +36,7 @@ protocol WiredCameraConnectionManaging: AnyObject { func configureLiveTransfer(albumID: Int?) func start() func unbindDelegate() + func suspendLiveTransfer() func disconnect() } @@ -86,6 +87,11 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging { func configureLiveTransfer(albumID: Int?) { liveTransferAlbumID = albumID OTGLog.debug(.connection, "live transfer album=\(albumID.map(String.init) ?? "nil")") + if albumID == nil { + stopLiveTransferServices() + return + } + resumeLiveTransferIfPossible() } /// Entry point when OTG UI appears: reconnect cached device or start discovery. @@ -115,6 +121,13 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging { cancelSearchTimers() } + /// 页面离开时暂停边拍边传轮询,保留 USB Session 与设备缓存。 + func suspendLiveTransfer() { + OTGLog.info(.connection, "suspend live transfer") + liveTransferAlbumID = nil + stopLiveTransferServices() + } + /// 断开 Session 并释放 Driver;缓存设备保留至 USB 拔出。 func disconnect() { guard let device = activeDevice else { @@ -131,12 +144,7 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging { isClosingSession = true isContentCatalogReady = false cancelSearchTimers() - sonyRemoteCapture?.stop() - sonyRemoteCapture = nil - canonRemoteCapture?.stop() - canonRemoteCapture = nil - nikonLiveCapture?.stop() - nikonLiveCapture = nil + stopLiveTransferServices() liveTransferAlbumID = nil currentDriver?.disconnect() currentDriver = nil @@ -305,12 +313,7 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging { } private func clearDeviceCache() { - sonyRemoteCapture?.stop() - sonyRemoteCapture = nil - canonRemoteCapture?.stop() - canonRemoteCapture = nil - nikonLiveCapture?.stop() - nikonLiveCapture = nil + stopLiveTransferServices() cachedDevice = nil currentDriver?.disconnect() currentDriver = nil @@ -428,24 +431,7 @@ private extension ConnectionManager { await runGetDeviceInfoProbe(on: camera) case .sony: - let transactionID: UInt32 = 1 - let result = await SonyPTPHelper.initializeRemoteSession( - on: camera, - startingTransactionID: transactionID - ) - if result.success { - let service = SonyRemoteCaptureService() - service.onShotSaved = makeLiveShotSavedHandler() - sonyRemoteCapture = service - service.start( - camera: camera, - startingTransactionID: result.nextTransactionID, - albumID: liveTransferAlbumID - ) - OTGLog.info(.sony, "Sony remote session ready") - } else { - OTGLog.error(.sony, "Sony remote session setup failed") - } + await startSonyLiveTransferIfNeeded(for: camera) default: await runGetDeviceInfoProbe(on: camera) @@ -474,10 +460,12 @@ private extension ConnectionManager { /// catalog 就绪后再初始化 Canon 远程模式,避免 SetRemoteMode 打断 SD 卡枚举。 private func startCanonLiveTransferIfNeeded(for camera: ICCameraDevice) { guard canonRemoteCapture == nil else { return } + guard liveTransferAlbumID != nil else { return } guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .canon else { return } Task { @MainActor in try? await Task.sleep(nanoseconds: 500_000_000) + guard self.canonRemoteCapture == nil, self.liveTransferAlbumID != nil else { return } let transactionID: UInt32 = 1 let result = await CanonPTPHelper.initializeRemoteSession( @@ -504,6 +492,7 @@ private extension ConnectionManager { /// catalog 就绪后启动尼康边拍边传(didAdd + catalog 轮询,无需 PTP 远程会话)。 private func startNikonLiveTransferIfNeeded(for camera: ICCameraDevice) { guard nikonLiveCapture == nil else { return } + guard liveTransferAlbumID != nil else { return } guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .nikon else { return } let service = NikonCatalogLiveCaptureService() @@ -513,6 +502,57 @@ private extension ConnectionManager { OTGLog.info(.nikon, "Nikon catalog live capture ready") } + private func startSonyLiveTransferIfNeeded(for camera: ICCameraDevice) async { + guard sonyRemoteCapture == nil else { return } + guard liveTransferAlbumID != nil else { return } + guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .sony else { return } + + let transactionID: UInt32 = 1 + let result = await SonyPTPHelper.initializeRemoteSession( + on: camera, + startingTransactionID: transactionID + ) + guard liveTransferAlbumID != nil else { return } + if result.success { + let service = SonyRemoteCaptureService() + service.onShotSaved = makeLiveShotSavedHandler() + sonyRemoteCapture = service + service.start( + camera: camera, + startingTransactionID: result.nextTransactionID, + albumID: liveTransferAlbumID + ) + OTGLog.info(.sony, "Sony remote session ready") + } else { + OTGLog.error(.sony, "Sony remote session setup failed") + } + } + + private func resumeLiveTransferIfPossible() { + guard let camera = cachedDevice, case .connected = state else { return } + switch PlatformDetector.detect(from: buildDeviceInfo(from: camera)) { + case .sony: + Task { await startSonyLiveTransferIfNeeded(for: camera) } + case .canon: + guard isContentCatalogReady else { return } + startCanonLiveTransferIfNeeded(for: camera) + case .nikon: + guard isContentCatalogReady else { return } + startNikonLiveTransferIfNeeded(for: camera) + case .unknown: + break + } + } + + private func stopLiveTransferServices() { + sonyRemoteCapture?.stop() + sonyRemoteCapture = nil + canonRemoteCapture?.stop() + canonRemoteCapture = nil + nikonLiveCapture?.stop() + nikonLiveCapture = nil + } + private func makeLiveShotSavedHandler() -> (String) -> Void { { [weak self] filename in guard let self, let albumID = self.liveTransferAlbumID else { return } @@ -557,12 +597,7 @@ extension ConnectionManager: ICCameraDeviceDelegate { } isClosingSession = false - sonyRemoteCapture?.stop() - sonyRemoteCapture = nil - canonRemoteCapture?.stop() - canonRemoteCapture = nil - nikonLiveCapture?.stop() - nikonLiveCapture = nil + stopLiveTransferServices() currentDriver = nil isContentCatalogReady = false if let camera = device as? ICCameraDevice { diff --git a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift index 32066d1..5f3f2b2 100644 --- a/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift +++ b/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift @@ -145,9 +145,10 @@ final class WiredCameraTransferViewModel { connectionManager.start() } - /// 页面消失时解绑 UI delegate,保留底层设备缓存。 + /// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。 func stop() { connectionManager.unbindDelegate() + connectionManager.suspendLiveTransfer() } /// 主动断开相机连接。 diff --git a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift index b763e1d..68b5e9c 100644 --- a/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift +++ b/suixinkan/UI/TravelAlbum/WiredCameraTransferViewController.swift @@ -51,10 +51,15 @@ final class WiredCameraTransferViewController: BaseViewController { override func viewDidLoad() { super.viewDidLoad() - viewModel.start() applyViewModel() } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + viewModel.start() + navigationController?.setNavigationBarHidden(true, animated: animated) + } + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) viewModel.stop() diff --git a/suixinkanTests/TravelAlbumViewModelTests.swift b/suixinkanTests/TravelAlbumViewModelTests.swift index 6461d8d..367a36d 100644 --- a/suixinkanTests/TravelAlbumViewModelTests.swift +++ b/suixinkanTests/TravelAlbumViewModelTests.swift @@ -197,6 +197,17 @@ final class WiredCameraTransferViewModelTests: XCTestCase { XCTAssertEqual(driver.downloadedObjectIds, []) XCTAssertTrue(viewModel.filteredPhotos().isEmpty) } + + func testStopSuspendsLiveTransferPolling() { + let context = makeOTGTestContext() + let manager = MockWiredCameraConnectionManager(driver: MockCameraDriver(objects: [])) + let viewModel = makeWiredViewModel(context: context, manager: manager) + + viewModel.start() + viewModel.stop() + + XCTAssertEqual(manager.suspendLiveTransferCallCount, 1) + } } /// 旅拍相册相机历史导入 ViewModel 测试。 @@ -437,6 +448,7 @@ private final class MockWiredCameraConnectionManager: WiredCameraConnectionManag var state: ConnectionState var currentDriver: CameraDriver? var isContentCatalogReady: Bool + private(set) var suspendLiveTransferCallCount = 0 init( state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"), @@ -451,6 +463,9 @@ private final class MockWiredCameraConnectionManager: WiredCameraConnectionManag func configureLiveTransfer(albumID: Int?) {} func start() {} func unbindDelegate() {} + func suspendLiveTransfer() { + suspendLiveTransferCallCount += 1 + } func disconnect() { state = .disconnected }