Skip to content

fix(app/ios): Apple Watch connectivity & reliability hardening#7133

Draft
mdmohsin7 wants to merge 10 commits intomainfrom
rex/watch-connectivity-reliability
Draft

fix(app/ios): Apple Watch connectivity & reliability hardening#7133
mdmohsin7 wants to merge 10 commits intomainfrom
rex/watch-connectivity-reliability

Conversation

@mdmohsin7
Copy link
Copy Markdown
Member

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

# File Bug
1 app/ios/Runner/AppDelegate.swift:538 didReceiveUserInfo (background path) had cases for sendAudioChunk/stopRecording/etc but no case for startRecording. Phone backgrounded when Watch starts → start message routed via transferUserInfo arrives, never flips isRecordingActive, every audio chunk silently dropped.
2 app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift:49,82 Watch sent startRecording/stopRecording/recordingError/microphonePermissionResult via sendMessage only, no errorHandler. Phone unreachable → message lost, no retry path.
3 app/ios/Runner/AppDelegate.swift:347 handleAudioChunk dropped any chunk arriving while isRecordingActive=false and only printed to console. Watch UI showed recording, phone received nothing.
4 app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift RecorderHostApiImpl falls back to updateApplicationContext for start/stop/permission, but the Watch had no didReceiveApplicationContext handler — dead-code fallback.
5 app/ios/Runner/AppDelegate.swift:422 sessionDidDeactivate was a print-only stub. Apple's docs require WCSession.default.activate() here for multi-Watch swap; without it the session stays dead until app restart.
6 both sides didFinishUserInfoTransfer:error: unimplemented — durable-queue failures were completely silent.
7 both sides activationDidCompleteWith empty stub — activation failures proceeded silently.
8 both sides No sessionReachabilityDidChange: / sessionWatchStateDidChange: — Flutter could only learn state by polling.

Commit-by-commit

Each commit is independently revertable.

  1. fix(app/ios): handle startRecording in background WCSession path — phone-side didReceiveUserInfo now has a startRecording case mirroring the foreground handler.
  2. fix(app/ios): give Watch state messages a transferUserInfo fallbacksendReliably(_:) helper applied to start/stop/error/permission messages.
  3. fix(app/ios): recover from Watch↔phone recording state divergence — chunks arriving without a prior start now log loudly, flip isRecordingActive, notify Flutter, and continue processing instead of dropping.
  4. fix(app/ios): wire Watch handler for applicationContext commandssession:didReceiveApplicationContext: dispatches start/stop/permission to the same handlers as didReceiveMessage.
  5. fix(app/ios): reactivate WCSession on sessionDidDeactivate — Apple's documented multi-Watch pattern.
  6. fix(app/ios): log transferUserInfo failures on both sides — durable-queue errors now visible in Console.app.
  7. fix(app/ios): log WCSession activation outcome on both sides — activation failures no longer silent.
  8. 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:

  • No application-level per-chunk ack protocol. WCSession's built-in delivery (sendMessage:replyHandler:errorHandler: for foreground ack, transferUserInfo for system-managed durable queue) is sufficient. A custom sequence-number protocol on top would amplify load and duplicate work the OS already does.
  • No WKExtendedRuntimeSession. The standard audio background mode (already declared in Info.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.
  • No Opus encoding / VAD on Watch. Out of scope for connectivity & reliability. Each is a substantial dependency-introduction that wants its own PR for binary-size + battery review.
  • No custom watchdog timer. The reliability gaps were all in lifecycle handlers we hadn't implemented; once those are wired the standard WCSession lifecycle covers reconnection.

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-existing pigeon import diagnostic on pigeon_interfaces.dart).

Golden path

  • iPhone foreground, Watch foreground: tap record on Watch → phone receives audio within 1.5s.
  • iPhone foreground, Watch foreground: tap stop on Watch → phone stops within 1.5s.
  • Phone-initiated start (Flutter UI): Watch starts recording.

State divergence (the headline failure mode)

  • Force-quit iOS app. Open Watch app, tap record. Reopen iOS app within ~10s. Expected: iOS shows recording, audio chunks flowing. Console.app log shows [Watch] Audio chunk arrived without prior startRecording — recovering state.
  • iOS app backgrounded (not killed). Tap record on Watch. Bring iOS to foreground. Expected: recording state visible immediately, no audio loss.
  • iOS app foreground, then user puts Watch in airplane mode briefly while recording. Re-enable. Expected: chunks queued during airplane mode delivered when reachable; recording continues.

Lifecycle hardening

  • Pair a second Apple Watch (settings → My Watch → All Watches). Trigger Watch swap. Expected: iOS app stays responsive to the new Watch (sessionDidDeactivate reactivation).
  • Force-quit iOS app while Watch is recording. Relaunch. Expected: activation logs visible in Console.app; first audio chunk triggers recovery.
  • Uninstall Watch app from paired Watch. Expected: onWatchStateChanged(isPaired:true, isWatchAppInstalled:false, ...) fires (visible in Flutter debug log).

Reachability

  • iOS in foreground, Watch on same wrist, recording. Walk out of Bluetooth range (~10m+ wall). Expected: [Watch] reachability changed: false log; chunks queued; on return, queue flushes and audio resumes.
  • iOS app backgrounded with screen off. Recording active. Expected: chunks delivered via transferUserInfo system queue with no loss visible in final transcript.

Negative

  • Tap record on Watch with phone bluetooth off entirely. Expected: recordingError surfaces with permission/connectivity error message instead of silent failure.
  • Force a WCSession activation failure (e.g. revoke entitlements in dev build). Expected: activation failure logged in Console.app; no silent no-op.

Files touched

  • app/ios/Runner/AppDelegate.swift — phone-side delegate hardening, silent-drop fix, new Pigeon callbacks
  • app/ios/omiWatchApp/WatchAudioRecorderViewModel.swift — Watch-side delegate hardening, sendReliably(_:) helper, didReceiveApplicationContext handler
  • app/lib/pigeon_interfaces.dart — two new FlutterAPI callbacks
  • app/lib/services/bridges/apple_watch_bridge.dart — bridge implementations
  • app/lib/services/devices/transports/watch_transport.dart — debug logging for new callbacks
  • app/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 regen

Out of scope (deliberate)

State persistence via updateApplicationContext for isRecordingActive truth — 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.

mdmohsin7 added 8 commits May 2, 2026 20:21
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).
@mdmohsin7 mdmohsin7 added bug Something isn't working ios area: Apple Watch labels May 2, 2026
@mdmohsin7
Copy link
Copy Markdown
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):

  • Commit 1: Background startRecording case in didReceiveUserInfo — mirrors the foreground handler at line 478 exactly (adjacent pattern parity ✅). Same 4-line state reset, DispatchQueue.main.async for Flutter notification.
  • Commit 2: sendReliably — clean sendMessagetransferUserInfo fallback. Correctly applied to state messages only; audio chunk paths left inline (different fallback shape). No retry amplification.
  • Commit 3: Divergence recovery — handleAudioChunk no longer drops chunks when isRecordingActive=false. Instead: log loudly, flip state, reset chunks, notify Flutter, continue processing. Data validation guard (audioChunk, chunkIndex, isLast, sampleRate) preserved above the recovery block. Trade-off documented in commit message (one resurrected chunk in worst-case race vs. indefinite silent drop).

Lifecycle hardening:

  • Commit 4: didReceiveApplicationContext dispatches to same handlers as didReceiveMessage, @MainActor correct. Covers the three methods RecorderHostApiImpl sends via context fallback — dead code is now alive.
  • Commit 5: sessionDidDeactivateWCSession.default.activate() — Apple's documented multi-Watch pattern.
  • Commits 6-7: Transfer failure + activation logging. Both sides. No app-level retry (correct — system queue handles durable retry).

Pigeon plumbing (commit 8):

  • Two new FlutterAPI callbacks (onWatchReachabilityChanged, onWatchStateChanged). Phone-side delegate wires them, Watch-side logs only (no Pigeon on Watch target — correct). Generated code follows existing patterns. Bridge + transport wire-up clean. Debug-log only for now, downstream subscribers noted as out of scope.

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.

mdmohsin7 and others added 2 commits May 4, 2026 18:51
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: Apple Watch bug Something isn't working ios

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant