Skip to content

feat: show an incoming call from native code, and observe events natively on iOS - #906

Merged
sboily merged 3 commits into
masterfrom
feat/native-incoming-calls
Oct 17, 2026
Merged

sboily merged 3 commits into
masterfrom
feat/native-incoming-calls

Conversation

@sboily

@sboily sboily commented Oct 17, 2026 •

Copy link
Copy Markdown
Member

A call push can wake an app whose JavaScript isn't running or isn't ready, yet the call has to ring at once. Luge Mobile shows its calls from native code on both platforms, with this code patched onto 4.4.0. This PR proposes it upstream.

Rebased on master with #904 and #905. #904 is what lets the native path use a foregroundService: the service reads its settings itself and no longer needs the module.

Android (self-managed, Android 8 and later)

  • RNCallKeepModule.setupNative(context, options)
    • Stores the settings and registers the phone account as setup() does, without creating a module or a React context.
    • Leaves the service available, the state setup() ends in once the account is registered (setup() sets it unavailable first, then available).
    • Throws IllegalArgumentException unless selfManaged is true on Android 8+.
  • RNCallKeepModule.displayIncomingCall(context, uuid, number, callerName, hasVideo)
    • Shows the call with that phone account.
    • Throws IllegalStateException before any setup in the process.
    • The app handles the call from CallKeep's existing local broadcasts. CallKeep keeps a broadcast for JavaScript only when no receiver gets it, so once the app registers its own receiver, those events aren't returned by getInitialEvents() when React Native starts (documented).
  • VoiceConnectionService.nativeFocusListener and hasConnectionServiceFocus()
    • They expose Telecom's call focus (Android 9 and later), so native code can start the call's audio before React Native runs, and release it when the focus is lost.
    • Telecom calls no onConnectionServiceFocusLost when the last call ends, so deinitConnection resets the state when the last connection goes.

To get there, initializeTelecomManager, registerPhoneAccount, storeSettings, getApplicationName and isSelfManaged become static and take the context as an argument; the JavaScript path calls them as before.

iOS

sendEventWithNameWrapper also posts each event to NotificationCenter, under its native name (RNCallKeepPerformAnswerCallAction, ...), with the body as userInfo. Native code can then follow the call's actions before a React Native runtime exists, or while JavaScript is busy, as Android's local broadcasts already allow. The JavaScript events are unchanged.

The notification is posted on the thread that sends the event, which can be CallKit's private queue (didDisplayIncomingCall). The docs recommend observing with queue: .main.

Docs

  • docs/setup.md: Setup from native code (Android).
  • docs/events.md: Listening from native code (iOS), with the notification names and the thread.
  • CHANGELOG.md: updated.

Tests

  • tests/android/CallKeepNativeSetupTest.kt, run in the example app (React Native 0.87.1) on a Galaxy S24 with Android 16, passes:

    • native setup, and an incoming call with no module;
    • the call focus once the call is active;
    • no focus once it has ended. Without the reset in deinitConnection, it fails with "the call focus outlived the last call".

    It doesn't compile on master, since the API is new.

  • Lint and checks: ./gradlew :react-native-callkeep:lintDebug reports no errors, and npm run check passes.

  • Luge Mobile: on the same S24, its 8 Android instrumentation classes pass with master plus fix(android): the foreground service keeps an app's channel and starts without the module #904, feat(android): foregroundServiceType and endCallsOnHostDestroy options #905 and this branch (native incoming call, silence, answer, hold, focus loss, end). That run used the first version of these branches.

  • After the rebase: this test, CallKeepCallLifetimeOptionsTest and fix(android): the foreground service keeps an app's channel and starts without the module #904's two CallKeepForegroundServiceTest cases pass in the example app.

  • iOS:

    • In a test harness, a native observer receives didDisplayIncomingCall, with the callUUID and the payload, before the module exists, and JavaScript still receives it afterwards. On master the observer receives nothing.
    • It is also the code Luge Mobile ships.

🤖 Generated with Claude Code

sboily and others added 2 commits October 17, 2026 17:22
…vely on iOS

A call push can wake an app whose JavaScript isn't running, or isn't
ready, yet the call has to ring at once. On Android, the setup and
displayIncomingCall paths needed the React Native module; on iOS, native
code had no way to hear the call's actions.

Android (self-managed, Android 8 and later):
- RNCallKeepModule.setupNative(context, options) stores the settings and
  registers the phone account as setup() does, without creating a module
  or a React context. The helpers it needs (initializeTelecomManager,
  registerPhoneAccount, storeSettings, getApplicationName,
  isSelfManaged) become static and take the context as an argument.
- RNCallKeepModule.displayIncomingCall(context, ...) shows the call with
  that phone account. The app handles the call from CallKeep's local
  broadcasts, as before.
- VoiceConnectionService.nativeFocusListener and
  hasConnectionServiceFocus() expose Telecom's call focus (Android 9 and
  later), so native code can start the call's audio before React Native
  runs and release it when the focus is lost.

iOS: sendEventWithNameWrapper also posts each event to NotificationCenter
under its native name, with the body as userInfo. The JavaScript events
are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Telecom calls no onConnectionServiceFocusLost when the last call ends,
  so hasConnectionServiceFocus() stayed true with no connection left
  (still true 5 s after the end). deinitConnection resets it when the
  last connection goes; the native test now ends the call and checks it.
- setupNative leaves the service available, as setup() does once the
  phone account is registered (it sets false first, then true); a
  comment says so.
- docs/setup.md: once native code registers its own receiver, CallKeep's
  broadcasts reach it and are no longer kept for getInitialEvents().
- docs/events.md: iOS posts each notification on the thread that sends
  the event, CallKit's private queue for didDisplayIncomingCall; observe
  with queue: .main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sboily
sboily force-pushed the feat/native-incoming-calls branch from 5bbf393 to 1bb0157 Compare October 17, 2026 21:22
Only the connection service's events (didDisplayIncomingCall,
createIncomingConnectionFailed, checkReachability, and an outgoing
call's ongoing-call and audio-session events) are kept for
getInitialEvents() when no receiver gets them. The call's actions, sent
by VoiceConnection (showIncomingCallUi, answer, end, hold, DTMF, audio
route, silence), reach only the receivers registered at the time: a call
shown natively with no receiver, then React Native started, gives
JavaScript only didDisplayIncomingCall (checked on an Android 10
emulator). The docs said every broadcast was kept when no receiver got
it, and now tell native code to register its receiver before
displayIncomingCall.
@sboily
sboily merged commit 4943b09 into master Oct 17, 2026
4 checks passed
@sboily
sboily deleted the feat/native-incoming-calls branch October 17, 2026 21:28
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