Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,40 @@ class WatchRecorderFlutterAPI(private val binaryMessenger: BinaryMessenger, priv
}
}
}
fun onWatchReachabilityChanged(isReachableArg: Boolean, callback: (Result<Unit>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchReachabilityChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(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>) -> Unit)
{
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
val channelName = "dev.flutter.pigeon.omi_pigeon.WatchRecorderFlutterAPI.onWatchStateChanged$separatedMessageChannelSuffix"
val channel = BasicMessageChannel<Any?>(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.
Expand Down
75 changes: 67 additions & 8 deletions app/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -345,18 +345,30 @@ 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,
let sampleRate = message["sampleRate"] as? Double else {
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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
38 changes: 38 additions & 0 deletions app/ios/Runner/PigeonCommunicator.g.swift
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,8 @@ protocol WatchRecorderFlutterAPIProtocol {
func onMicrophonePermissionResult(granted grantedArg: Bool, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onMainAppMicrophonePermissionResult(granted grantedArg: Bool, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onWatchBatteryUpdate(batteryLevel batteryLevelArg: Double, batteryState batteryStateArg: Int64, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onWatchReachabilityChanged(isReachable isReachableArg: Bool, completion: @escaping (Result<Void, PigeonError>) -> Void)
func onWatchStateChanged(isPaired isPairedArg: Bool, isWatchAppInstalled isWatchAppInstalledArg: Bool, isReachable isReachableArg: Bool, completion: @escaping (Result<Void, PigeonError>) -> Void)
}
class WatchRecorderFlutterAPI: WatchRecorderFlutterAPIProtocol {
private let binaryMessenger: FlutterBinaryMessenger
Expand Down Expand Up @@ -803,6 +805,42 @@ class WatchRecorderFlutterAPI: WatchRecorderFlutterAPIProtocol {
}
}
}
func onWatchReachabilityChanged(isReachable isReachableArg: Bool, completion: @escaping (Result<Void, PigeonError>) -> 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, PigeonError>) -> 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.
///
Expand Down
70 changes: 56 additions & 14 deletions app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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])
}
}

Expand Down Expand Up @@ -339,14 +338,30 @@ 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 {
#if os(iOS)
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 {
Expand Down Expand Up @@ -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)")
}
}
}
}


60 changes: 60 additions & 0 deletions app/lib/gen/pigeon_communicator.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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' : '';
{
Expand Down Expand Up @@ -1018,6 +1022,62 @@ abstract class WatchRecorderFlutterAPI {
});
}
}
{
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'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<Object?> args = (message as List<Object?>?)!;
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<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
'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<Object?> args = (message as List<Object?>?)!;
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()));
}
});
}
}
}
}

Expand Down
Loading
Loading