fix(app/ios): Apple Watch connectivity & reliability hardening#7133
fix(app/ios): Apple Watch connectivity & reliability hardening#7133mdmohsin7 wants to merge 10 commits into
Conversation
Phone-side didReceiveUserInfo had cases for sendAudioChunk, stopRecording, recordingError, batteryUpdate and watchInfoUpdate but no case for startRecording. When the iOS app was backgrounded as the user started recording on the Watch, the start message routed via transferUserInfo arrived but never flipped isRecordingActive. Subsequent audio chunks hit the !isRecordingActive guard in handleAudioChunk and were silently dropped — Watch UI showed recording, phone received nothing. Mirror the foreground startRecording case in the background handler: flip the flag, reset chunk state, notify Flutter.
Watch sent startRecording, stopRecording, recordingError and microphonePermissionResult via sendMessage with no errorHandler. When the iPhone was unreachable (screen off, app backgrounded, link flapping) those messages were dropped with no retry path — phone never learned the Watch had started or stopped, recording state diverged silently. Audio chunks already had a sendMessage→transferUserInfo fallback; this commit applies the same shape to the state messages via a sendReliably(_:) helper. Audio chunk paths are left inline.
handleAudioChunk dropped any chunk that arrived while isRecordingActive was false. With the prior commits Watch will retry startRecording over transferUserInfo, but a window remained: if the start message was lost or arrived after a chunk (background queue ordering, app cold-start between start and first chunk), the chunk hit the guard and the entire recording was lost — Watch UI showed recording, phone received nothing. Treat an audio chunk arriving without a prior start as an implicit start: log loudly so the divergence is visible in logs, flip isRecordingActive, reset chunk state, notify Flutter, then continue processing. The chunk itself is preserved instead of being dropped. Worst-case race (Watch chunk in flight as phone stop completes) costs one resurrected chunk before Watch sees the stop reply; before this fix every chunk was dropped indefinitely until the user re-toggled.
RecorderHostApiImpl falls back to updateApplicationContext for startRecording, stopRecording and requestMicrophonePermission when sendMessage fails (phone unreachable). The Watch had no session:didReceiveApplicationContext: implementation, so those fallback commands were silently dropped — the fallback was dead code. Implement the delegate method and dispatch the three method values to the same handlers used for foreground sendMessage. Marshalled onto the main actor since the view-model methods run on @mainactor.
Apple's documented multi-Watch pattern requires calling WCSession.default.activate() inside sessionDidDeactivate — when the user pairs a new Watch, iOS deactivates the old session and the app must request a new one. Without this call the session stays dead until the iOS app is restarted; messages and audio chunks from the new Watch go nowhere.
didFinishUserInfoTransfer:error: was unimplemented on Watch and phone, so durable-queue failures (corrupted payload, sandbox quota, etc.) were completely silent — no log, no metric, no surface to the user. The system queue still re-attempts most transient errors, but when a transfer permanently fails there was no way to know. Implement the delegate on both sides to log the failing method name and error description. No app-level retry — WCSession's own queue handles durable retry, and double-queueing would amplify load. This just makes failures visible.
activationDidCompleteWith was an empty stub on Watch and phone. If WCSession failed to activate (entitlement issue, OS state, paired device mismatch) the app proceeded as if everything was fine — every sendMessage / transferUserInfo call would silently no-op. Log activation state and any error on both sides so the cause is visible in Console.app when the bridge appears dead.
Neither sessionReachabilityDidChange: nor sessionWatchStateDidChange: was implemented, so reachability flips (phone goes out of range, app backgrounded, link flapping) and pair/install changes never reached the Dart layer. Flutter could only know the state by polling the existing isWatchReachable / isWatchPaired host getters. Add two Pigeon FlutterAPI callbacks: - onWatchReachabilityChanged(isReachable) - onWatchStateChanged(isPaired, isWatchAppInstalled, isReachable) Wire them from the phone-side WCSessionDelegate and forward to AppleWatchFlutterBridge / WatchTransport (debug-log only for now; listeners can subscribe via the bridge constructor). The Watch-side reachability handler logs to Console.app — no Pigeon channel exists on the Watch target, so it's diagnostic only. Generated Pigeon code is included (Dart, Swift, Kotlin).
|
@morpheus review — Approved ✅ Well-structured reliability hardening. All 8 commits address real WCSession lifecycle gaps that caused silent audio drop. Reviewed the full diff (277 insertions / 22 deletions across 8 files). What I verified: Defense in depth for the headline bug (silent chunk drop):
Lifecycle hardening:
Pigeon plumbing (commit 8):
Process: 8 atomic commits, each independently revertable. Good commit messages with root-cause context. Labels set (bug, ios, area: Apple Watch). No scope creep — considered and dropped list in PR body is thoughtful. Needs hardware verification — iPhone + Apple Watch testing per the test plan. The code changes are correct by static review and adjacent-pattern comparison. |
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Closing as stale after confirmed stale-close triage. This PR was identified as high-confidence stale due to age plus one or more of: conflicts, draft/failing status, superseded work, or obsolete architecture. No merge was performed. If the work is still needed, please reopen with a fresh rebase or start from current main. |
|
Hey @mdmohsin7 👋 Thank you so much for taking the time to contribute to Omi! We truly appreciate you putting in the effort to submit this pull request. After careful review, we've decided not to merge this particular PR. Please don't take this personally — we genuinely try to merge as many contributions as possible, but sometimes we have to make tough calls based on:
Your contribution is still valuable to us, and we'd love to see you contribute again in the future! If you'd like feedback on how to improve this PR or want to discuss alternative approaches, please don't hesitate to reach out. Thank you for being part of the Omi community! 💜 |
Summary
Apple Watch ↔ phone audio streaming had several silent failure modes where the Watch UI showed "recording" but the iOS app received nothing. This PR addresses each one. The headline bug — silent audio chunk drop on state divergence — is fixed three ways for defense in depth: at the source (Watch sends start with a durable fallback), at the destination (phone now handles start in the background path), and at the symptom (chunks arriving without a prior start auto-recover instead of being dropped).
No new dependencies. No new background runtime modes. No protocol changes — just plugging holes in the existing WCSession lifecycle.
What's broken in main today
app/ios/Runner/AppDelegate.swift:538didReceiveUserInfo(background path) had cases forsendAudioChunk/stopRecording/etc but no case forstartRecording. Phone backgrounded when Watch starts → start message routed viatransferUserInfoarrives, never flipsisRecordingActive, every audio chunk silently dropped.app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift:49,82startRecording/stopRecording/recordingError/microphonePermissionResultviasendMessageonly, no errorHandler. Phone unreachable → message lost, no retry path.app/ios/Runner/AppDelegate.swift:347handleAudioChunkdropped any chunk arriving whileisRecordingActive=falseand only printed to console. Watch UI showed recording, phone received nothing.app/ios/omiWatchApp/WatchAudioRecorderViewModel.swiftRecorderHostApiImplfalls back toupdateApplicationContextfor start/stop/permission, but the Watch had nodidReceiveApplicationContexthandler — dead-code fallback.app/ios/Runner/AppDelegate.swift:422sessionDidDeactivatewas aprint-only stub. Apple's docs requireWCSession.default.activate()here for multi-Watch swap; without it the session stays dead until app restart.didFinishUserInfoTransfer:error:unimplemented — durable-queue failures were completely silent.activationDidCompleteWithempty stub — activation failures proceeded silently.sessionReachabilityDidChange:/sessionWatchStateDidChange:— Flutter could only learn state by polling.Commit-by-commit
Each commit is independently revertable.
fix(app/ios): handle startRecording in background WCSession path— phone-sidedidReceiveUserInfonow has astartRecordingcase mirroring the foreground handler.fix(app/ios): give Watch state messages a transferUserInfo fallback—sendReliably(_:)helper applied to start/stop/error/permission messages.fix(app/ios): recover from Watch↔phone recording state divergence— chunks arriving without a prior start now log loudly, flipisRecordingActive, notify Flutter, and continue processing instead of dropping.fix(app/ios): wire Watch handler for applicationContext commands—session:didReceiveApplicationContext:dispatches start/stop/permission to the same handlers asdidReceiveMessage.fix(app/ios): reactivate WCSession on sessionDidDeactivate— Apple's documented multi-Watch pattern.fix(app/ios): log transferUserInfo failures on both sides— durable-queue errors now visible in Console.app.fix(app/ios): log WCSession activation outcome on both sides— activation failures no longer silent.feat(app/ios): surface Watch reachability and state changes to Flutter— two new Pigeon FlutterAPI callbacks (onWatchReachabilityChanged,onWatchStateChanged); generated code regenerated for Dart, Swift, Kotlin.Why no Opus / VAD / extended-runtime / custom ack protocol
Considered and dropped after design review:
sendMessage:replyHandler:errorHandler:for foreground ack,transferUserInfofor system-managed durable queue) is sufficient. A custom sequence-number protocol on top would amplify load and duplicate work the OS already does.WKExtendedRuntimeSession. The standard audio background mode (already declared inInfo.plist) keeps the Watch app alive while actively recording. Extended-runtime sessions are for screen-off recording continuation — a separate UX feature, not a reliability fix.Test plan
This needs physical iPhone + Apple Watch hardware — WatchConnectivity behavior cannot be exercised in the simulator. The Linux build environment used here cannot compile iOS Swift, so the Swift changes were validated by static reading and adjacent-pattern comparison; Dart code passes
flutter analyze(the only finding is a pre-existingpigeonimport diagnostic onpigeon_interfaces.dart).Golden path
State divergence (the headline failure mode)
[Watch] Audio chunk arrived without prior startRecording — recovering state.Lifecycle hardening
sessionDidDeactivatereactivation).onWatchStateChanged(isPaired:true, isWatchAppInstalled:false, ...)fires (visible in Flutter debug log).Reachability
[Watch] reachability changed: falselog; chunks queued; on return, queue flushes and audio resumes.transferUserInfosystem queue with no loss visible in final transcript.Negative
recordingErrorsurfaces with permission/connectivity error message instead of silent failure.Files touched
app/ios/Runner/AppDelegate.swift— phone-side delegate hardening, silent-drop fix, new Pigeon callbacksapp/ios/omiWatchApp/WatchAudioRecorderViewModel.swift— Watch-side delegate hardening,sendReliably(_:)helper,didReceiveApplicationContexthandlerapp/lib/pigeon_interfaces.dart— two new FlutterAPI callbacksapp/lib/services/bridges/apple_watch_bridge.dart— bridge implementationsapp/lib/services/devices/transports/watch_transport.dart— debug logging for new callbacksapp/lib/gen/pigeon_communicator.g.dart,app/ios/Runner/PigeonCommunicator.g.swift,app/android/app/src/main/kotlin/com/friend/ios/PigeonCommunicator.g.kt— Pigeon regenOut of scope (deliberate)
State persistence via
updateApplicationContextforisRecordingActivetruth — the recovery path in commit 3 covers the same failure mode reactively. UI plumbing for the new reachability/state callbacks beyond debug logging — bridge and Pigeon channel are in place; downstream subscribers can attach without further iOS changes.