Skip to content

feat: overhaul phone call system — thread safety, CallKit extraction, China compliance#6322

Merged
mdmohsin7 merged 26 commits into
mainfrom
feat/phone-call-system-improvement
Apr 7, 2026
Merged

feat: overhaul phone call system — thread safety, CallKit extraction, China compliance#6322
mdmohsin7 merged 26 commits into
mainfrom
feat/phone-call-system-improvement

Conversation

@mdmohsin7
Copy link
Copy Markdown
Member

@mdmohsin7 mdmohsin7 commented Apr 4, 2026

Summary

  • Audio thread safety: Added OmiAudioLock (os_unfair_lock, 5-10ns) to protect audio device state from Core Audio real-time callbacks. Pre-allocates capture buffers instead of malloc on the audio thread.
  • Structured native errors: Errors now propagate from native to Dart with specific codes (MIC_PERMISSION_DENIED, TWILIO_ERROR, etc.). Mute/speaker toggles use confirmation-based state updates, fixing a race condition where UI could desync from native state.
  • CallKit coordinator extraction: All CallKit management extracted into OmiCallCoordinator with provider reset recovery, 3-second readiness timeout, and pending completion tracking. The plugin itself no longer imports CallKit.
  • China/CallKit compliance: OmiRegionCheck detects CN locale and carrier MCC 460, swapping to OmiDirectCallCoordinator which manages AVAudioSession directly without CallKit. Resolves Apple Guideline 5 Legal rejection.
  • Token refresh: Proactive Twilio token refresh with a 3-minute buffer before expiry, preventing silent call failures on long calls.
  • Proximity sensor: Screen turns off when phone held to ear during calls, prevents accidental touches.
  • Audio route selection: Full discovery of available outputs (iPhone, Speaker, AirPods, Bluetooth). Long-press the speaker button to open the route picker.
  • WebSocket resilience: Audio data buffered during transcription WebSocket reconnects (~2s). Max reconnect attempts increased from 5 to 10. TranscriptionStatus enum tracks WS health.
  • Transcription status UI: Color-coded indicator (yellow=connecting, orange=reconnecting, red=failed) in call view and banner.
  • File organization: All phone call Swift files moved into app/ios/Runner/PhoneCalls/ subfolder (9 files).

Test plan

  • Make a call on iOS — verify audio works, CallKit green bar appears (non-China region)
  • Set device locale to China — verify no CallKit UI, call still works
  • Connect AirPods → long-press speaker → verify route picker shows AirPods
  • Hold phone to ear during call → verify screen turns off
  • Toggle mute rapidly 10x → verify UI stays in sync
  • Kill backend WebSocket mid-call → verify reconnecting/unavailable indicators
  • Start long call → verify token refreshes without interrupting audio
  • Test on Android → verify speaker toggle and basic routes still work

🤖 Generated with Claude Code

mdmohsin7 added 24 commits April 4, 2026 23:33
…io thread safety

Core Audio real-time callbacks access shared state ~100 times/sec.
OmiAudioLock uses os_unfair_lock (5-10ns) instead of DispatchQueue.sync
(50-200ns) to protect audio device state without blocking real-time threads.
…audio state

Moved to PhoneCalls/ subfolder. Applied @OmiAudioLock to audioUnit,
renderingContext, capturingContext, and isMicStreamMuted. Pre-allocates
capture buffer in startCapturing() instead of allocating on the real-time
audio thread. Eliminates active data race and malloc-on-RT-thread bugs.
…t error reporting

Provides specific error codes (MIC_PERMISSION_DENIED, TWILIO_ERROR, etc.)
with human-readable messages, serializable to Flutter EventChannel.
…tors

Protocol enabling CallKit (OmiCallCoordinator) and no-CallKit
(OmiDirectCallCoordinator) implementations to be swapped at init time
based on region. Decouples the plugin from CallKit entirely.
…eset recovery

Extracted CallKit management into dedicated coordinator with:
- DispatchQueue-based state isolation
- Provider readiness tracking with 3-second timeout
- Automatic provider recreation after system reset
- Pending completion tracking by UUID
- 20ms audio buffer config (5ms corrupts AirPods)
- Fallback action.fulfill() to prevent CallKit timeouts
Enables proximity monitoring on call ringing, disables on cleanup.
Toggles idle timer based on proximity state changes.
Manages AVAudioSession directly without CallKit for regions where
Apple requires CallKit to be deactivated. No system call UI, no
Phone.app recents — VoIP functionality preserved.
…iction

Checks device locale (CN) and carrier MCC (460) to determine if CallKit
should be disabled, as required by Apple/MIIT for the China App Store.
… check, proximity

Moved to PhoneCalls/ subfolder as OmiPhoneCallsPlugin. Key changes:
- No CallKit import — delegates to OmiCallCoordinatorProtocol
- Region-based coordinator selection (CallKit vs Direct for China)
- Proximity sensor enable/disable on call lifecycle
- Audio route discovery and selection (iPhone/Speaker/AirPods/BT)
- Structured error events, mute/speaker confirmation events
- Dedicated audio dispatch queue (off main thread)
Adds PhoneCalls PBXGroup, moves existing file refs, adds 7 new Swift
files to build sources.
Data model for audio output routes (iPhone, Speaker, AirPods,
Bluetooth, headphones, CarPlay) with factory from native map.
Structured error with code/message from native EventChannel.
Token expiresAt enables proactive refresh scheduling.
…s, region check

Adds onError, onMuteConfirmed, onSpeakerConfirmed event parsing.
New methods: getAudioRoutes(), selectAudioRoute(), isCallKitAvailable().
…onStatus

- Proactive token refresh (3-min buffer before expiry)
- Audio route discovery and selection via native bridge
- TranscriptionStatus enum exposed to UI (idle/connecting/active/reconnecting/failed)
- Audio buffering during WS reconnect (~2s, 100 frames)
- Increased max WS reconnect from 5 to 10 attempts
- Confirmation-based mute/speaker (no more optimistic UI update)
- Structured PhoneCallError storage
…to call UI

- Transcription status dot (yellow/orange/red) above transcript
- Audio route picker bottom sheet (long-press speaker button)
- Specific error messages in failed state from lastError
- Route icons for iPhone/Speaker/AirPods/Bluetooth/headphones
Green when active, orange when reconnecting, red when failed —
gives user visual feedback on transcription health without text.
…KitAvailable

Basic Speaker/Phone route support on Android. isCallKitAvailable returns
false (iOS-only). Maintains MethodChannel contract parity.
… access levels

- AVAudioSession.Port has no .carPlay member — removed from route discovery
- Changed callCoordinator, callUUID, proximitySensor to fileprivate for
  access from TwilioCallDelegateHandler in the same file
…r all 34 locales

New keys: transcriptionConnecting, transcriptionReconnecting,
transcriptionUnavailable, audioOutput — with real translations
for all 33 non-English locales.
Replace hardcoded strings with context.l10n.transcriptionConnecting,
transcriptionReconnecting, transcriptionUnavailable, and audioOutput.
…ve call

iOS temporarily deactivates the audio session during screen lock and
reactivates it. Stopping the audio device during this window causes
Twilio to detect the device stopped and disconnect the call. Now only
stops the device when no active call exists.

Fixes: pressing power button on ActiveCallPage ending the call.
…press is intended iOS behavior

Pressing the power button during a CallKit VoIP call sends CXEndCallAction
by design. Removed action.fail(),
audio session reactivation hacks, and debug stack traces. Kept clean
CXEndCallAction handling that fulfills and disconnects normally.
…ximity sensor

- Android: send muteConfirmed/speakerConfirmed events so Dart UI updates
  (confirmation-based state requires native to echo back the change)
- iOS: switch audio session mode from .voiceChat to .default
  (fixes Bluetooth earphone audio output routing)
- iOS: rewrite OmiProximitySensor with @mainactor pattern
@mdmohsin7 mdmohsin7 force-pushed the feat/phone-call-system-improvement branch from 1fedefa to 0416c99 Compare April 6, 2026 12:49
@mdmohsin7 mdmohsin7 marked this pull request as ready for review April 6, 2026 13:11
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 6, 2026

Greptile Summary

This PR comprehensively overhauled the phone call stack: OmiCallCoordinator extracts CallKit lifecycle management, OmiDirectCallCoordinator + OmiRegionCheck add China compliance (resolving Apple Guideline 5), OmiAudioLock provides os_unfair_lock-backed audio thread safety, mute/speaker now use confirmation-based state updates that eliminate UI desync races, proactive token refresh prevents silent call failures, audio buffering bridges WS reconnects, and the proximity sensor and audio route picker round out UX. Prior review concerns (expiresAt frozen at construction, _tokenExpiry dead code, silent refresh failure) have all been addressed.

  • P1: _scheduleTokenRefresh(30) fires in 15 s due to the ttl ~/ 2 branch; change the retry argument to 60 in phone_call_provider.dart.

Confidence Score: 4/5

Safe to merge after fixing the token-refresh retry timing; all other findings are P2 or lower

One P1 bug: _scheduleTokenRefresh(30) fires in 15 s instead of the intended 30 s with a misleading log message. All three prior reviewer concerns (expiresAt frozen, _tokenExpiry dead code, silent refresh failure with no retry) have been resolved in this revision. The remaining P2 (DEBUG race in OmiAudioLock) does not affect production builds.

app/lib/providers/phone_call_provider.dart — _scheduleTokenRefresh retry argument should be 60, not 30

Important Files Changed

Filename Overview
app/ios/Runner/PhoneCalls/OmiPhoneCallsPlugin.swift Core plugin correctly wires coordinator + Twilio and sends confirmation events for mute/speaker; audio route discovery reads availableInputs (not outputs), which may miss output-only routes on edge-case hardware
app/ios/Runner/PhoneCalls/OmiCallCoordinator.swift CallKit coordinator with queue-protected state, provider-reset recovery, 3-second readiness timeout, and pending-completion tracking — logic is sound
app/ios/Runner/PhoneCalls/OmiAudioLock.swift os_unfair_lock property wrapper correct for production; DEBUG diagnostic stats updated outside the lock (race in debug builds only)
app/ios/Runner/PhoneCalls/OmiRegionCheck.swift Locale + carrier MCC 460 dual-check for China CallKit restriction is correct; computed each init so no stale state
app/ios/Runner/PhoneCalls/OmiDirectCallCoordinator.swift Direct AVAudioSession coordinator for China; correct setup/teardown with .notifyOthersOnDeactivation
app/ios/Runner/PhoneCalls/OmiRecordingAudioDevice.swift VoiceProcessingIO device with pre-allocated capture buffers and OmiAudioLock-protected state; audio thread safety design is sound
app/lib/providers/phone_call_provider.dart Token refresh, audio buffering, confirmation-based mute/speaker state, and transcription status correctly added; retry delay calculation bug: _scheduleTokenRefresh(30) produces 15 s delay instead of 30 s
app/lib/services/phone_call_service.dart Native bridge updated for error/muteConfirmed/speakerConfirmed events and audio route methods; straightforward and correct
app/android/app/src/main/kotlin/com/friend/ios/PhoneCallsPlugin.kt Android stubs for getAudioRoutes/selectAudioRoute/isCallKitAvailable added; earpiece type string 'iPhone' flagged in prior review
app/lib/pages/phone_calls/active_call_page.dart TranscriptionStatusIndicator and AudioRouteSheet added; long-press speaker for route picker wired correctly
app/lib/backend/schema/phone_call.dart expiresAt now frozen at construction time (prior review concern resolved); PhoneCallError class added with correct fromEvent factory

Sequence Diagram

sequenceDiagram
    participant F as Flutter/Dart
    participant P as PhoneCallProvider
    participant S as PhoneCallService
    participant N as OmiPhoneCallsPlugin
    participant C as OmiCallCoordinator
    participant T as TwilioVoiceSDK

    F->>P: makeCall(phoneNumber)
    P->>S: initialize(token)
    P->>S: makeCall(phoneNumber, callId)
    S->>N: MethodChannel makeCall
    N->>C: startCall(uuid, phoneNumber)
    C-->>N: onAudioSessionActivated
    N->>T: TwilioVoiceSDK.connect()
    T-->>N: callDidConnect
    N-->>C: reportCallConnected(uuid)
    N-->>S: EventChannel {callStateChanged: active}
    S-->>P: onCallStateChanged(active)
    P-->>F: notifyListeners()
    Note over P,N: Proactive token refresh (TTL − 3 min)
    P->>P: _scheduleTokenRefresh(ttl)
    P->>S: initialize(newToken)
    Note over P,N: Confirmation-based mute toggle
    F->>P: toggleMute()
    P->>S: toggleMute(!_isMuted)
    S->>N: MethodChannel toggleMute
    N-->>S: EventChannel {muteConfirmed}
    S-->>P: onMuteConfirmed(muted)
    P-->>F: notifyListeners()
    Note over P: WS reconnect with audio buffering
    P->>P: buffer audio frames (_audioBuffer)
    P->>P: _connectTranscriptionSocket()
    P->>P: flush _audioBuffer to new socket
Loading

Reviews (2): Last reviewed commit: "fix: address PR review — remove double-d..." | Re-trigger Greptile

Comment on lines +351 to +362
func sendCallStateEvent(_ state: String) {
sendEvent(["type": "callStateChanged", "state": state])
}

func sendErrorEvent(_ error: OmiCallError) {
sendEvent(error.toEventData())
}

func sendAudioDataEvent(_ data: Data, channel: Int) {
audioEventQueue.async { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unnecessary double-dispatch on the audio path

audioEventQueue.async immediately re-dispatches to the main thread via DispatchQueue.main.async. The intermediate hop adds a thread context switch on every audio callback (~50 Hz) without doing any work on audioEventQueue. The comment on the queue says it exists "to avoid flooding the main thread", but this implementation doesn't throttle or batch — the sink call still ends up on main. Compare sendEvent(_:) below, which dispatches directly to main in one hop.

Suggested change
func sendCallStateEvent(_ state: String) {
sendEvent(["type": "callStateChanged", "state": state])
}
func sendErrorEvent(_ error: OmiCallError) {
sendEvent(error.toEventData())
}
func sendAudioDataEvent(_ data: Data, channel: Int) {
audioEventQueue.async { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
func sendAudioDataEvent(_ data: Data, channel: Int) {
DispatchQueue.main.async { [weak self] in
self?.eventSink?([
"type": "audioData",
"data": FlutterStandardTypedData(bytes: data),
"channel": channel,
])
}
}

Comment thread app/lib/backend/schema/phone_call.dart Outdated

PhoneCallToken({required this.accessToken, required this.ttl, required this.identity});

DateTime get expiresAt => DateTime.now().add(Duration(seconds: ttl));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 expiresAt returns a different value on every access

DateTime.now().add(Duration(seconds: ttl)) is evaluated fresh each time the getter is called. If a caller holds a PhoneCallToken and queries expiresAt minutes later, the result will be minutes too far in the future — it no longer represents the actual expiry of the token. Currently _tokenExpiry is assigned right after the HTTP response so drift is minimal, but any future use of this getter for expiry decisions will be subtly wrong.

Fix by computing it once at object construction:

Suggested change
DateTime get expiresAt => DateTime.now().add(Duration(seconds: ttl));
late final DateTime expiresAt = DateTime.now().add(Duration(seconds: ttl));

This freezes the value at parse time, making it safe to call multiple times.

Comment on lines 54 to +56

// Audio routes
List<AudioRoute> _availableRoutes = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _tokenExpiry is stored but never read — dead code

_tokenExpiry is assigned in two places (initial call setup and inside the refresh callback) but is never read for any decision. The refresh logic uses token.ttl directly in _scheduleTokenRefresh(token.ttl), so storing _tokenExpiry has no effect. If the intent was to compute remaining time more accurately on the next cycle (e.g. _tokenExpiry!.difference(DateTime.now()).inSeconds), that calculation is missing. Remove the field until it is actually used, to avoid misleading future readers.

Suggested change
// Audio routes
List<AudioRoute> _availableRoutes = [];
// Token refresh
Timer? _tokenRefreshTimer;

Comment on lines +398 to +408

Logger.info('PhoneCallProvider: scheduling token refresh in ${refreshInSeconds}s');
_tokenRefreshTimer = Timer(Duration(seconds: refreshInSeconds), () async {
if (_callState != PhoneCallState.active && _callState != PhoneCallState.ringing) return;
Logger.info('PhoneCallProvider: refreshing call token');
var token = await api.getPhoneCallToken();
if (token != null) {
await _nativeService.initialize(token.accessToken);
_tokenExpiry = token.expiresAt;
_scheduleTokenRefresh(token.ttl);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Silent token refresh failure — no retry or user notification

If api.getPhoneCallToken() returns null (network error, server error), the refresh silently does nothing. The original token will expire at its original TTL, and the call will fail with no warning to the user. For a feature specifically designed to prevent silent call failures, this is an ironic gap. At minimum, log an error and consider a simple retry:

var token = await api.getPhoneCallToken();
if (token != null) {
  await _nativeService.initialize(token.accessToken);
  _scheduleTokenRefresh(token.ttl);
} else {
  Logger.error('PhoneCallProvider: token refresh failed — retrying in 30s');
  // Retry once before the original token expires
  _tokenRefreshTimer = Timer(const Duration(seconds: 30), () => _scheduleTokenRefresh(30));
}

Comment on lines +120 to +124
"getAudioRoutes" -> {
// Basic routes for Android — Speaker and Phone
val routes = listOf(
mapOf("id" to "phone", "name" to "Phone", "type" to "iPhone"),
mapOf("id" to "speaker", "name" to "Speaker", "type" to "speaker")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Android earpiece uses iOS-specific type string "iPhone"

Sending "type" to "iPhone" from Android causes AudioRoute.fromMap to assign AudioRouteType.iPhone to the earpiece. The UI then uses Icons.phone_android for that type, which happens to be correct — but only by coincidence, because the icon lookup for AudioRouteType.iPhone was written with Icons.phone_android. If AudioRouteType.iPhone is ever renamed or its icon updated for iOS, the Android earpiece silently breaks.

Consider adding a shared earpiece type to AudioRouteType on the Dart side and using it from both platforms:

mapOf("id" to "phone", "name" to "Phone", "type" to "earpiece"),

…d refresh retry

- Remove unnecessary audioEventQueue double-dispatch on audio path
- Compute expiresAt once at construction instead of on every access
- Remove unused _tokenExpiry field
- Retry token refresh in 30s on failure instead of silently giving up
@mdmohsin7
Copy link
Copy Markdown
Member Author

@greptile-apps re-review

_scheduleTokenRefresh halves the input (ttl/2), so passing 30 resulted
in a 15s delay. Pass 60 so the actual timer fires at 30s.
@mdmohsin7 mdmohsin7 merged commit aafdb07 into main Apr 7, 2026
2 checks passed
@mdmohsin7 mdmohsin7 deleted the feat/phone-call-system-improvement branch April 7, 2026 12:07
Glucksberg pushed a commit to Glucksberg/omi-local that referenced this pull request Apr 28, 2026
… China compliance (BasedHardware#6322)

## Summary

- **Audio thread safety**: Added `OmiAudioLock` (`os_unfair_lock`,
5-10ns) to protect audio device state from Core Audio real-time
callbacks. Pre-allocates capture buffers instead of malloc on the audio
thread.
- **Structured native errors**: Errors now propagate from native to Dart
with specific codes (`MIC_PERMISSION_DENIED`, `TWILIO_ERROR`, etc.).
Mute/speaker toggles use confirmation-based state updates, fixing a race
condition where UI could desync from native state.
- **CallKit coordinator extraction**: All CallKit management extracted
into `OmiCallCoordinator` with provider reset recovery, 3-second
readiness timeout, and pending completion tracking. The plugin itself no
longer imports CallKit.
- **China/CallKit compliance**: `OmiRegionCheck` detects CN locale and
carrier MCC 460, swapping to `OmiDirectCallCoordinator` which manages
AVAudioSession directly without CallKit. Resolves Apple Guideline 5
Legal rejection.
- **Token refresh**: Proactive Twilio token refresh with a 3-minute
buffer before expiry, preventing silent call failures on long calls.
- **Proximity sensor**: Screen turns off when phone held to ear during
calls, prevents accidental touches.
- **Audio route selection**: Full discovery of available outputs
(iPhone, Speaker, AirPods, Bluetooth). Long-press the speaker button to
open the route picker.
- **WebSocket resilience**: Audio data buffered during transcription
WebSocket reconnects (~2s). Max reconnect attempts increased from 5 to
10. `TranscriptionStatus` enum tracks WS health.
- **Transcription status UI**: Color-coded indicator (yellow=connecting,
orange=reconnecting, red=failed) in call view and banner.
- **File organization**: All phone call Swift files moved into
`app/ios/Runner/PhoneCalls/` subfolder (9 files).

## Test plan

- [x] Make a call on iOS — verify audio works, CallKit green bar appears
(non-China region)
- [x] Set device locale to China — verify no CallKit UI, call still
works
- [ ] Connect AirPods → long-press speaker → verify route picker shows
AirPods
- [x] Hold phone to ear during call → verify screen turns off
- [ ] Toggle mute rapidly 10x → verify UI stays in sync
- [ ] Kill backend WebSocket mid-call → verify reconnecting/unavailable
indicators
- [ ] Start long call → verify token refreshes without interrupting
audio
- [x] Test on Android → verify speaker toggle and basic routes still
work

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant