fix(app/ios): Apple Watch connectivity & reliability hardening#7133
Draft
fix(app/ios): Apple Watch connectivity & reliability hardening#7133
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).
Member
Author
|
@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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.