fix: suspend OTG live transfer on exit

This commit is contained in:
2026-07-08 09:32:35 +08:00
parent 92fcad7ac9
commit e9f593643f
4 changed files with 94 additions and 38 deletions

View File

@ -36,6 +36,7 @@ protocol WiredCameraConnectionManaging: AnyObject {
func configureLiveTransfer(albumID: Int?) func configureLiveTransfer(albumID: Int?)
func start() func start()
func unbindDelegate() func unbindDelegate()
func suspendLiveTransfer()
func disconnect() func disconnect()
} }
@ -86,6 +87,11 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging {
func configureLiveTransfer(albumID: Int?) { func configureLiveTransfer(albumID: Int?) {
liveTransferAlbumID = albumID liveTransferAlbumID = albumID
OTGLog.debug(.connection, "live transfer album=\(albumID.map(String.init) ?? "nil")") 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. /// Entry point when OTG UI appears: reconnect cached device or start discovery.
@ -115,6 +121,13 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging {
cancelSearchTimers() cancelSearchTimers()
} }
/// USB Session
func suspendLiveTransfer() {
OTGLog.info(.connection, "suspend live transfer")
liveTransferAlbumID = nil
stopLiveTransferServices()
}
/// Session Driver USB /// Session Driver USB
func disconnect() { func disconnect() {
guard let device = activeDevice else { guard let device = activeDevice else {
@ -131,12 +144,7 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging {
isClosingSession = true isClosingSession = true
isContentCatalogReady = false isContentCatalogReady = false
cancelSearchTimers() cancelSearchTimers()
sonyRemoteCapture?.stop() stopLiveTransferServices()
sonyRemoteCapture = nil
canonRemoteCapture?.stop()
canonRemoteCapture = nil
nikonLiveCapture?.stop()
nikonLiveCapture = nil
liveTransferAlbumID = nil liveTransferAlbumID = nil
currentDriver?.disconnect() currentDriver?.disconnect()
currentDriver = nil currentDriver = nil
@ -305,12 +313,7 @@ final class ConnectionManager: NSObject, WiredCameraConnectionManaging {
} }
private func clearDeviceCache() { private func clearDeviceCache() {
sonyRemoteCapture?.stop() stopLiveTransferServices()
sonyRemoteCapture = nil
canonRemoteCapture?.stop()
canonRemoteCapture = nil
nikonLiveCapture?.stop()
nikonLiveCapture = nil
cachedDevice = nil cachedDevice = nil
currentDriver?.disconnect() currentDriver?.disconnect()
currentDriver = nil currentDriver = nil
@ -428,24 +431,7 @@ private extension ConnectionManager {
await runGetDeviceInfoProbe(on: camera) await runGetDeviceInfoProbe(on: camera)
case .sony: case .sony:
let transactionID: UInt32 = 1 await startSonyLiveTransferIfNeeded(for: camera)
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")
}
default: default:
await runGetDeviceInfoProbe(on: camera) await runGetDeviceInfoProbe(on: camera)
@ -474,10 +460,12 @@ private extension ConnectionManager {
/// catalog Canon SetRemoteMode SD /// catalog Canon SetRemoteMode SD
private func startCanonLiveTransferIfNeeded(for camera: ICCameraDevice) { private func startCanonLiveTransferIfNeeded(for camera: ICCameraDevice) {
guard canonRemoteCapture == nil else { return } guard canonRemoteCapture == nil else { return }
guard liveTransferAlbumID != nil else { return }
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .canon else { return } guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .canon else { return }
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(nanoseconds: 500_000_000)
guard self.canonRemoteCapture == nil, self.liveTransferAlbumID != nil else { return }
let transactionID: UInt32 = 1 let transactionID: UInt32 = 1
let result = await CanonPTPHelper.initializeRemoteSession( let result = await CanonPTPHelper.initializeRemoteSession(
@ -504,6 +492,7 @@ private extension ConnectionManager {
/// catalog didAdd + catalog PTP /// catalog didAdd + catalog PTP
private func startNikonLiveTransferIfNeeded(for camera: ICCameraDevice) { private func startNikonLiveTransferIfNeeded(for camera: ICCameraDevice) {
guard nikonLiveCapture == nil else { return } guard nikonLiveCapture == nil else { return }
guard liveTransferAlbumID != nil else { return }
guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .nikon else { return } guard PlatformDetector.detect(from: buildDeviceInfo(from: camera)) == .nikon else { return }
let service = NikonCatalogLiveCaptureService() let service = NikonCatalogLiveCaptureService()
@ -513,6 +502,57 @@ private extension ConnectionManager {
OTGLog.info(.nikon, "Nikon catalog live capture ready") 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 { private func makeLiveShotSavedHandler() -> (String) -> Void {
{ [weak self] filename in { [weak self] filename in
guard let self, let albumID = self.liveTransferAlbumID else { return } guard let self, let albumID = self.liveTransferAlbumID else { return }
@ -557,12 +597,7 @@ extension ConnectionManager: ICCameraDeviceDelegate {
} }
isClosingSession = false isClosingSession = false
sonyRemoteCapture?.stop() stopLiveTransferServices()
sonyRemoteCapture = nil
canonRemoteCapture?.stop()
canonRemoteCapture = nil
nikonLiveCapture?.stop()
nikonLiveCapture = nil
currentDriver = nil currentDriver = nil
isContentCatalogReady = false isContentCatalogReady = false
if let camera = device as? ICCameraDevice { if let camera = device as? ICCameraDevice {

View File

@ -145,9 +145,10 @@ final class WiredCameraTransferViewModel {
connectionManager.start() connectionManager.start()
} }
/// UI delegate /// UI delegate
func stop() { func stop() {
connectionManager.unbindDelegate() connectionManager.unbindDelegate()
connectionManager.suspendLiveTransfer()
} }
/// ///

View File

@ -51,10 +51,15 @@ final class WiredCameraTransferViewController: BaseViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
viewModel.start()
applyViewModel() applyViewModel()
} }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
viewModel.start()
navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) { override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
viewModel.stop() viewModel.stop()

View File

@ -197,6 +197,17 @@ final class WiredCameraTransferViewModelTests: XCTestCase {
XCTAssertEqual(driver.downloadedObjectIds, []) XCTAssertEqual(driver.downloadedObjectIds, [])
XCTAssertTrue(viewModel.filteredPhotos().isEmpty) 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 /// ViewModel
@ -437,6 +448,7 @@ private final class MockWiredCameraConnectionManager: WiredCameraConnectionManag
var state: ConnectionState var state: ConnectionState
var currentDriver: CameraDriver? var currentDriver: CameraDriver?
var isContentCatalogReady: Bool var isContentCatalogReady: Bool
private(set) var suspendLiveTransferCallCount = 0
init( init(
state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"), state: ConnectionState = .connected(platform: .canon, deviceName: "Canon"),
@ -451,6 +463,9 @@ private final class MockWiredCameraConnectionManager: WiredCameraConnectionManag
func configureLiveTransfer(albumID: Int?) {} func configureLiveTransfer(albumID: Int?) {}
func start() {} func start() {}
func unbindDelegate() {} func unbindDelegate() {}
func suspendLiveTransfer() {
suspendLiveTransferCallCount += 1
}
func disconnect() { func disconnect() {
state = .disconnected state = .disconnected
} }