diff --git a/mirroringBooth/StoreTests/CameraPreviewStoreTests.swift b/mirroringBooth/StoreTests/CameraPreviewStoreTests.swift index c0b06df6..f60a60d8 100644 --- a/mirroringBooth/StoreTests/CameraPreviewStoreTests.swift +++ b/mirroringBooth/StoreTests/CameraPreviewStoreTests.swift @@ -47,7 +47,8 @@ struct CameraPreviewStoreTests { let store = CameraPreviewStore( browser: Browser(), manager: manager, - deviceName: deviceName + deviceName: deviceName, + watchConnectionManager: nil ) return (store, manager) } @@ -154,9 +155,9 @@ struct CameraPreviewStoreTests { @Test func 미러링_연결이_끊기면_상태가_반영됨() { let (store, _) = makeSUT() - store.send(.isMirroringDisconnected) + store.send(.isPrimaryDeviceDisconnected) - #expect(store.state.isMirroringDisconnected == true) + #expect(store.state.isPrimaryDeviceDisconnected == true) } // MARK: - reduce 테스트 (Browser 관련 Intent 대체) diff --git a/mirroringBooth/WatchStoreTests/WatchStoreTests.swift b/mirroringBooth/WatchStoreTests/WatchStoreTests.swift index 8912beac..e02f2745 100644 --- a/mirroringBooth/WatchStoreTests/WatchStoreTests.swift +++ b/mirroringBooth/WatchStoreTests/WatchStoreTests.swift @@ -101,7 +101,7 @@ struct WatchStoreTests { @Test func 대기_화면이_나타나면_대기_상태가_켜짐() { let store = makeSUT() - store.send(.setIsWaiting(true)) + store.send(.isWaiting(true)) #expect(store.state.isWaiting == true) } diff --git a/mirroringBooth/mirroringBooth/App/RootStore.swift b/mirroringBooth/mirroringBooth/App/RootStore.swift index a10170ec..df004195 100644 --- a/mirroringBooth/mirroringBooth/App/RootStore.swift +++ b/mirroringBooth/mirroringBooth/App/RootStore.swift @@ -70,7 +70,7 @@ extension RootStore { for await stream in advertiser.rootStream { switch stream { case .onHeartbeatTimeout: - await MainActor.run { + await MainActor.run { [weak self] in self?.send(.showTimeoutAlert(true)) } } diff --git a/mirroringBooth/mirroringBooth/App/RootView.swift b/mirroringBooth/mirroringBooth/App/RootView.swift index 169ab27d..3c21c9dd 100644 --- a/mirroringBooth/mirroringBooth/App/RootView.swift +++ b/mirroringBooth/mirroringBooth/App/RootView.swift @@ -23,8 +23,8 @@ struct RootView: View { case .advertising: AdvertisingView() - case .connectionList(let list, let browser): - ConnectionCheckView(list, browser: browser) + case .connectionList(let list, let browser, let watchConnectionManager): + ConnectionCheckView(list, browser: browser, watchConnectionManager: watchConnectionManager) case .completion: StreamingCompletionView() diff --git a/mirroringBooth/mirroringBooth/Core/Router.swift b/mirroringBooth/mirroringBooth/Core/Router.swift index 1809398b..8559c5cd 100644 --- a/mirroringBooth/mirroringBooth/Core/Router.swift +++ b/mirroringBooth/mirroringBooth/Core/Router.swift @@ -27,7 +27,7 @@ final class Router { enum CameraRoute: Hashable { case browsing case advertising - case connectionList(ConnectionList, Browser) + case connectionList(ConnectionList, Browser, WatchConnectionManager) case completion } diff --git a/mirroringBooth/mirroringBooth/Device/Camera/Browser/Browser.swift b/mirroringBooth/mirroringBooth/Device/Camera/Browser/Browser.swift index 7c6b3e69..71ed3e3f 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/Browser/Browser.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/Browser/Browser.swift @@ -45,6 +45,9 @@ final class Browser: NSObject, BrowserCommandDelegate { /// 타이머 모드 선택 명령 수신 콜백 var onSelectedTimerModeCommand: (() -> Void)? + /// 타이머 모드가 선택되었는지 여부 (워치 disconnect 무시용) + var isTimerModeSelected: Bool = false + // MARK: - Stream Manager let streamManager = BrowserStreamManager() diff --git a/mirroringBooth/mirroringBooth/Device/Camera/Browser/BrowsingView.swift b/mirroringBooth/mirroringBooth/Device/Camera/Browser/BrowsingView.swift index 9462d54d..90822518 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/Browser/BrowsingView.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/Browser/BrowsingView.swift @@ -93,9 +93,10 @@ struct BrowsingView: View { ConnectionList( cameraName: store.browser.myDeviceName, mirroringName: mirroringDevice.id, - remoteName: store.state.remoteDevice?.id ?? nil + remoteName: store.state.remoteDevice?.id ), - store.browser + store.browser, + store.watchConnectionManager ) ) } diff --git a/mirroringBooth/mirroringBooth/Device/Camera/Browser/Manager/BrowserCommandManager.swift b/mirroringBooth/mirroringBooth/Device/Camera/Browser/Manager/BrowserCommandManager.swift index 30ae52f7..e2cd8e49 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/Browser/Manager/BrowserCommandManager.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/Browser/Manager/BrowserCommandManager.swift @@ -15,6 +15,7 @@ protocol BrowserCommandDelegate: AnyObject { var onSelectedTimerModeCommand: (() -> Void)? { get } var mirroringHeartBeater: HeartBeater { get } var remoteHeartBeater: HeartBeater? { get } + var isTimerModeSelected: Bool { get set } } /// 명령 수신 및 실행을 담당하는 매니저 @@ -42,11 +43,14 @@ final class BrowserCommandManager { self?.delegate?.sendRemoteCommand(.navigateToRemoteComplete) } case .setRemoteMode: + delegate?.isTimerModeSelected = false DispatchQueue.main.async { [weak self] in self?.delegate?.onRemoteModeCommand?() self?.delegate?.sendRemoteCommand(.navigateToRemoteCapture) } case .selectedTimerMode: + // 워치 disconnect 무시를 위해 동기적으로 먼저 플래그 설정 + delegate?.isTimerModeSelected = true DispatchQueue.main.async { [weak self] in self?.delegate?.onSelectedTimerModeCommand?() self?.delegate?.sendRemoteCommand(.navigateToHome) diff --git a/mirroringBooth/mirroringBooth/Device/Camera/Browser/WatchConnectionManager+iOS.swift b/mirroringBooth/mirroringBooth/Device/Camera/Browser/WatchConnectionManager+iOS.swift index d4b2b45e..9ff2c4a7 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/Browser/WatchConnectionManager+iOS.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/Browser/WatchConnectionManager+iOS.swift @@ -40,6 +40,7 @@ final class WatchConnectionManager: NSObject { var onReachableChanged: ((Bool) -> Void)? var onReceiveCaptureRequest: (() -> Void)? var onReceiveConnectionAck: (() -> Void)? + var onWatchDisconnected: (() -> Void)? override init() { if WCSession.isSupported() { @@ -239,6 +240,11 @@ extension WatchConnectionManager: WCSessionDelegate { self.prepareWatchToCapture() } } + } else if actionValue == ActionValue.disconnect.rawValue { + self.logger.info("워치에서 연결 해제 요청 수신됨.") + Task { @MainActor in + self.onWatchDisconnected?() + } } } diff --git a/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckStore.swift b/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckStore.swift index b886c78b..b33459a5 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckStore.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckStore.swift @@ -36,6 +36,7 @@ final class ConnectionCheckStore: StoreProtocol { private(set) var state: State = .init() let browser: Browser + let watchConnectionManager: WatchConnectionManager let cameraDevice: String let mirroringDevice: String @@ -43,11 +44,13 @@ final class ConnectionCheckStore: StoreProtocol { init( _ list: ConnectionList, - _ browser: Browser + _ browser: Browser, + _ watchConnectionManager: WatchConnectionManager ) { self.cameraDevice = list.cameraName self.mirroringDevice = list.mirroringName self.browser = browser + self.watchConnectionManager = watchConnectionManager Task { @MainActor in reduce(.setRemoteDevice(list.remoteName)) diff --git a/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckView.swift b/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckView.swift index 1ec83b31..a9c2839d 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckView.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/ConnectionCheck/ConnectionCheckView.swift @@ -11,8 +11,8 @@ struct ConnectionCheckView: View { @Environment(Router.self) var router: Router @State private var store: ConnectionCheckStore - init(_ list: ConnectionList, browser: Browser) { - self.store = .init(list, browser) + init(_ list: ConnectionList, browser: Browser, watchConnectionManager: WatchConnectionManager) { + self.store = .init(list, browser, watchConnectionManager) } var body: some View { @@ -86,6 +86,7 @@ struct ConnectionCheckView: View { CameraPreview( store.browser, mirroringName: store.mirroringDevice, + watchConnectionManager: store.watchConnectionManager, onDismissByCaptureCompletion: { store.send(.navigateToCompletion(true)) } diff --git a/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreview.swift b/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreview.swift index 649e680b..8b87d007 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreview.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreview.swift @@ -19,9 +19,15 @@ struct CameraPreview: View { init( _ browser: Browser, mirroringName: String, + watchConnectionManager: WatchConnectionManager?, onDismissByCaptureCompletion: (() -> Void)? = nil ) { - self.store = .init(browser: browser, manager: CameraManager(), deviceName: mirroringName) + self.store = .init( + browser: browser, + manager: CameraManager(), + deviceName: mirroringName, + watchConnectionManager: watchConnectionManager + ) self.onDismissByCaptureCompletion = onDismissByCaptureCompletion } @@ -93,7 +99,7 @@ struct CameraPreview: View { } .homeAlert( isPresented: Binding( - get: { store.state.isMirroringDisconnected }, + get: { store.state.isPrimaryDeviceDisconnected }, set: { _ in } ), message: "기기 연결이 끊겼습니다.", diff --git a/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreviewStore.swift b/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreviewStore.swift index ed34ad64..244a70b8 100644 --- a/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreviewStore.swift +++ b/mirroringBooth/mirroringBooth/Device/Camera/Streaming/CameraPreviewStore.swift @@ -21,14 +21,14 @@ final class CameraPreviewStore: StoreProtocol { var animationFlag: Bool = false var isTransferring: Bool = false var isCaptureCompleted: Bool = false - var isMirroringDisconnected: Bool = false + var isPrimaryDeviceDisconnected: Bool = false } enum Intent { case entry(withAngle: Int) case exit case updateAngle(rawValue: Int) - case isMirroringDisconnected + case isPrimaryDeviceDisconnected case browserEvent(CameraStreamEvents) } @@ -41,12 +41,13 @@ final class CameraPreviewStore: StoreProtocol { case setIsTransferring(Bool) case captureCompleted case resetCaptureCompleted - case isMirroringDisconnected + case setIsPrimaryDeviceDisconnected } private(set) var state: State private let browser: Browser private let cameraManager: CameraManageable + private let watchConnectionManager: WatchConnectionManager? private var cancellables = Set() private var heartbeatTask: Task? @@ -58,12 +59,15 @@ final class CameraPreviewStore: StoreProtocol { browser: Browser, manager: CameraManageable, deviceName: String, + watchConnectionManager: WatchConnectionManager? ) { self.browser = browser self.cameraManager = manager + self.watchConnectionManager = watchConnectionManager self.state = State(deviceName: deviceName) setupHeartbeatListener() + setupWatchConnectionListener() } deinit { @@ -89,8 +93,8 @@ final class CameraPreviewStore: StoreProtocol { case .updateAngle(let rawValue): return [.updateAngle(rawValue)] - case .isMirroringDisconnected: - return [.isMirroringDisconnected] + case .isPrimaryDeviceDisconnected: + return [.setIsPrimaryDeviceDisconnected] case .browserEvent(let event): return handleBrowserEvent(event) @@ -122,8 +126,8 @@ final class CameraPreviewStore: StoreProtocol { case .resetCaptureCompleted: state.isCaptureCompleted = false - case .isMirroringDisconnected: - state.isMirroringDisconnected = true + case .setIsPrimaryDeviceDisconnected: + state.isPrimaryDeviceDisconnected = true } self.state = state } @@ -187,16 +191,32 @@ extension CameraPreviewStore { await MainActor.run { switch event { case .heartbeatTimeout: - self.send(.isMirroringDisconnected) - self.browser.disconnect(useType: .remote) + self.handleDisconnection() case .remoteHeartbeatTimeout: - self.browser.sendCommand(.switchSelectModeView) + self.handleDisconnection() } } } } } + private func setupWatchConnectionListener() { + watchConnectionManager?.onWatchDisconnected = { [weak self] in + guard let self else { return } + Task { @MainActor in + // 타이머 모드가 선택되었으면 워치 disconnect 무시 + guard !self.browser.isTimerModeSelected else { return } + self.handleDisconnection() + } + } + } + + @MainActor + private func handleDisconnection() { + send(.isPrimaryDeviceDisconnected) + browser.disconnect() + } + private func handleBrowserEvent(_ event: CameraStreamEvents) -> [Result] { switch event { case .sendPhoto: diff --git a/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionManger+watchOS.swift b/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionManger+watchOS.swift index cec04f06..da5bc2a0 100644 --- a/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionManger+watchOS.swift +++ b/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionManger+watchOS.swift @@ -158,6 +158,14 @@ final class WatchConnectionManager: NSObject { ) } + /// 사용자가 X 버튼을 눌러 연결을 해제했음을 iPhone에 알립니다. + func sendDisconnectRequest() { + self.sendMessage( + action: .disconnect, + rejectedActionString: "연결 해제 요청을 보낼 수 없습니다." + ) + } + private nonisolated func handleAppStateUpdate(_ applicationContext: [String: Any]) { let appStateRawValue = applicationContext[MessageKey.appState.rawValue] as? String let appStateValue = appStateRawValue.flatMap { AppStateValue(rawValue: $0) } diff --git a/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionStore.swift b/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionStore.swift index 7a194c66..a3b1976b 100644 --- a/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionStore.swift +++ b/mirroringBooth/mirroringBooth/Device/Remote/AppleWatch/WatchConnectionStore.swift @@ -78,6 +78,7 @@ final class WatchConnectionStore: StoreProtocol { self.connectionManager.start() case .disconnect: + self.connectionManager.sendDisconnectRequest() self.connectionManager.stop() return [.setConnectionState(.notConnected)] diff --git a/mirroringBooth/mirroringBooth/Device/Remote/RemoteCaptureView.swift b/mirroringBooth/mirroringBooth/Device/Remote/RemoteCaptureView.swift index 787f4d54..175376c9 100644 --- a/mirroringBooth/mirroringBooth/Device/Remote/RemoteCaptureView.swift +++ b/mirroringBooth/mirroringBooth/Device/Remote/RemoteCaptureView.swift @@ -28,5 +28,6 @@ struct RemoteCaptureView: View { } } } + .navigationBarBackButtonHidden() } }