diff --git a/app/android/app/src/main/kotlin/com/friend/ios/PigeonCommunicator.g.kt b/app/android/app/src/main/kotlin/com/friend/ios/PigeonCommunicator.g.kt index 9d27257b66f..bf2873240b2 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/PigeonCommunicator.g.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/PigeonCommunicator.g.kt @@ -792,6 +792,40 @@ class WatchRecorderFlutterAPI(private val binaryMessenger: BinaryMessenger, priv } } } + fun onWatchReachabilityChanged(isReachableArg: Boolean, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchReachabilityChanged$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(isReachableArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(PigeonCommunicatorPigeonUtils.createConnectionError(channelName))) + } + } + } + fun onWatchStateChanged(isPairedArg: Boolean, isWatchAppInstalledArg: Boolean, isReachableArg: Boolean, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(isPairedArg, isWatchAppInstalledArg, isReachableArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(PigeonCommunicatorPigeonUtils.createConnectionError(channelName))) + } + } + } } /** * Dart → Native: commands sent from Flutter to the native BLE module. diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift index 3535d7017a6..34fbf56928d 100644 --- a/app/ios/Runner/AppDelegate.swift +++ b/app/ios/Runner/AppDelegate.swift @@ -345,11 +345,6 @@ final class QuickActionsIconPatcher: NSObject { } private func handleAudioChunk(_ message: [String: Any]) { - guard isRecordingActive else { - print("Ignoring audio chunk - recording not active") // probably started recording with main omi app closed - return - } - guard let audioChunk = message["audioChunk"] as? Data, let chunkIndex = message["chunkIndex"] as? Int, let isLast = message["isLast"] as? Bool, @@ -357,6 +352,23 @@ final class QuickActionsIconPatcher: NSObject { return } + if !isRecordingActive { + NSLog("[Watch] Audio chunk arrived without prior startRecording — recovering state (chunkIndex=\(chunkIndex))") + isRecordingActive = true + audioChunks.removeAll() + nextExpectedChunkIndex = 0 + DispatchQueue.main.async { + self.flutterWatchAPI?.onRecordingStarted() { result in + switch result { + case .success: + break + case .failure(let error): + print("Recording started (recovered from divergence) sent to Flutter - Error: \(error.message)") + } + } + } + } + audioChunks[chunkIndex] = (audioChunk, sampleRate) if isLast { @@ -413,14 +425,46 @@ func registerPlugins(registry: FlutterPluginRegistry) { extension AppDelegate: WCSessionDelegate { - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { } - + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { + if let error = error { + NSLog("[Watch] phone-side WCSession activation failed: \(error.localizedDescription)") + } else { + NSLog("[Watch] phone-side WCSession activated state=\(activationState.rawValue)") + } + } + + func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: Error?) { + if let error = error { + let method = userInfoTransfer.userInfo["method"] as? String ?? "unknown" + NSLog("[Watch] phone-side transferUserInfo failed (method=\(method)): \(error.localizedDescription)") + } + } + func sessionDidBecomeInactive(_ session: WCSession) { print("Session Watch Become Inactive") } - + func sessionDidDeactivate(_ session: WCSession) { print("Session Watch Deactivate") + WCSession.default.activate() + } + + func sessionReachabilityDidChange(_ session: WCSession) { + let isReachable = session.isReachable + NSLog("[Watch] reachability changed: \(isReachable)") + DispatchQueue.main.async { + self.flutterWatchAPI?.onWatchReachabilityChanged(isReachable: isReachable) { _ in } + } + } + + func sessionWatchStateDidChange(_ session: WCSession) { + let isPaired = session.isPaired + let isInstalled = session.isWatchAppInstalled + let isReachable = session.isReachable + NSLog("[Watch] state changed paired=\(isPaired) installed=\(isInstalled) reachable=\(isReachable)") + DispatchQueue.main.async { + self.flutterWatchAPI?.onWatchStateChanged(isPaired: isPaired, isWatchAppInstalled: isInstalled, isReachable: isReachable) { _ in } + } } // Receive a message from watch (foreground/active) @@ -543,6 +587,21 @@ extension AppDelegate: WCSessionDelegate { } switch method { + case "startRecording": + self.isRecordingActive = true + self.audioChunks.removeAll() + self.nextExpectedChunkIndex = 0 + + DispatchQueue.main.async { + self.flutterWatchAPI?.onRecordingStarted() { result in + switch result { + case .success: + break + case .failure(let error): + print("Recording started (background) sent to Flutter - Error: \(error.message)") + } + } + } case "sendAudioChunk": self.handleAudioChunk(userInfo) case "stopRecording": diff --git a/app/ios/Runner/PigeonCommunicator.g.swift b/app/ios/Runner/PigeonCommunicator.g.swift index 10ef43ed1c2..ba469a2510d 100644 --- a/app/ios/Runner/PigeonCommunicator.g.swift +++ b/app/ios/Runner/PigeonCommunicator.g.swift @@ -648,6 +648,8 @@ protocol WatchRecorderFlutterAPIProtocol { func onMicrophonePermissionResult(granted grantedArg: Bool, completion: @escaping (Result) -> Void) func onMainAppMicrophonePermissionResult(granted grantedArg: Bool, completion: @escaping (Result) -> Void) func onWatchBatteryUpdate(batteryLevel batteryLevelArg: Double, batteryState batteryStateArg: Int64, completion: @escaping (Result) -> Void) + func onWatchReachabilityChanged(isReachable isReachableArg: Bool, completion: @escaping (Result) -> Void) + func onWatchStateChanged(isPaired isPairedArg: Bool, isWatchAppInstalled isWatchAppInstalledArg: Bool, isReachable isReachableArg: Bool, completion: @escaping (Result) -> Void) } class WatchRecorderFlutterAPI: WatchRecorderFlutterAPIProtocol { private let binaryMessenger: FlutterBinaryMessenger @@ -803,6 +805,42 @@ class WatchRecorderFlutterAPI: WatchRecorderFlutterAPIProtocol { } } } + func onWatchReachabilityChanged(isReachable isReachableArg: Bool, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchReachabilityChanged\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([isReachableArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } + func onWatchStateChanged(isPaired isPairedArg: Bool, isWatchAppInstalled isWatchAppInstalledArg: Bool, isReachable isReachableArg: Bool, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([isPairedArg, isWatchAppInstalledArg, isReachableArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(())) + } + } + } } /// Dart → Native: commands sent from Flutter to the native BLE module. /// diff --git a/app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift b/app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift index cb88c28fef9..bbf0797ffce 100644 --- a/app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift +++ b/app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift @@ -46,9 +46,9 @@ class WatchAudioRecorderViewModel: NSObject, ObservableObject { if success { self.setupAudioStreaming() self.isRecording = true - self.session.sendMessage(["method": "startRecording"], replyHandler: nil) + self.sendReliably(["method": "startRecording"]) } else { - self.session.sendMessage(["method": "recordingError", "error": "Microphone permission denied"], replyHandler: nil) + self.sendReliably(["method": "recordingError", "error": "Microphone permission denied"]) } } } @@ -79,7 +79,7 @@ class WatchAudioRecorderViewModel: NSObject, ObservableObject { chunkBuffer = Data() bufferStartTime = nil - session.sendMessage(["method": "stopRecording"], replyHandler: nil) + sendReliably(["method": "stopRecording"]) } private func bufferAndSendAudioData(_ audioData: Data) { @@ -202,25 +202,24 @@ class WatchAudioRecorderViewModel: NSObject, ObservableObject { switch permissionStatus { case .granted: - session.sendMessage(["method": "microphonePermissionResult", "granted": true], replyHandler: nil) - + sendReliably(["method": "microphonePermissionResult", "granted": true]) + case .denied: - session.sendMessage(["method": "microphonePermissionResult", "granted": false], replyHandler: nil) - + sendReliably(["method": "microphonePermissionResult", "granted": false]) + case .undetermined: // Request permission - this will show the permission dialog audioSession.requestRecordPermission { [weak self] granted in DispatchQueue.main.async { - self?.session.sendMessage([ - "method": "microphonePermissionResult", + self?.sendReliably([ + "method": "microphonePermissionResult", "granted": granted - ], replyHandler: nil) + ]) } } - + @unknown default: - // Send failure result to main app - session.sendMessage(["method": "microphonePermissionResult", "granted": false], replyHandler: nil) + sendReliably(["method": "microphonePermissionResult", "granted": false]) } } @@ -339,6 +338,16 @@ class WatchAudioRecorderViewModel: NSObject, ObservableObject { // Buffer audio data for target-duration chunks bufferAndSendAudioData(byteData) } + + private func sendReliably(_ message: [String: Any]) { + if session.isReachable { + session.sendMessage(message, replyHandler: nil) { error in + self.session.transferUserInfo(message) + } + } else { + session.transferUserInfo(message) + } + } } extension WatchAudioRecorderViewModel: WCSessionDelegate { @@ -346,7 +355,13 @@ extension WatchAudioRecorderViewModel: WCSessionDelegate { public func sessionDidBecomeInactive(_ session: WCSession) { } public func sessionDidDeactivate(_ session: WCSession) { } #endif - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) {} + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: (any Error)?) { + if let error = error { + NSLog("[Watch] WCSession activation failed: \(error.localizedDescription)") + } else { + NSLog("[Watch] WCSession activated state=\(activationState.rawValue)") + } + } func session(_ session: WCSession, didReceiveMessage message: [String : Any]) { Task { @@ -381,6 +396,33 @@ extension WatchAudioRecorderViewModel: WCSessionDelegate { } } } + + func session(_ session: WCSession, didFinish userInfoTransfer: WCSessionUserInfoTransfer, error: (any Error)?) { + if let error = error { + let method = userInfoTransfer.userInfo["method"] as? String ?? "unknown" + NSLog("[Watch] transferUserInfo failed (method=\(method)): \(error.localizedDescription)") + } + } + + func sessionReachabilityDidChange(_ session: WCSession) { + NSLog("[Watch] reachability changed: \(session.isReachable)") + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { + Task { @MainActor in + guard let method = applicationContext["method"] as? String else { return } + switch method { + case "startRecording": + self.startRecording() + case "stopRecording": + self.stopRecording() + case "requestMicrophonePermission": + self.requestMicrophonePermissionOnly() + default: + print("Unknown applicationContext method: \(method)") + } + } + } } diff --git a/app/lib/gen/pigeon_communicator.g.dart b/app/lib/gen/pigeon_communicator.g.dart index d3703abe077..89d1826a966 100644 --- a/app/lib/gen/pigeon_communicator.g.dart +++ b/app/lib/gen/pigeon_communicator.g.dart @@ -816,6 +816,10 @@ abstract class WatchRecorderFlutterAPI { void onWatchBatteryUpdate(double batteryLevel, int batteryState); + void onWatchReachabilityChanged(bool isReachable); + + void onWatchStateChanged(bool isPaired, bool isWatchAppInstalled, bool isReachable); + static void setUp(WatchRecorderFlutterAPI? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; { @@ -1018,6 +1022,62 @@ abstract class WatchRecorderFlutterAPI { }); } } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchReachabilityChanged$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchReachabilityChanged was null.'); + final List args = (message as List?)!; + final bool? arg_isReachable = (args[0] as bool?); + assert(arg_isReachable != null, + 'Argument for dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchReachabilityChanged was null, expected non-null bool.'); + try { + api.onWatchReachabilityChanged(arg_isReachable!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged was null.'); + final List args = (message as List?)!; + final bool? arg_isPaired = (args[0] as bool?); + assert(arg_isPaired != null, + 'Argument for dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged was null, expected non-null bool.'); + final bool? arg_isWatchAppInstalled = (args[1] as bool?); + assert(arg_isWatchAppInstalled != null, + 'Argument for dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged was null, expected non-null bool.'); + final bool? arg_isReachable = (args[2] as bool?); + assert(arg_isReachable != null, + 'Argument for dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged was null, expected non-null bool.'); + try { + api.onWatchStateChanged(arg_isPaired!, arg_isWatchAppInstalled!, arg_isReachable!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } } } diff --git a/app/lib/pigeon_interfaces.dart b/app/lib/pigeon_interfaces.dart index a6c8a1b6b3b..b20d8832ee3 100644 --- a/app/lib/pigeon_interfaces.dart +++ b/app/lib/pigeon_interfaces.dart @@ -58,6 +58,8 @@ abstract class WatchRecorderFlutterAPI { void onMicrophonePermissionResult(bool granted); void onMainAppMicrophonePermissionResult(bool granted); void onWatchBatteryUpdate(double batteryLevel, int batteryState); + void onWatchReachabilityChanged(bool isReachable); + void onWatchStateChanged(bool isPaired, bool isWatchAppInstalled, bool isReachable); } // ============================================================================= diff --git a/app/lib/services/bridges/apple_watch_bridge.dart b/app/lib/services/bridges/apple_watch_bridge.dart index fedf85cc929..acc65088810 100644 --- a/app/lib/services/bridges/apple_watch_bridge.dart +++ b/app/lib/services/bridges/apple_watch_bridge.dart @@ -11,6 +11,8 @@ class AppleWatchFlutterBridge implements WatchRecorderFlutterAPI { final void Function(bool granted)? onMicPermissionCb; final void Function(bool granted)? onMainAppMicPermissionCb; final void Function(double batteryLevel, int batteryState)? onBatteryUpdateCb; + final void Function(bool isReachable)? onReachabilityChangedCb; + final void Function(bool isPaired, bool isWatchAppInstalled, bool isReachable)? onWatchStateChangedCb; AppleWatchFlutterBridge({ this.onChunk, @@ -20,6 +22,8 @@ class AppleWatchFlutterBridge implements WatchRecorderFlutterAPI { this.onMicPermissionCb, this.onMainAppMicPermissionCb, this.onBatteryUpdateCb, + this.onReachabilityChangedCb, + this.onWatchStateChangedCb, }); @override @@ -59,4 +63,14 @@ class AppleWatchFlutterBridge implements WatchRecorderFlutterAPI { void onWatchBatteryUpdate(double batteryLevel, int batteryState) { onBatteryUpdateCb?.call(batteryLevel, batteryState); } + + @override + void onWatchReachabilityChanged(bool isReachable) { + onReachabilityChangedCb?.call(isReachable); + } + + @override + void onWatchStateChanged(bool isPaired, bool isWatchAppInstalled, bool isReachable) { + onWatchStateChangedCb?.call(isPaired, isWatchAppInstalled, isReachable); + } } diff --git a/app/lib/services/devices/transports/watch_transport.dart b/app/lib/services/devices/transports/watch_transport.dart index f6ea932a3ab..7879b6c45e6 100644 --- a/app/lib/services/devices/transports/watch_transport.dart +++ b/app/lib/services/devices/transports/watch_transport.dart @@ -82,6 +82,12 @@ class WatchTransport extends DeviceTransport { } } }, + onReachabilityChangedCb: (bool isReachable) { + Logger.debug('Watch Transport: Reachability changed: $isReachable'); + }, + onWatchStateChangedCb: (bool isPaired, bool isWatchAppInstalled, bool isReachable) { + Logger.debug('Watch Transport: State changed paired=$isPaired installed=$isWatchAppInstalled reachable=$isReachable'); + }, ); WatchRecorderFlutterAPI.setUp(_bridge!); }