From 15326920e3c138bca0f68d2538c382b8588b8adf Mon Sep 17 00:00:00 2001 From: Sylvain Boily Date: Sat, 17 Oct 2026 16:53:03 -0400 Subject: [PATCH 1/3] feat: show an incoming call from native code, and observe events natively 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 --- CHANGELOG.md | 2 + .../io/wazo/callkeep/RNCallKeepModule.java | 72 ++++++++++++++----- .../wazo/callkeep/VoiceConnectionService.java | 30 ++++++++ docs/events.md | 28 ++++++++ docs/setup.md | 15 ++++ ios/RNCallKeep/RNCallKeep.m | 7 ++ tests/android/CallKeepNativeSetupTest.kt | 68 ++++++++++++++++++ 7 files changed, 204 insertions(+), 18 deletions(-) create mode 100644 tests/android/CallKeepNativeSetupTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 35960415..611a31c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ are not listed here. - **Android:** `foregroundService.foregroundServiceType` sets the types the foreground service starts with (Android 10 and later), as a mask of `ServiceInfo.FOREGROUND_SERVICE_TYPE_*` values. An app can ring with `phoneCall` alone while its manifest also declares `microphone`; CallKeep keeps the types for the whole call, so adding `microphone` after the answer is up to the app's native code (see the option in `docs/setup.md`). Without the option, the service still starts with every type the manifest declares. - **Android:** `endCallsOnHostDestroy: false` keeps the calls, and the app's process, when the React activity is destroyed, for calls that must outlive it (owned by native code, for instance). By default CallKeep still ends every call and kills the process. +- **Android:** `RNCallKeepModule.setupNative(context, options)` and `RNCallKeepModule.displayIncomingCall(context, uuid, number, callerName, hasVideo)` set CallKeep up and show an incoming call from native code, before React Native runs or without it (self-managed mode, Android 8 and later), for instance from a Firebase messaging service. `VoiceConnectionService.nativeFocusListener` and `hasConnectionServiceFocus()` let native code follow Telecom's call focus (Android 9 and later). See [Setup from native code (Android)](docs/setup.md#setup-from-native-code-android). +- **iOS:** Events are also posted to `NotificationCenter`, so native code can observe the call's actions before a React Native runtime exists. See [Listening from native code (iOS)](docs/events.md#listening-from-native-code-ios). ### Changed diff --git a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java index 7ff18628..f8c9bf21 100644 --- a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java +++ b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java @@ -176,7 +176,7 @@ private RNCallKeepModule(ReactApplicationContext reactContext) { this.reactContext = reactContext; } - private boolean isSelfManaged() { + private static boolean isSelfManaged() { try { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && _settings.hasKey("selfManaged") && _settings.getBoolean("selfManaged"); } catch (Exception e) { @@ -231,8 +231,12 @@ public void initializeTelecomManager() { Log.w(TAG, "[RNCallKeepModule][initializeTelecomManager] no react context found."); return; } + initializeTelecomManager(context); + } + + private static void initializeTelecomManager(Context context) { ComponentName cName = new ComponentName(context, VoiceConnectionService.class); - String appName = this.getApplicationName(context); + String appName = getApplicationName(context); handle = new PhoneAccountHandle(cName, appName); telecomManager = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE); @@ -383,7 +387,26 @@ public void setSettings(ReadableMap options) { if (options == null) { return; } - _settings = storeSettings(options); + _settings = storeSettings(getAppContext(), options); + } + + /** + * Sets up self-managed calls from native code, before React Native runs or without it, so that + * a service (an FCM service, for instance) can show an incoming call with the static + * displayIncomingCall. It stores the settings and registers the phone account as setup() does, + * but creates no module: the app shows its own incoming-call UI and listens to CallKeep's local + * broadcasts. When React Native starts later, its module uses the same settings. + * Self-managed mode only, Android 8 and later. + */ + public static void setupNative(Context context, ReadableMap options) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || + !options.hasKey("selfManaged") || !options.getBoolean("selfManaged")) { + throw new IllegalArgumentException("Native setup requires self-managed calls on Android 8+"); + } + _settings = storeSettings(context.getApplicationContext(), options); + registerPhoneAccount(context.getApplicationContext()); + VoiceConnectionService.setPhoneAccountHandle(handle); + VoiceConnectionService.setAvailable(true); } @ReactMethod @@ -443,7 +466,7 @@ public void registerPhoneAccount(ReadableMap options) { return; } - this.registerPhoneAccount(context); + registerPhoneAccount(context); } @ReactMethod @@ -481,6 +504,26 @@ public void displayIncomingCall(String uuid, String number, String callerName, b Log.d(TAG, "[RNCallKeepModule] displayIncomingCall, uuid: " + uuid + ", number: " + number + ", callerName: " + callerName + ", hasVideo: " + hasVideo + ", payload: " + payload); + this.listenToNativeCallsState(); + addIncomingCall(uuid, number, callerName, hasVideo, payload); + } + + /** + * Shows an incoming call from native code, with the phone account set up by setupNative (or + * setup) in this process. Telecom then reports it through CallKeep's broadcasts, as for a call + * shown from JavaScript. + */ + public static void displayIncomingCall(Context context, String uuid, String number, + String callerName, boolean hasVideo) { + if (!isSelfManaged()) { + throw new IllegalStateException("Call setup is required before native display"); + } + initializeTelecomManager(context.getApplicationContext()); + addIncomingCall(uuid, number, callerName, hasVideo, null); + } + + private static void addIncomingCall(String uuid, String number, String callerName, + boolean hasVideo, @Nullable Bundle payload) { Bundle extras = new Bundle(); Uri uri = Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null); @@ -493,7 +536,6 @@ public void displayIncomingCall(String uuid, String number, String callerName, b extras.putBundle(EXTRA_PAYLOAD, payload); incomingCallPayloads.put(uuid, payload); } - this.listenToNativeCallsState(); telecomManager.addNewIncomingCall(handle, extras); } @@ -1201,19 +1243,14 @@ public Activity getCurrentReactActivity() { return this.reactContext.getCurrentActivity(); } - private void registerPhoneAccount(Context appContext) { + private static void registerPhoneAccount(Context context) { if (!isConnectionServiceAvailable()) { Log.w(TAG, "[RNCallKeepModule] registerPhoneAccount ignored due to no ConnectionService"); return; } - this.initializeTelecomManager(); - Context context = this.getAppContext(); - if (context == null) { - Log.w(TAG, "[RNCallKeepModule][registerPhoneAccount] no react context found."); - return; - } - String appName = this.getApplicationName(context); + initializeTelecomManager(context); + String appName = getApplicationName(context); PhoneAccount.Builder builder = new PhoneAccount.Builder(handle, appName); if (isSelfManaged()) { @@ -1224,8 +1261,8 @@ private void registerPhoneAccount(Context appContext) { } if (_settings != null && _settings.hasKey("imageName")) { - int identifier = appContext.getResources().getIdentifier(_settings.getString("imageName"), "drawable", appContext.getPackageName()); - Icon icon = Icon.createWithResource(appContext, identifier); + int identifier = context.getResources().getIdentifier(_settings.getString("imageName"), "drawable", context.getPackageName()); + Icon icon = Icon.createWithResource(context, identifier); builder.setIcon(icon); } @@ -1252,7 +1289,7 @@ public void sendEventToJS(String eventName, @Nullable WritableMap params) { } } - private String getApplicationName(Context appContext) { + private static String getApplicationName(Context appContext) { ApplicationInfo applicationInfo = appContext.getApplicationInfo(); int stringId = applicationInfo.labelRes; @@ -1324,8 +1361,7 @@ private Context getAppContext() { } // Store all callkeep settings in JSON - private WritableMap storeSettings(ReadableMap options) { - Context context = getAppContext(); + private static WritableMap storeSettings(@Nullable Context context, ReadableMap options) { if (context == null) { Log.w(TAG, "[RNCallKeepModule][storeSettings] no react context found."); return MapUtils.readableToWritableMap(options); diff --git a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java index 7640d7ac..065ec5c8 100644 --- a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java +++ b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java @@ -156,6 +156,36 @@ public static void setInitialized(boolean value) { isInitialized = value; } + /** + * Lets native code follow the call focus Telecom gives self-managed calls (Android 9 and later), + * to start a call's media before React Native runs. When the focus is lost, release the call's + * audio and microphone, then call connectionServiceFocusReleased() on the service. + */ + public interface NativeFocusListener { + void onFocusGained(); + void onFocusLost(); + } + public static NativeFocusListener nativeFocusListener; + private static boolean connectionServiceFocused = false; + + public static boolean hasConnectionServiceFocus() { + return connectionServiceFocused; + } + + @Override + public void onConnectionServiceFocusGained() { + super.onConnectionServiceFocusGained(); + connectionServiceFocused = true; + if (nativeFocusListener != null) nativeFocusListener.onFocusGained(); + } + + @Override + public void onConnectionServiceFocusLost() { + super.onConnectionServiceFocusLost(); + connectionServiceFocused = false; + if (nativeFocusListener != null) nativeFocusListener.onFocusLost(); + } + public static void deinitConnection(String connectionId) { Log.d(TAG, "[VoiceConnectionService] deinitConnection:" + connectionId); VoiceConnectionService.hasOutgoingCall = false; diff --git a/docs/events.md b/docs/events.md index 8423d2fd..87e239b8 100644 --- a/docs/events.md +++ b/docs/events.md @@ -324,3 +324,31 @@ RNCallKeep.addEventListener('onHasActiveCall', () => { }); ``` + +## Listening from native code (iOS) + +iOS also posts these events to `NotificationCenter.default`, under the names below, with the event's data as `userInfo`. Native code can observe them before a React Native runtime exists, or while JavaScript is busy; the events JavaScript receives are unchanged. `didLoadWithEvents` and `didChangeAudioRoute` are not posted. + +| Event | Notification name | +| ---------------------------- | ---------------------------------------- | +| didReceiveStartCallAction | `RNCallKeepDidReceiveStartCallAction` | +| answerCall | `RNCallKeepPerformAnswerCallAction` | +| endCall | `RNCallKeepPerformEndCallAction` | +| didActivateAudioSession | `RNCallKeepDidActivateAudioSession` | +| didDeactivateAudioSession | `RNCallKeepDidDeactivateAudioSession` | +| didDisplayIncomingCall | `RNCallKeepDidDisplayIncomingCall` | +| didPerformSetMutedCallAction | `RNCallKeepDidPerformSetMutedCallAction` | +| didToggleHoldCallAction | `RNCallKeepDidToggleHoldAction` | +| didPerformDTMFAction | `RNCallKeepDidPerformDTMFAction` | +| didResetProvider | `RNCallKeepProviderReset` | + +```swift +NotificationCenter.default.addObserver( + forName: Notification.Name("RNCallKeepPerformAnswerCallAction"), object: nil, queue: .main +) { notification in + let callUUID = notification.userInfo?["callUUID"] as? String + // ... +} +``` + +On Android, native code receives CallKeep's local broadcasts: see [Setup from native code (Android)](setup.md#setup-from-native-code-android). diff --git a/docs/setup.md b/docs/setup.md index b887ca1d..d1320bf5 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -125,6 +125,21 @@ On iOS you can also perform setup in `AppDelegate`. Doing this allows capturing ``` +## Setup from native code (Android) + +In self-managed mode, native code can set CallKeep up and show an incoming call before React Native runs, or without it: from a Firebase messaging service, for instance, when a call push wakes the app. `setupNative` takes the `android` options of `setup()`, with `selfManaged: true` (Android 8 and later): + +```kotlin +RNCallKeepModule.setupNative(context, Arguments.createMap().apply { + putBoolean("selfManaged", true) +}) +RNCallKeepModule.displayIncomingCall(context, callUUID, number, callerName, false) +``` + +It stores the settings and registers the phone account as `setup()` does, but creates no module and no React context. Your app then shows its own incoming-call UI and handles the call from CallKeep's local broadcasts: register a receiver with `LocalBroadcastManager` for the `Constants.ACTION_*` actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, ...); each intent carries the call's `Constants.EXTRA_CALL_UUID` in its `attributeMap` extra. When React Native starts later, its module uses the same settings. + +Telecom gives self-managed calls the call focus (Android 9 and later). To start a call's audio before React Native runs, set `VoiceConnectionService.nativeFocusListener`: `onFocusGained` tells when the call may use audio, and on `onFocusLost` release its audio and microphone, then call `connectionServiceFocusReleased()` on `VoiceConnectionService.currentConnectionService`. `VoiceConnectionService.hasConnectionServiceFocus()` reads the current state. + ## Foreground service (Android 11 and later) Since Android 11, an app needs a [foreground service](https://developer.android.com/about/versions/11/privacy/foreground-services) to use the microphone in the background. Set the `foregroundService` key in the [`setup()` options](#options), and declare the service's types in the manifest: see [Android installation](android-installation.md#manifest). diff --git a/ios/RNCallKeep/RNCallKeep.m b/ios/RNCallKeep/RNCallKeep.m index 63288c28..c5abe988 100644 --- a/ios/RNCallKeep/RNCallKeep.m +++ b/ios/RNCallKeep/RNCallKeep.m @@ -164,6 +164,13 @@ - (void)onAudioRouteChange:(NSNotification *)notification - (void)sendEventWithNameWrapper:(NSString *)name body:(id)body { NSLog(@"[RNCallKeep] sendEventWithNameWrapper: %@, hasListeners : %@", name, _hasListeners ? @"YES": @"NO"); + // Native code can observe every event too, under the same name with the body as userInfo, + // as Android's local broadcasts allow: before a React Native runtime exists, and while + // JavaScript is busy. The event sent to JavaScript below is unchanged. + [[NSNotificationCenter defaultCenter] postNotificationName:name + object:nil + userInfo:body ?: @{}]; + if (_hasListeners) { [self sendEventWithName:name body:body]; } else { diff --git a/tests/android/CallKeepNativeSetupTest.kt b/tests/android/CallKeepNativeSetupTest.kt new file mode 100644 index 00000000..3a8f1012 --- /dev/null +++ b/tests/android/CallKeepNativeSetupTest.kt @@ -0,0 +1,68 @@ +package io.wazo.callkeep + +import android.os.Build +import android.telecom.Connection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.facebook.react.bridge.Arguments +import java.util.UUID +import org.junit.Assert.* +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Setup and an incoming call from native code, without a React context or a CallKeep module, then + * Telecom's call focus. Same host setup as CallKeepSelfManagedTest. Android 9+ (the call focus). + */ +@RunWith(AndroidJUnit4::class) +class CallKeepNativeSetupTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context get() = instrumentation.targetContext + + private fun waitFor(condition: () -> Boolean): Boolean { + val deadline = System.currentTimeMillis() + 5000 + while (System.currentTimeMillis() < deadline) { + var met = false + instrumentation.runOnMainSync { met = condition() } + if (met) return true + Thread.sleep(100) + } + return false + } + + @Test fun aNativeIncomingCallRingsAndGetsTheCallFocus() { + assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + // Other fixtures may have created the module in this process: this one runs without it. + val module = RNCallKeepModule.instance + RNCallKeepModule.instance = null + var gained = false + val uuid = UUID.randomUUID().toString() + try { + instrumentation.runOnMainSync { + VoiceConnectionService.nativeFocusListener = object : VoiceConnectionService.NativeFocusListener { + override fun onFocusGained() { gained = true } + override fun onFocusLost() { VoiceConnectionService.currentConnectionService?.connectionServiceFocusReleased() } + } + RNCallKeepModule.setupNative(context, Arguments.createMap().apply { putBoolean("selfManaged", true) }) + RNCallKeepModule.displayIncomingCall(context, uuid, "5551007", "Native call", false) + } + assertTrue("Telecom did not create the incoming connection", waitFor { VoiceConnectionService.getConnection(uuid) != null }) + instrumentation.runOnMainSync { + assertNull("native setup created a module", RNCallKeepModule.instance) + assertEquals(Connection.PROPERTY_SELF_MANAGED, + VoiceConnectionService.getConnection(uuid)!!.connectionProperties and Connection.PROPERTY_SELF_MANAGED) + VoiceConnectionService.getConnection(uuid)!!.onAnswer() + VoiceConnectionService.setState(uuid, Connection.STATE_ACTIVE) + } + assertTrue("the active call did not get the call focus", + waitFor { gained && VoiceConnectionService.hasConnectionServiceFocus() }) + } finally { + instrumentation.runOnMainSync { + VoiceConnectionService.getConnection(uuid)?.onDisconnect() + VoiceConnectionService.nativeFocusListener = null + } + RNCallKeepModule.instance = module + } + } +} From 1bb0157dd18ba6bdb285ca0df7c7a9e8876e27af Mon Sep 17 00:00:00 2001 From: Sylvain Boily Date: Sat, 17 Oct 2026 17:16:10 -0400 Subject: [PATCH 2/3] fix(android): the call focus ends with the last call, per review - 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 --- .../src/main/java/io/wazo/callkeep/RNCallKeepModule.java | 1 + .../main/java/io/wazo/callkeep/VoiceConnectionService.java | 2 ++ docs/events.md | 2 ++ docs/setup.md | 4 ++-- tests/android/CallKeepNativeSetupTest.kt | 7 +++++-- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java index f8c9bf21..8196ca6c 100644 --- a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java +++ b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java @@ -406,6 +406,7 @@ public static void setupNative(Context context, ReadableMap options) { _settings = storeSettings(context.getApplicationContext(), options); registerPhoneAccount(context.getApplicationContext()); VoiceConnectionService.setPhoneAccountHandle(handle); + // The state setup() ends in once the phone account is registered: available, so initialized. VoiceConnectionService.setAvailable(true); } diff --git a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java index 065ec5c8..c24dc012 100644 --- a/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java +++ b/android/src/main/java/io/wazo/callkeep/VoiceConnectionService.java @@ -195,6 +195,8 @@ public static void deinitConnection(String connectionId) { } if (currentConnections.isEmpty()) { + // Telecom doesn't call onConnectionServiceFocusLost when the last call ends. + connectionServiceFocused = false; Log.d(TAG, "[VoiceConnectionService] deinitConnection. No other connections left -> RUN stopForegroundService!"); currentConnectionService.stopForegroundService(); } else { diff --git a/docs/events.md b/docs/events.md index 87e239b8..b2e5280d 100644 --- a/docs/events.md +++ b/docs/events.md @@ -329,6 +329,8 @@ RNCallKeep.addEventListener('onHasActiveCall', () => { iOS also posts these events to `NotificationCenter.default`, under the names below, with the event's data as `userInfo`. Native code can observe them before a React Native runtime exists, or while JavaScript is busy; the events JavaScript receives are unchanged. `didLoadWithEvents` and `didChangeAudioRoute` are not posted. +Each notification is posted on the thread that sends the event, which can be CallKit's private queue (`didDisplayIncomingCall`, for instance). Observe with `queue: .main`, as below, or move to the main queue yourself before touching the UI or the app's state. + | Event | Notification name | | ---------------------------- | ---------------------------------------- | | didReceiveStartCallAction | `RNCallKeepDidReceiveStartCallAction` | diff --git a/docs/setup.md b/docs/setup.md index d1320bf5..3395a81d 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -136,9 +136,9 @@ RNCallKeepModule.setupNative(context, Arguments.createMap().apply { RNCallKeepModule.displayIncomingCall(context, callUUID, number, callerName, false) ``` -It stores the settings and registers the phone account as `setup()` does, but creates no module and no React context. Your app then shows its own incoming-call UI and handles the call from CallKeep's local broadcasts: register a receiver with `LocalBroadcastManager` for the `Constants.ACTION_*` actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, ...); each intent carries the call's `Constants.EXTRA_CALL_UUID` in its `attributeMap` extra. When React Native starts later, its module uses the same settings. +It stores the settings and registers the phone account as `setup()` does, but creates no module and no React context. Your app then shows its own incoming-call UI and handles the call from CallKeep's local broadcasts: register a receiver with `LocalBroadcastManager` for the `Constants.ACTION_*` actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, ...); each intent carries the call's `Constants.EXTRA_CALL_UUID` in its `attributeMap` extra. When React Native starts later, its module uses the same settings. CallKeep keeps a broadcast for JavaScript only when no receiver gets it: once your receiver is registered, the events it gets aren't returned by [`getInitialEvents()`](api.md#getinitialevents) when React Native starts. -Telecom gives self-managed calls the call focus (Android 9 and later). To start a call's audio before React Native runs, set `VoiceConnectionService.nativeFocusListener`: `onFocusGained` tells when the call may use audio, and on `onFocusLost` release its audio and microphone, then call `connectionServiceFocusReleased()` on `VoiceConnectionService.currentConnectionService`. `VoiceConnectionService.hasConnectionServiceFocus()` reads the current state. +Telecom gives self-managed calls the call focus (Android 9 and later). To start a call's audio before React Native runs, set `VoiceConnectionService.nativeFocusListener`: `onFocusGained` tells when the call may use audio, and on `onFocusLost` release its audio and microphone, then call `connectionServiceFocusReleased()` on `VoiceConnectionService.currentConnectionService`. `VoiceConnectionService.hasConnectionServiceFocus()` reads the current state; it turns false when the last call ends, for which Telecom calls no `onFocusLost`. ## Foreground service (Android 11 and later) diff --git a/tests/android/CallKeepNativeSetupTest.kt b/tests/android/CallKeepNativeSetupTest.kt index 3a8f1012..150156f3 100644 --- a/tests/android/CallKeepNativeSetupTest.kt +++ b/tests/android/CallKeepNativeSetupTest.kt @@ -13,7 +13,7 @@ import org.junit.runner.RunWith /** * Setup and an incoming call from native code, without a React context or a CallKeep module, then - * Telecom's call focus. Same host setup as CallKeepSelfManagedTest. Android 9+ (the call focus). + * Telecom's call focus, gained by the active call and gone with the last call. Same host setup as CallKeepSelfManagedTest. Android 9+ (the call focus). */ @RunWith(AndroidJUnit4::class) class CallKeepNativeSetupTest { @@ -31,7 +31,7 @@ class CallKeepNativeSetupTest { return false } - @Test fun aNativeIncomingCallRingsAndGetsTheCallFocus() { + @Test fun aNativeIncomingCallGetsTheCallFocusUntilItEnds() { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) // Other fixtures may have created the module in this process: this one runs without it. val module = RNCallKeepModule.instance @@ -57,6 +57,9 @@ class CallKeepNativeSetupTest { } assertTrue("the active call did not get the call focus", waitFor { gained && VoiceConnectionService.hasConnectionServiceFocus() }) + instrumentation.runOnMainSync { VoiceConnectionService.getConnection(uuid)!!.onDisconnect() } + assertTrue("the call focus outlived the last call", + waitFor { VoiceConnectionService.getConnection(uuid) == null && !VoiceConnectionService.hasConnectionServiceFocus() }) } finally { instrumentation.runOnMainSync { VoiceConnectionService.getConnection(uuid)?.onDisconnect() From 0b7ad36fd5b934b62f69b377ea5fe34350ce6446 Mon Sep 17 00:00:00 2001 From: Sylvain BOILY Date: Sat, 17 Oct 2026 17:27:10 -0400 Subject: [PATCH 3/3] docs: which broadcasts are kept for JavaScript, per review 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. --- docs/setup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/setup.md b/docs/setup.md index 3395a81d..3eb0e9a0 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -136,7 +136,7 @@ RNCallKeepModule.setupNative(context, Arguments.createMap().apply { RNCallKeepModule.displayIncomingCall(context, callUUID, number, callerName, false) ``` -It stores the settings and registers the phone account as `setup()` does, but creates no module and no React context. Your app then shows its own incoming-call UI and handles the call from CallKeep's local broadcasts: register a receiver with `LocalBroadcastManager` for the `Constants.ACTION_*` actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, ...); each intent carries the call's `Constants.EXTRA_CALL_UUID` in its `attributeMap` extra. When React Native starts later, its module uses the same settings. CallKeep keeps a broadcast for JavaScript only when no receiver gets it: once your receiver is registered, the events it gets aren't returned by [`getInitialEvents()`](api.md#getinitialevents) when React Native starts. +It stores the settings and registers the phone account as `setup()` does, but creates no module and no React context. Your app then shows its own incoming-call UI and handles the call from CallKeep's local broadcasts: register a receiver with `LocalBroadcastManager` for the `Constants.ACTION_*` actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, ...); each intent carries the call's `Constants.EXTRA_CALL_UUID` in its `attributeMap` extra. When React Native starts later, its module uses the same settings. Register your receiver before `displayIncomingCall`: the call's actions (`ACTION_SHOW_INCOMING_CALL_UI`, `ACTION_ANSWER_CALL`, `ACTION_END_CALL`, hold, DTMF...) reach only the receivers registered when they happen. Only the connection service's events (`ACTION_DID_DISPLAY_INCOMING_CALL`, `ACTION_ON_CREATE_CONNECTION_FAILED`...) are kept for JavaScript when no receiver gets them, so once your receiver is registered, React Native's [`getInitialEvents()`](api.md#getinitialevents) doesn't return them either. Telecom gives self-managed calls the call focus (Android 9 and later). To start a call's audio before React Native runs, set `VoiceConnectionService.nativeFocusListener`: `onFocusGained` tells when the call may use audio, and on `onFocusLost` release its audio and microphone, then call `connectionServiceFocusReleased()` on `VoiceConnectionService.currentConnectionService`. `VoiceConnectionService.hasConnectionServiceFocus()` reads the current state; it turns false when the last call ends, for which Telecom calls no `onFocusLost`.