From 60a68367f433f0b797a0682d4ff3ac727eb41153 Mon Sep 17 00:00:00 2001 From: Sylvain BOILY Date: Sat, 17 Oct 2026 18:12:05 -0400 Subject: [PATCH] fix(android): calls outlive the activity, as in other calling apps onHostDestroy, added in #724 to "stop all calls and processes when the app is killed", ended every call and killed the process whenever the React activity was destroyed. That is not the app being killed: a configuration change the activity doesn't handle (font size, language), Back on Android 11 and earlier, or swiping the app away from Recents destroy the activity while the process and React Native's runtime keep running. On Android 10 and 16 emulators, each of these ended an active call and closed the app. A call now lasts as long as something can handle it: - the activity is destroyed: the call goes on, as in WhatsApp or Teams. Telecom keeps the process running during a call, with or without the foreground service (checked on both versions); - the process dies: Telecom ends its calls itself (checked: a crash leaves no call); - React Native's instance stops while the process runs (a reload, an over-the-air update): the module's invalidate() ends the calls on the main thread. On master a reload left the call active in Telecom with no JavaScript to handle it. endCallsWhenReactNativeStops: false keeps them for apps whose native code owns the calls. It replaces the unreleased endCallsOnHostDestroy option. The example keeps its call state and CallKeep's listeners outside the screen, registered once from index.js, so a new activity shows the call that goes on; setup() still runs from the screen, and the events replayed from before it wait for it. docs/setup.md explains the lifecycle. --- CHANGELOG.md | 3 +- .../io/wazo/callkeep/RNCallKeepModule.java | 53 +-- docs/setup.md | 14 +- example/README.md | 3 +- example/index.js | 2 + example/src/useCallKeep.ts | 314 ++++++++++-------- index.d.ts | 4 +- .../CallKeepCallLifetimeOptionsTest.kt | 51 ++- 8 files changed, 258 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db619926..d50e3ac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,13 @@ are not listed here. ### Added - **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:** `endCallsWhenReactNativeStops` (default `true`): when React Native's instance stops while the app keeps running (a reload, an over-the-air update), CallKeep ends the calls, which no JavaScript handles any more. Set it to `false` when native code owns them. - **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 +- **Android:** Calls no longer end, and the app's process is no longer killed, when the React activity is destroyed: a configuration change the activity doesn't handle (font size, language), Back on Android 11 and earlier, or swiping the app away from Recents ended the call and closed the app. Calls go on, as in other calling apps, and end when the process dies (Telecom ends them) or when React Native's instance stops (`endCallsWhenReactNativeStops`). Keep the call state and CallKeep's listeners outside your screens; see [Calls and the app's lifecycle](docs/setup.md#calls-and-the-apps-lifecycle-android). - **Docs:** The Expo guide explains the config plugin's setup and what to add to it: the `audio` background mode and microphone text on iOS; on Android, the foreground service permissions, without which Android 14 and later refuse CallKeep's foreground service, and the `phoneCall|microphone` service types; `RECORD_AUDIO` with Expo SDK 54 and earlier. ([#849](https://github.com/react-native-webrtc/react-native-callkeep/pull/849), @sultanmyrza) - **Docs:** The API reference now lists `setSettings`, `setReachable`, `reportConnectingOutgoingCallWithUUID`, `reportConnectedOutgoingCallWithUUID` (iOS) and `unregisterAndroidEvents` (Android), which were never documented. CI checks that every method matches across `index.js`, `index.d.ts` and the API reference's table, and that the relative links in the docs resolve. - **Docs:** The iOS and Android installation guides are rewritten for autolinking: they still described `react-native link` and manual Xcode and Gradle steps from before React Native 0.60. They now follow the example app's setup, tested on devices: background modes, Recents and Siri (with or without scenes) and VoIP push on iOS; the manifest, foreground service types, runtime permissions, managed and self-managed modes on Android. diff --git a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java index 8196ca6c..9f910f91 100644 --- a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java +++ b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java @@ -66,6 +66,7 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.UiThreadUtil; import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableNativeMap; import com.facebook.react.HeadlessJsTaskService; @@ -826,32 +827,36 @@ public void onHostPause() { @Override public void onHostDestroy() { - // Calls that must outlive the activity (owned by native code, for instance) stay, and so - // does the process. - WritableMap settings = getSettings(this.getAppContext()); - if (settings != null && settings.hasKey("endCallsOnHostDestroy") && !settings.getBoolean("endCallsOnHostDestroy")) { - Log.d(TAG, "[RNCallKeepModule] onHostDestroy: calls kept, endCallsOnHostDestroy is false"); - return; - } - // When activity destroyed end all calls - Log.d(TAG, "[RNCallKeepModule] onHostDestroy called"); - if (!isConnectionServiceAvailable() || !hasPhoneAccount()) { - Log.w(TAG, "[RNCallKeepModule] onHostDestroy ignored due to no ConnectionService or no phone account"); - return; - } - - ArrayList> connections = - new ArrayList>(VoiceConnectionService.currentConnections.entrySet()); - for (Map.Entry connectionEntry : connections) { - Connection connectionToEnd = connectionEntry.getValue(); - connectionToEnd.onDisconnect(); - } - this.stopListenToNativeCallsState(); - Log.d(TAG, "[RNCallKeepModule] onHostDestroy executed"); - // This line will kill the android process after ending all calls - android.os.Process.killProcess(android.os.Process.myPid()); + // The activity is gone, not the call: an unhandled configuration change (font size, + // language), Back on Android 11 and earlier, or swiping the app away from Recents destroy it + // while the app's JavaScript keeps running. Calls go on, as in other calling apps. They end + // when the process dies (Telecom ends them) or when React Native stops (invalidate). + Log.d(TAG, "[RNCallKeepModule] onHostDestroy: the activity is destroyed, calls go on"); } + @Override + public void invalidate() { + // React Native's instance is going away (a reload, an over-the-air update): the JavaScript + // that handles the calls goes with it, so they end, unless native code owns them. + WritableMap settings = getSettings(this.getAppContext()); + boolean endCalls = settings == null || !settings.hasKey("endCallsWhenReactNativeStops") + || settings.getBoolean("endCallsWhenReactNativeStops"); + if (endCalls) { + // React Native may invalidate on a background thread; connections live on the main one. + UiThreadUtil.runOnUiThread(() -> { + ArrayList connections = new ArrayList<>(VoiceConnectionService.currentConnections.values()); + Log.d(TAG, "[RNCallKeepModule] invalidate: ending " + connections.size() + " call(s)"); + for (Connection connection : connections) { + connection.onDisconnect(); + } + }); + } else { + Log.d(TAG, "[RNCallKeepModule] invalidate: calls kept, endCallsWhenReactNativeStops is false"); + } + this.stopListenToNativeCallsState(); + super.invalidate(); + } + @ReactMethod public void rejectCall(String uuid) { Log.d(TAG, "[RNCallKeepModule] rejectCall, uuid: " + uuid); diff --git a/docs/setup.md b/docs/setup.md index 165f1ccb..170fb501 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -85,8 +85,8 @@ RNCallKeep.setup(options).then(accepted => {}); - `notificationTitle`: string - `notificationIcon`: string (optional), the name of a `mipmap` resource, such as `ic_launcher` - `foregroundServiceType`: number (optional, Android 10 and later), the types the service starts with, as a mask of [`ServiceInfo.FOREGROUND_SERVICE_TYPE_*`](https://developer.android.com/reference/android/content/pm/ServiceInfo#FOREGROUND_SERVICE_TYPE_PHONE_CALL) values: `4` for `phoneCall`, `132` for `phoneCall` and `microphone`. Each type must be declared on the service in the manifest. Without it, the service starts with every type the manifest declares. CallKeep starts the service when the call is created and keeps these types for the whole call: with `4` alone, an answered call has no microphone access while the app is in the background. To add `microphone` once the call is answered, call `startForeground(Constants.FOREGROUND_SERVICE_NOTIFICATION_ID, notification, types)` on `VoiceConnectionService.currentConnectionService` from native code, with both types - - `endCallsOnHostDestroy`: boolean (optional, default `true`) - When the React activity is destroyed, CallKeep ends every call and kills the app's process. Set it to `false` when your calls must outlive the activity, for instance when native code owns them: ending them is then up to your app. + - `endCallsWhenReactNativeStops`: boolean (optional, default `true`) + When React Native's instance stops while the app keeps running (a reload, an over-the-air update), CallKeep ends the calls: the JavaScript that handled them is gone. Set it to `false` when native code owns your calls, as with [setup from native code](#setup-from-native-code-android). See [Calls and the app's lifecycle](#calls-and-the-apps-lifecycle-android). - `selfManaged`: boolean (optional) When set to true, call keep will configure itself to run as a self managed connection service. See the [self-managed mode guide](guides/android-self-managed.md) and [Google's documentation](https://developer.android.com/guide/topics/connectivity/telecom/selfManaged). - `displayCallReachabilityTimeout`: number in ms (optional) @@ -142,6 +142,16 @@ It stores the settings and registers the phone account as `setup()` does, but cr 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`. +## Calls and the app's lifecycle (Android) + +A call lasts as long as the app can handle it, not as long as its screen: + +- **The activity is destroyed**, while the app keeps running: a configuration change the activity doesn't handle (font size, language), Back on Android 11 and earlier, or the user swiping the app away from Recents. The call goes on, as in other calling apps; Telecom keeps the app's process running during a call. The user gets back to it from the app or its notification. +- **The app's process dies** (a crash, or the system stops it): Telecom ends its calls. +- **React Native's instance stops** while the process runs (a reload, an over-the-air update): CallKeep ends the calls, unless [`endCallsWhenReactNativeStops`](#options) is `false`. + +So keep your call state, and register CallKeep's event listeners, outside your screens, once when the app starts (in `index.js`, for instance): a new activity renders the calls that are still going on. The [example app](../example/) does so. + ## 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/example/README.md b/example/README.md index 7d7501a7..98dbc2b9 100644 --- a/example/README.md +++ b/example/README.md @@ -38,6 +38,7 @@ Changes to the library's JavaScript (`../index.js`, `../actions.js`) reload with | **Audio routes** | Lists the speaker, receiver, Bluetooth and headset, and moves the active call to one. | `getAudioRoutes`, `setAudioRoute` | | **Events** | Every event the library sends, with its payload. | all of them | | **Android mode** | Switches between self-managed and managed mode and runs `setup` again. | `setup({ android: { selfManaged } })` | +| During a call (Android): change the font size, press **Back**, or swipe the app away from Recents | The call goes on. Reopen the app: it shows the call, which you can hang up. | [Calls and the app's lifecycle](../docs/setup.md#calls-and-the-apps-lifecycle-android) | | Call a contact back from **Recents** (iOS) | iOS opens the app and passes the call to the scene delegate; the app starts it. | `RNCallKeep.continueUserActivity` → `didReceiveStartCallAction` without `callUUID` → `startCall` | ## Where to look @@ -45,7 +46,7 @@ Changes to the library's JavaScript (`../index.js`, `../actions.js`) reload with | File | What it shows | |---|---| | [`src/callkeep.ts`](src/callkeep.ts) | The `setup()` options, Android runtime permissions, call UUIDs | -| [`src/useCallKeep.ts`](src/useCallKeep.ts) | The event listeners and what an app does on each one; events received before JavaScript was listening (`didLoadWithEvents`) | +| [`index.js`](index.js), [`src/useCallKeep.ts`](src/useCallKeep.ts) | The event listeners, registered once at startup, and what an app does on each one; the call state, kept outside the screen; events received before JavaScript was listening (`didLoadWithEvents`) | | [`App.tsx`](App.tsx) | The screen | | [`ios/CallKeepExample/SceneDelegate.swift`](ios/CallKeepExample/SceneDelegate.swift) | The scene life cycle that iOS 27 requires, and calls from Recents, Contacts or Siri | | [`ios/CallKeepExample/AppDelegate.swift`](ios/CallKeepExample/AppDelegate.swift) | Where an app handles VoIP pushes | diff --git a/example/index.js b/example/index.js index 9b739329..2b87fd3e 100644 --- a/example/index.js +++ b/example/index.js @@ -5,5 +5,7 @@ import { AppRegistry } from 'react-native'; import App from './App'; import { name as appName } from './app.json'; +import { startCallKeep } from './src/useCallKeep'; +startCallKeep(); AppRegistry.registerComponent(appName, () => App); diff --git a/example/src/useCallKeep.ts b/example/src/useCallKeep.ts index 98e2f2a1..bd24b23b 100644 --- a/example/src/useCallKeep.ts +++ b/example/src/useCallKeep.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useSyncExternalStore } from 'react'; import { Platform } from 'react-native'; import RNCallKeep, { CONSTANTS } from 'react-native-callkeep'; import type { AudioRoute, Events, EventsPayload } from 'react-native-callkeep'; @@ -49,162 +49,188 @@ const REPLAYED: Record = { type Handlers = { [E in Events]?: (data: EventsPayload[E]) => void }; -export function useCallKeep() { - const [selfManaged, setSelfManaged] = useState(true); - const [ready, setReady] = useState(false); - const [calls, setCalls] = useState([]); - const [log, setLog] = useState([]); - const [audioRoutes, setAudioRoutes] = useState([]); - const nextLogId = useRef(0); - - const addLog = useCallback((event: string, data?: unknown) => { - const entry = { - id: nextLogId.current++, - time: new Date().toLocaleTimeString(), - event, - data: data === undefined ? undefined : JSON.stringify(data), - }; - setLog(entries => [entry, ...entries].slice(0, 100)); - }, []); - - const addCall = useCallback((call: Call) => { - setCalls(list => (list.some(c => c.uuid === call.uuid) ? list : [...list, call])); - }, []); - const updateCall = useCallback((uuid: string, change: Partial) => { - setCalls(list => list.map(c => (c.uuid === uuid.toLowerCase() ? { ...c, ...change } : c))); - }, []); - const removeCall = useCallback((uuid: string) => { - setCalls(list => list.filter(c => c.uuid !== uuid.toLowerCase())); - }, []); - - // The example has no VoIP server: an outgoing call "connects" two seconds after it starts. An app - // reports these steps when its signaling says the remote side is ringing, then has answered. - const connectOutgoingCall = useCallback( - (uuid: string) => { - RNCallKeep.reportConnectingOutgoingCallWithUUID(uuid); // iOS only - setTimeout(() => { - RNCallKeep.reportConnectedOutgoingCallWithUUID(uuid); // iOS: CallKit starts the call timer - RNCallKeep.setCurrentCallActive(uuid); // Android: Telecom marks the call active - updateCall(uuid, { state: 'active' }); - }, 2000); - }, - [updateCall], - ); - - const handlers = useRef({}); - handlers.current = { - didReceiveStartCallAction: ({ handle, callUUID }) => { - if (!callUUID) { - // iOS, a call started from Recents, Contacts or Siri: the system only gives the number, and - // the app starts the call itself. - const uuid = newCallUUID(); - addCall({ uuid, handle, name: handle, direction: 'outgoing', state: 'connecting', held: false, muted: false }); - RNCallKeep.startCall(uuid, handle, handle, 'number', false); - return; - } - // Sent for every call startCall() starts, and on Android for calls started from the dialer. - addCall({ - uuid: callUUID.toLowerCase(), - handle, - name: handle, - direction: 'outgoing', - state: 'connecting', - held: false, - muted: false, - }); - connectOutgoingCall(callUUID); - }, - answerCall: ({ callUUID }) => { - // Answered from the system call screen or with answerIncomingCall(). An app connects the media - // here; on Android it then tells Telecom the call is active. - RNCallKeep.setCurrentCallActive(callUUID); - updateCall(callUUID, { state: 'active' }); - }, - endCall: ({ callUUID }) => removeCall(callUUID), - didDisplayIncomingCall: ({ callUUID, error }) => { - if (error) { - removeCall(callUUID); // CallKit refused the call (Do Not Disturb, blocked number, ...) - } - }, - didToggleHoldCallAction: ({ hold, callUUID }) => updateCall(callUUID, { held: hold }), - didPerformSetMutedCallAction: ({ muted, callUUID }) => updateCall(callUUID, { muted }), - // Android, self-managed: the app shows its own incoming-call screen. Here, the call's Answer and - // Decline buttons. - showIncomingCallUi: ({ callUUID, handle, name }) => - addCall({ - uuid: callUUID.toLowerCase(), - handle, - name, - direction: 'incoming', - state: 'ringing', - held: false, - muted: false, - }), - createIncomingConnectionFailed: ({ callUUID }) => removeCall(callUUID), - didResetProvider: () => setCalls([]), - didLoadWithEvents: events => { +// The calls and the log live outside the screen, as an app's call state should. On Android, calls +// outlive the activity: a font size or language change, Back on Android 11 and earlier, or swiping +// the app away from Recents destroy it while the call goes on, and a new screen reads the state again. +type State = { ready: boolean; selfManaged: boolean; calls: Call[]; log: LogEntry[]; audioRoutes: AudioRoute[] }; +let state: State = { ready: false, selfManaged: true, calls: [], log: [], audioRoutes: [] }; +const subscribers = new Set<() => void>(); + +function setState(change: (current: State) => Partial) { + state = { ...state, ...change(state) }; + subscribers.forEach(notify => notify()); +} + +function subscribe(notify: () => void) { + subscribers.add(notify); + return () => { + subscribers.delete(notify); + }; +} + +let nextLogId = 0; +function addLog(event: string, data?: unknown) { + const entry = { + id: nextLogId++, + time: new Date().toLocaleTimeString(), + event, + data: data === undefined ? undefined : JSON.stringify(data), + }; + setState(({ log }) => ({ log: [entry, ...log].slice(0, 100) })); +} + +function addCall(call: Call) { + setState(({ calls }) => ({ calls: calls.some(c => c.uuid === call.uuid) ? calls : [...calls, call] })); +} +function updateCall(uuid: string, change: Partial) { + setState(({ calls }) => ({ calls: calls.map(c => (c.uuid === uuid.toLowerCase() ? { ...c, ...change } : c)) })); +} +function removeCall(uuid: string) { + setState(({ calls }) => ({ calls: calls.filter(c => c.uuid !== uuid.toLowerCase()) })); +} + +// setup() runs from the screen, once per mode (see useCallKeep); events replayed from before it wait. +let setUpFor: boolean | undefined; +let markSetUp = () => {}; +const setUpOnce = new Promise(resolve => { + markSetUp = resolve; +}); + +// The example has no VoIP server: an outgoing call "connects" two seconds after it starts. An app +// reports these steps when its signaling says the remote side is ringing, then has answered. +function connectOutgoingCall(uuid: string) { + RNCallKeep.reportConnectingOutgoingCallWithUUID(uuid); // iOS only + setTimeout(() => { + RNCallKeep.reportConnectedOutgoingCallWithUUID(uuid); // iOS: CallKit starts the call timer + RNCallKeep.setCurrentCallActive(uuid); // Android: Telecom marks the call active + updateCall(uuid, { state: 'active' }); + }, 2000); +} + +const handlers: Handlers = { + didReceiveStartCallAction: ({ handle, callUUID }) => { + if (!callUUID) { + // iOS, a call started from Recents, Contacts or Siri: the system only gives the number, and + // the app starts the call itself. + const uuid = newCallUUID(); + addCall({ uuid, handle, name: handle, direction: 'outgoing', state: 'connecting', held: false, muted: false }); + RNCallKeep.startCall(uuid, handle, handle, 'number', false); + return; + } + // Sent for every call startCall() starts, and on Android for calls started from the dialer. + addCall({ + uuid: callUUID.toLowerCase(), + handle, + name: handle, + direction: 'outgoing', + state: 'connecting', + held: false, + muted: false, + }); + connectOutgoingCall(callUUID); + }, + answerCall: ({ callUUID }) => { + // Answered from the system call screen or with answerIncomingCall(). An app connects the media + // here; on Android it then tells Telecom the call is active. + RNCallKeep.setCurrentCallActive(callUUID); + updateCall(callUUID, { state: 'active' }); + }, + endCall: ({ callUUID }) => removeCall(callUUID), + didDisplayIncomingCall: ({ callUUID, error }) => { + if (error) { + removeCall(callUUID); // CallKit refused the call (Do Not Disturb, blocked number, ...) + } + }, + didToggleHoldCallAction: ({ hold, callUUID }) => updateCall(callUUID, { held: hold }), + didPerformSetMutedCallAction: ({ muted, callUUID }) => updateCall(callUUID, { muted }), + // Android, self-managed: the app shows its own incoming-call screen. Here, the call's Answer and + // Decline buttons. + showIncomingCallUi: ({ callUUID, handle, name }) => + addCall({ + uuid: callUUID.toLowerCase(), + handle, + name, + direction: 'incoming', + state: 'ringing', + held: false, + muted: false, + }), + createIncomingConnectionFailed: ({ callUUID }) => removeCall(callUUID), + didResetProvider: () => setState(() => ({ calls: [] })), + didLoadWithEvents: events => { + // These can come before setup() has run, as the listeners exist from startup: a call started + // from Recents on a cold start (iOS) needs it to start the call. + setUpOnce.then(() => { for (const { name, data } of events) { const event = REPLAYED[name]; if (event) { - (handlers.current[event] as ((payload: unknown) => void) | undefined)?.(data); + (handlers[event] as ((payload: unknown) => void) | undefined)?.(data); } } RNCallKeep.clearInitialEvents(); - }, - }; + }); + }, +}; - useEffect(() => { - const subscriptions = EVENTS.map(event => - RNCallKeep.addEventListener(event, data => { - addLog(event, data); - (handlers.current[event] as ((payload: unknown) => void) | undefined)?.(data); - }), - ); - return () => subscriptions.forEach(subscription => subscription.remove()); - }, [addLog]); +// Called once from index.js, before React renders: the listeners stay for the life of the JavaScript +// runtime, whatever happens to the screen. +export function startCallKeep() { + for (const event of EVENTS) { + RNCallKeep.addEventListener(event, data => { + addLog(event, data); + (handlers[event] as ((payload: unknown) => void) | undefined)?.(data); + }); + } +} + +async function setup(selfManaged: boolean) { + setUpFor = selfManaged; + await requestAndroidPermissions(); + try { + await RNCallKeep.setup(setupOptions(selfManaged)); + RNCallKeep.setAvailable(true); // Android: the app can take calls + setState(() => ({ ready: true })); + addLog('setup done', Platform.OS === 'android' ? { selfManaged } : undefined); + markSetUp(); + } catch (error) { + addLog('setup failed', String(error)); + } +} +function displayIncomingCall() { + const uuid = newCallUUID(); + addCall({ uuid, handle: '5551234', name: 'Alice (example)', direction: 'incoming', state: 'ringing', held: false, muted: false }); + RNCallKeep.displayIncomingCall(uuid, '5551234', 'Alice (example)', 'number', false); +} + +function startCall() { + const uuid = newCallUUID(); + addCall({ uuid, handle: '5555678', name: 'Bob (example)', direction: 'outgoing', state: 'connecting', held: false, muted: false }); + RNCallKeep.startCall(uuid, '5555678', 'Bob (example)', 'number', false); +} + +async function loadAudioRoutes() { + const routes = await RNCallKeep.getAudioRoutes(); + setState(() => ({ audioRoutes: routes })); + addLog('getAudioRoutes', routes); +} + +export function useCallKeep() { + const { ready, selfManaged, calls, log, audioRoutes } = useSyncExternalStore(subscribe, () => state); + + // setup() needs the activity on Android, where it may ask for permissions: the screen runs it, once + // per mode, and not again when a new screen replaces it. useEffect(() => { - let active = true; - (async () => { - await requestAndroidPermissions(); - try { - await RNCallKeep.setup(setupOptions(selfManaged)); - RNCallKeep.setAvailable(true); // Android: the app can take calls - if (active) { - setReady(true); - addLog('setup done', Platform.OS === 'android' ? { selfManaged } : undefined); - } - } catch (error) { - addLog('setup failed', String(error)); - } - })(); - return () => { - active = false; - }; - }, [selfManaged, addLog]); - - const displayIncomingCall = useCallback(() => { - const uuid = newCallUUID(); - addCall({ uuid, handle: '5551234', name: 'Alice (example)', direction: 'incoming', state: 'ringing', held: false, muted: false }); - RNCallKeep.displayIncomingCall(uuid, '5551234', 'Alice (example)', 'number', false); - }, [addCall]); - - const startCall = useCallback(() => { - const uuid = newCallUUID(); - addCall({ uuid, handle: '5555678', name: 'Bob (example)', direction: 'outgoing', state: 'connecting', held: false, muted: false }); - RNCallKeep.startCall(uuid, '5555678', 'Bob (example)', 'number', false); - }, [addCall]); - - const loadAudioRoutes = useCallback(async () => { - const routes = await RNCallKeep.getAudioRoutes(); - setAudioRoutes(routes); - addLog('getAudioRoutes', routes); - }, [addLog]); + if (setUpFor !== selfManaged) { + setup(selfManaged); + } + }, [selfManaged]); return { ready, selfManaged, - setSelfManaged, + // Android: setup() runs again in the other mode. + setSelfManaged: (value: boolean) => setState(() => ({ selfManaged: value, ready: false })), calls, log, audioRoutes, diff --git a/index.d.ts b/index.d.ts index eaf13b3f..d5bcf045 100644 --- a/index.d.ts +++ b/index.d.ts @@ -111,8 +111,8 @@ declare module 'react-native-callkeep' { imageName?: string, additionalPermissions: string[], selfManaged?: boolean, - /** End every call and the process when the React activity is destroyed (default true). */ - endCallsOnHostDestroy?: boolean, + /** End the calls when React Native's instance stops, as on a reload (default true). */ + endCallsWhenReactNativeStops?: boolean, foregroundService?: { channelId: string, channelName: string, diff --git a/tests/android/CallKeepCallLifetimeOptionsTest.kt b/tests/android/CallKeepCallLifetimeOptionsTest.kt index d17d5906..91dd6d50 100644 --- a/tests/android/CallKeepCallLifetimeOptionsTest.kt +++ b/tests/android/CallKeepCallLifetimeOptionsTest.kt @@ -21,7 +21,8 @@ import org.junit.Test import org.junit.runner.RunWith /** - * The `foregroundService.foregroundServiceType` and `endCallsOnHostDestroy` options, through Telecom. + * A call's lifetime and the `foregroundService.foregroundServiceType` and `endCallsWhenReactNativeStops` + * options, through Telecom. * Same host setup as CallKeepForegroundServiceTest. The host manifest must declare * `phoneCall|microphone` on VoiceConnectionService: with `phoneCall` alone, the service starts with * `phoneCall` without the option too, and the type test would prove nothing, so it asserts this. @@ -55,10 +56,10 @@ class CallKeepCallLifetimeOptionsTest { return condition() } - private fun selfManagedOptions(foregroundServiceType: Int? = null, endCallsOnHostDestroy: Boolean? = null): WritableMap = + private fun selfManagedOptions(foregroundServiceType: Int? = null, endCallsWhenReactNativeStops: Boolean? = null): WritableMap = Arguments.createMap().apply { putBoolean("selfManaged", true) - endCallsOnHostDestroy?.let { putBoolean("endCallsOnHostDestroy", it) } + endCallsWhenReactNativeStops?.let { putBoolean("endCallsWhenReactNativeStops", it) } putMap("foregroundService", Arguments.createMap().apply { putString("channelId", "io.wazo.callkeep.test") putString("channelName", "CallKeep test") @@ -92,24 +93,50 @@ class CallKeepCallLifetimeOptionsTest { } } - /** With `endCallsOnHostDestroy: false`, destroying the activity leaves the call and the process. */ - @Test fun theCallOutlivesTheActivityWhenAsked() { + /** A call that rings, then React Native's lifecycle: [react] runs on the module, [check] after. */ + private fun withACall(options: WritableMap, react: (RNCallKeepModule) -> Unit, check: (String) -> Unit) { assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) val module = RNCallKeepModule.getInstance(BridgeReactContext(context), true) - onModuleThread { module.setup(selfManagedOptions(endCallsOnHostDestroy = false)) } + onModuleThread { module.setup(options) } val uuid = UUID.randomUUID().toString() try { - onModuleThread { module.displayIncomingCall(uuid, "5551006", "Outlives the activity", false, null) } + onModuleThread { module.displayIncomingCall(uuid, "5551006", "Lifetime", false, null) } assertTrue("call not created", waitFor { VoiceConnectionService.currentConnections.containsKey(uuid) }) - // React Native calls this on the main thread. Without the option, it ends the calls and kills - // the process, which the instrumentation reports as a crash. - instrumentation.runOnMainSync { module.onHostDestroy() } - Thread.sleep(500) - assertTrue("the call ended with the activity", VoiceConnectionService.currentConnections.containsKey(uuid)) + react(module) + check(uuid) } finally { onModuleThread { module.endCall(uuid) } waitFor { !isForeground() } moduleThread.quitSafely() } } + + /** + * The activity is destroyed (a configuration change, Back, the app swiped away from Recents): the + * call goes on, and so does the process. Before, CallKeep ended the calls and killed the process, + * which the instrumentation reports as a crash. + */ + @Test fun theCallOutlivesTheActivity() = withACall(selfManagedOptions(), { module -> + // React Native calls this on the main thread. + instrumentation.runOnMainSync { module.onHostDestroy() } + Thread.sleep(500) + }) { uuid -> + assertTrue("the call ended with the activity", VoiceConnectionService.currentConnections.containsKey(uuid)) + } + + /** React Native's instance stops (a reload): the JavaScript that handles the call is gone, so it ends. */ + @Test fun theCallEndsWhenReactNativeStops() = withACall(selfManagedOptions(), { module -> + // React Native invalidates the modules off the main thread. + onModuleThread { module.invalidate() } + }) { uuid -> + assertTrue("the call outlived React Native", waitFor { !VoiceConnectionService.currentConnections.containsKey(uuid) }) + } + + /** With `endCallsWhenReactNativeStops: false`, native code owns the calls: they outlive React Native. */ + @Test fun nativeCallsOutliveReactNative() = withACall(selfManagedOptions(endCallsWhenReactNativeStops = false), { module -> + onModuleThread { module.invalidate() } + Thread.sleep(500) + }) { uuid -> + assertTrue("the call ended with React Native", VoiceConnectionService.currentConnections.containsKey(uuid)) + } }