From be924106d489c55cda3131490f731b1e4a841bdf Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 17 Aug 2026 17:40:07 -0300 Subject: [PATCH 01/14] fix(voip): run RNCallKeep.setup on all Android versions setup() was gated on the android.software.telecom system feature, but that feature is only declared from API 33. On Android 6-12 the gate was always false, so setup() never ran: _settings stayed empty, hasListeners stayed false and the static PhoneAccountHandle stayed null. Everything guarded by hasPhoneAccount() then became a silent no-op -- endCall, endAllCalls, rejectCall, reportEndCallWithUUID, answerIncomingCall, startCall, onHostDestroy. Ending a call from CallView left the self-managed Telecom connection ACTIVE forever, so VoipNotification.hasActiveCall() saw it and decideIncomingVoipPushAction rejected every later push as busy. Closing the app did not help, since Telecom keeps VoiceConnectionService bound. CallKeep JS events were queued into delayedEvents and never delivered either. Devices that genuinely lack the Telecom subsystem are already handled natively: registerPhoneAccount bails on !FEATURE_TELECOM and wraps the registration in try/catch (patches/react-native-callkeep+4.3.16.patch), which is where the crash prevention from #7334 belongs. Co-Authored-By: Claude Opus 5 (1M context) --- index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 9c3fd8555b5..ee672a1c7a3 100644 --- a/index.js +++ b/index.js @@ -2,7 +2,6 @@ import 'react-native-gesture-handler'; import 'react-native-console-time-polyfill'; import { AppRegistry, LogBox, PermissionsAndroid, Platform } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; -import DeviceInfo from 'react-native-device-info'; import { name as appName } from './app.json'; @@ -23,7 +22,11 @@ if (process.env.USE_STORYBOOK) { LogBox.ignoreAllLogs(); - if (Platform.OS === 'android' && DeviceInfo.hasSystemFeatureSync('android.software.telecom')) { + // Do not gate this on a PackageManager feature: FEATURE_TELECOM is only declared from API 33, + // so gating skipped setup() on every older device, leaving endCall and the CallKeep event + // listeners dead. Devices without the Telecom subsystem are handled natively — registerPhoneAccount + // bails on !FEATURE_TELECOM (see patches/react-native-callkeep+4.3.16.patch). + if (Platform.OS === 'android') { const options = { android: { // TODO: i18n From 2a4b6559693ab21f876fb8b69fbd8a237793ab77 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 17 Aug 2026 17:40:15 -0300 Subject: [PATCH 02/14] fix(voip): disconnect the Telecom connection natively on Android teardown The Telecom connection is created from native code (VoipNotification.registerCallWithTelecomManager), so teardown must not depend on RNCallKeep's JS-side module state being initialized. Whenever setup() has not run -- or has not run yet, for a push that arrives before JS boots -- RNCallKeep.endCall silently no-ops and the connection is stranded in ACTIVE, which makes every later incoming push get rejected as busy. Add VoipModule.disconnectNativeCall(callId), which calls onDisconnect() on the VoiceConnection registered for that call id, and use it in terminateNativeCall alongside RNCallKeep.endCall. iOS is a no-op stub: CallKit teardown keeps going through RNCallKeep. Co-Authored-By: Claude Opus 5 (1M context) --- .../rocket/reactnative/voip/VoipModule.kt | 15 +++++ app/lib/native/NativeVoip.ts | 12 ++++ .../voip/MediaSessionInstance.test.ts | 3 +- .../services/voip/terminateNativeCall.test.ts | 59 +++++++++++++++++++ app/lib/services/voip/terminateNativeCall.ts | 8 +++ .../services/voip/useCallStore.ios.test.ts | 1 + app/lib/services/voip/useCallStore.test.ts | 1 + app/views/CallView/index.test.tsx | 1 + ios/Libraries/VoipModule.mm | 4 ++ 9 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 app/lib/services/voip/terminateNativeCall.test.ts diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt index c0ec94430ee..594bad2f96f 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt @@ -17,6 +17,7 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.Arguments import com.facebook.react.modules.core.DeviceEventManagerModule import chat.rocket.reactnative.networking.NativeVoipSpec +import io.wazo.callkeep.VoiceConnectionService import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference @@ -199,6 +200,20 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo } } + override fun disconnectNativeCall(callId: String) { + try { + when (val connection = VoiceConnectionService.getConnection(callId)) { + null -> Log.d(TAG, "disconnectNativeCall: no connection for $callId") + else -> { + connection.onDisconnect() + Log.d(TAG, "disconnectNativeCall: disconnected $callId") + } + } + } catch (e: Exception) { + Log.e(TAG, "disconnectNativeCall: failed to disconnect $callId", e) + } + } + override fun startVoipCallService(callId: String, promise: Promise) { // Only valid for outgoing calls initiated from a visible activity. The incoming-accept // path starts the service from native (VoipNotification.handleAcceptAction) after Telecom diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index 4aa6636b53d..a194c56d2bb 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -56,6 +56,17 @@ export interface Spec extends TurboModule { */ stopVoipCallService(): void; + /** + * Disconnects the Telecom connection for a call without going through CallKeep. + * iOS: No-op (CallKit teardown goes through RNCallKeep). + * Android: Calls onDisconnect() on the VoiceConnection registered for `callId`, or no-ops when + * there is none. The connection is created natively (VoipNotification.registerCallWithTelecomManager), + * so teardown must not depend on RNCallKeep's JS-side module state being initialized — otherwise a + * failed/absent setup() strands the connection in ACTIVE and every later push is rejected as busy. + * Also covers a push arriving before JS has booted. + */ + disconnectNativeCall(callId: string): void; + /** * Routes call audio between speakerphone and earpiece. * Android: API 31+ uses AudioManager.setCommunicationDevice(SPEAKER) for on, @@ -106,6 +117,7 @@ const NativeVoipModule = stopNativeDDPClient: () => undefined, startVoipCallService: () => Promise.resolve(), stopVoipCallService: () => undefined, + disconnectNativeCall: () => undefined, setSpeakerOn: () => Promise.resolve(false), startAudioRouteSync: () => Promise.resolve(), stopAudioRouteSync: () => Promise.resolve(), diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 564bddb47d3..12ba1da9c40 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -137,7 +137,8 @@ jest.mock('../../native/NativeVoip', () => ({ default: { stopNativeDDPClient: jest.fn(), startVoipCallService: (callId: string) => mockStartVoipCallService(callId), - stopVoipCallService: () => mockStopVoipCallService() + stopVoipCallService: () => mockStopVoipCallService(), + disconnectNativeCall: jest.fn() } })); diff --git a/app/lib/services/voip/terminateNativeCall.test.ts b/app/lib/services/voip/terminateNativeCall.test.ts new file mode 100644 index 00000000000..ffaddff45e4 --- /dev/null +++ b/app/lib/services/voip/terminateNativeCall.test.ts @@ -0,0 +1,59 @@ +import { Platform } from 'react-native'; +import RNCallKeep from 'react-native-callkeep'; + +import NativeVoipModule from '../../native/NativeVoip'; +import { terminateNativeCall } from './terminateNativeCall'; + +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + disconnectNativeCall: jest.fn(), + stopVoipCallService: jest.fn() + } +})); + +describe('terminateNativeCall', () => { + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'android'; + }); + + it('disconnects the Telecom connection natively so teardown does not depend on CallKeep setup', () => { + terminateNativeCall('call-1'); + + expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-1'); + expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledWith('call-1'); + expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); + }); + + it('still disconnects natively when RNCallKeep.endCall throws', () => { + (RNCallKeep.endCall as jest.Mock).mockImplementationOnce(() => { + throw new Error('CallKeep unavailable'); + }); + + terminateNativeCall('call-2'); + + expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledWith('call-2'); + expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); + }); + + it('still stops the foreground service when the native disconnect throws', () => { + (NativeVoipModule.disconnectNativeCall as jest.Mock).mockImplementationOnce(() => { + throw new Error('bridge unavailable'); + }); + + terminateNativeCall('call-3'); + + expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); + }); + + it('does not touch the Android natives on iOS', () => { + Platform.OS = 'ios'; + + terminateNativeCall('call-4'); + + expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-4'); + expect(NativeVoipModule.disconnectNativeCall).not.toHaveBeenCalled(); + expect(NativeVoipModule.stopVoipCallService).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/services/voip/terminateNativeCall.ts b/app/lib/services/voip/terminateNativeCall.ts index c843f9fee40..15a6eaa924b 100644 --- a/app/lib/services/voip/terminateNativeCall.ts +++ b/app/lib/services/voip/terminateNativeCall.ts @@ -10,6 +10,14 @@ export function terminateNativeCall(callId: string): void { // CallKeep may be unavailable; still attempt to stop the Android service below } if (Platform.OS === 'android') { + try { + // The Telecom connection is created natively, so it must be disconnectable natively too: + // RNCallKeep.endCall silently no-ops whenever its JS-side setup() didn't run, which strands + // the connection in ACTIVE and makes every later incoming push get rejected as busy. + NativeVoipModule.disconnectNativeCall(callId); + } catch { + // bridge unavailable pre-boot + } try { NativeVoipModule.stopVoipCallService(); } catch { diff --git a/app/lib/services/voip/useCallStore.ios.test.ts b/app/lib/services/voip/useCallStore.ios.test.ts index b5e8bf25250..6bc8c73921e 100644 --- a/app/lib/services/voip/useCallStore.ios.test.ts +++ b/app/lib/services/voip/useCallStore.ios.test.ts @@ -48,6 +48,7 @@ jest.mock('../../native/NativeVoip', () => ({ getLastVoipToken: jest.fn(() => ''), stopNativeDDPClient: jest.fn(), stopVoipCallService: jest.fn(), + disconnectNativeCall: jest.fn(), addListener: jest.fn(), removeListeners: jest.fn() } diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index b41b1cdef6e..3c2b9d72915 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -53,6 +53,7 @@ jest.mock('../../native/NativeVoip', () => ({ getLastVoipToken: jest.fn(() => ''), stopNativeDDPClient: jest.fn(), stopVoipCallService: jest.fn(), + disconnectNativeCall: jest.fn(), addListener: jest.fn(), removeListeners: jest.fn() } diff --git a/app/views/CallView/index.test.tsx b/app/views/CallView/index.test.tsx index 70e8fec9a7a..f8a8fbad31b 100644 --- a/app/views/CallView/index.test.tsx +++ b/app/views/CallView/index.test.tsx @@ -32,6 +32,7 @@ jest.mock('../../lib/native/NativeVoip', () => ({ getLastVoipToken: jest.fn(() => ''), stopNativeDDPClient: jest.fn(), stopVoipCallService: jest.fn(), + disconnectNativeCall: jest.fn(), setSpeakerOn: jest.fn(() => Promise.resolve(true)), startAudioRouteSync: jest.fn(() => Promise.resolve()), stopAudioRouteSync: jest.fn(() => Promise.resolve()), diff --git a/ios/Libraries/VoipModule.mm b/ios/Libraries/VoipModule.mm index 0183db4aeea..542432cd802 100644 --- a/ios/Libraries/VoipModule.mm +++ b/ios/Libraries/VoipModule.mm @@ -124,6 +124,10 @@ - (void)startVoipCallService:(NSString *)callId resolve(nil); } +// Android-only Telecom teardown fallback. iOS tears CallKit down through RNCallKeep. +- (void)disconnectNativeCall:(NSString *)callId { +} + // iOS keeps using InCallManager.setForceSpeakerphoneOn from JS; this stub satisfies the codegen spec. - (void)setSpeakerOn:(BOOL)on resolve:(RCTPromiseResolveBlock)resolve From a4674e3504dabb0d63bba5ff7b11617fd57c3db6 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 17 Aug 2026 17:40:07 -0300 Subject: [PATCH 03/14] fix(voip): run RNCallKeep.setup on all Android versions setup() was gated on the android.software.telecom system feature, but that feature is only declared from API 33. On Android 6-12 the gate was always false, so setup() never ran: _settings stayed empty, hasListeners stayed false and the static PhoneAccountHandle stayed null. Everything guarded by hasPhoneAccount() then became a silent no-op -- endCall, endAllCalls, rejectCall, reportEndCallWithUUID, answerIncomingCall, startCall, onHostDestroy. Ending a call from CallView left the self-managed Telecom connection ACTIVE forever, so VoipNotification.hasActiveCall() saw it and decideIncomingVoipPushAction rejected every later push as busy. Closing the app did not help, since Telecom keeps VoiceConnectionService bound. CallKeep JS events were queued into delayedEvents and never delivered either. Devices that genuinely lack the Telecom subsystem are already handled natively: registerPhoneAccount bails on !FEATURE_TELECOM and wraps the registration in try/catch (patches/react-native-callkeep+4.3.16.patch), which is where the crash prevention from #7334 belongs. Co-Authored-By: Claude Opus 5 (1M context) --- index.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/index.js b/index.js index ee672a1c7a3..149900bb2e2 100644 --- a/index.js +++ b/index.js @@ -22,10 +22,7 @@ if (process.env.USE_STORYBOOK) { LogBox.ignoreAllLogs(); - // Do not gate this on a PackageManager feature: FEATURE_TELECOM is only declared from API 33, - // so gating skipped setup() on every older device, leaving endCall and the CallKeep event - // listeners dead. Devices without the Telecom subsystem are handled natively — registerPhoneAccount - // bails on !FEATURE_TELECOM (see patches/react-native-callkeep+4.3.16.patch). + // Never gate on FEATURE_TELECOM (API 33+): non-Telecom devices bail natively in registerPhoneAccount if (Platform.OS === 'android') { const options = { android: { From ff541163bfd21ff02037d8fb13fdfe9db38eb499 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 17 Aug 2026 17:40:15 -0300 Subject: [PATCH 04/14] fix(voip): disconnect the Telecom connection natively on Android teardown The Telecom connection is created from native code (VoipNotification.registerCallWithTelecomManager), so teardown must not depend on RNCallKeep's JS-side module state being initialized. Whenever setup() has not run -- or has not run yet, for a push that arrives before JS boots -- RNCallKeep.endCall silently no-ops and the connection is stranded in ACTIVE, which makes every later incoming push get rejected as busy. Add VoipModule.disconnectNativeCall(callId), which calls onDisconnect() on the VoiceConnection registered for that call id, and use it in terminateNativeCall alongside RNCallKeep.endCall. iOS is a no-op stub: CallKit teardown keeps going through RNCallKeep. Co-Authored-By: Claude Opus 5 (1M context) --- app/lib/native/NativeVoip.ts | 3 +++ app/lib/services/voip/terminateNativeCall.ts | 8 ++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index a194c56d2bb..b9fd34ebb74 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -56,6 +56,9 @@ export interface Spec extends TurboModule { */ stopVoipCallService(): void; + /** Android: disconnects the VoiceConnection registered for `callId`, bypassing CallKeep. iOS: no-op. */ + disconnectNativeCall(callId: string): void; + /** * Disconnects the Telecom connection for a call without going through CallKeep. * iOS: No-op (CallKit teardown goes through RNCallKeep). diff --git a/app/lib/services/voip/terminateNativeCall.ts b/app/lib/services/voip/terminateNativeCall.ts index 15a6eaa924b..c38a1b5a9f8 100644 --- a/app/lib/services/voip/terminateNativeCall.ts +++ b/app/lib/services/voip/terminateNativeCall.ts @@ -11,13 +11,9 @@ export function terminateNativeCall(callId: string): void { } if (Platform.OS === 'android') { try { - // The Telecom connection is created natively, so it must be disconnectable natively too: - // RNCallKeep.endCall silently no-ops whenever its JS-side setup() didn't run, which strands - // the connection in ACTIVE and makes every later incoming push get rejected as busy. + // RNCallKeep.endCall no-ops when its JS-side setup() didn't run, stranding the connection NativeVoipModule.disconnectNativeCall(callId); - } catch { - // bridge unavailable pre-boot - } + } catch {} try { NativeVoipModule.stopVoipCallService(); } catch { From 55a853a7002de0b9355a767c70938ae9f4bdefd1 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 19 Aug 2026 17:51:01 +0000 Subject: [PATCH 05/14] chore: format code and fix lint issues --- app/containers/UIKit/Button.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/containers/UIKit/Button.tsx b/app/containers/UIKit/Button.tsx index e16a7d13d95..eb2c570ff2e 100644 --- a/app/containers/UIKit/Button.tsx +++ b/app/containers/UIKit/Button.tsx @@ -42,7 +42,11 @@ const UIKitButton: FC = ({ title, onPress, type = 'primary', accessibilityLabel={title} accessibilityRole='button' style={({ pressed }) => [styles.container, { backgroundColor }, style, pressed && styles.pressed]}> - {loading ? : {title}} + {loading ? ( + + ) : ( + {title} + )} ); }; From 2eaab9fd369d5b228868048f3656054e15acc695 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 19 Aug 2026 14:56:20 -0300 Subject: [PATCH 06/14] fix(voip): disconnect natively before CallKeep clears the connection --- .../services/voip/terminateNativeCall.test.ts | 46 ++++++++++++++---- app/lib/services/voip/terminateNativeCall.ts | 48 +++++++++++++++++-- app/lib/services/voip/useCallStore.ts | 6 ++- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/app/lib/services/voip/terminateNativeCall.test.ts b/app/lib/services/voip/terminateNativeCall.test.ts index ffaddff45e4..a8b95041b4e 100644 --- a/app/lib/services/voip/terminateNativeCall.test.ts +++ b/app/lib/services/voip/terminateNativeCall.test.ts @@ -2,7 +2,7 @@ import { Platform } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; import NativeVoipModule from '../../native/NativeVoip'; -import { terminateNativeCall } from './terminateNativeCall'; +import { resetTerminateNativeCallForTesting, terminateNativeCall } from './terminateNativeCall'; jest.mock('../../native/NativeVoip', () => ({ __esModule: true, @@ -15,6 +15,7 @@ jest.mock('../../native/NativeVoip', () => ({ describe('terminateNativeCall', () => { beforeEach(() => { jest.clearAllMocks(); + resetTerminateNativeCallForTesting(); Platform.OS = 'android'; }); @@ -26,24 +27,53 @@ describe('terminateNativeCall', () => { expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); }); - it('still disconnects natively when RNCallKeep.endCall throws', () => { - (RNCallKeep.endCall as jest.Mock).mockImplementationOnce(() => { - throw new Error('CallKeep unavailable'); + it('disconnects natively before RNCallKeep.endCall removes the connection', () => { + const order: string[] = []; + (NativeVoipModule.disconnectNativeCall as jest.Mock).mockImplementationOnce(() => order.push('native')); + (RNCallKeep.endCall as jest.Mock).mockImplementationOnce(() => order.push('callkeep')); + + terminateNativeCall('call-order'); + + expect(order).toEqual(['native', 'callkeep']); + }); + + it('ignores repeat invocations for the same callId', () => { + terminateNativeCall('call-dup'); + terminateNativeCall('call-dup'); + + expect(RNCallKeep.endCall).toHaveBeenCalledTimes(1); + expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledTimes(1); + expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalledTimes(1); + }); + + it('still terminates a different callId after one was already terminated', () => { + terminateNativeCall('call-a'); + terminateNativeCall('call-b'); + + expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-a'); + expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-b'); + expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalledTimes(2); + }); + + it('still calls RNCallKeep.endCall when the native disconnect throws', () => { + (NativeVoipModule.disconnectNativeCall as jest.Mock).mockImplementationOnce(() => { + throw new Error('bridge unavailable'); }); terminateNativeCall('call-2'); - expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledWith('call-2'); + expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-2'); expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); }); - it('still stops the foreground service when the native disconnect throws', () => { - (NativeVoipModule.disconnectNativeCall as jest.Mock).mockImplementationOnce(() => { - throw new Error('bridge unavailable'); + it('still stops the foreground service when RNCallKeep.endCall throws', () => { + (RNCallKeep.endCall as jest.Mock).mockImplementationOnce(() => { + throw new Error('CallKeep unavailable'); }); terminateNativeCall('call-3'); + expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledWith('call-3'); expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); }); diff --git a/app/lib/services/voip/terminateNativeCall.ts b/app/lib/services/voip/terminateNativeCall.ts index c38a1b5a9f8..41b42e0025f 100644 --- a/app/lib/services/voip/terminateNativeCall.ts +++ b/app/lib/services/voip/terminateNativeCall.ts @@ -3,17 +3,47 @@ import RNCallKeep from 'react-native-callkeep'; import NativeVoipModule from '../../native/NativeVoip'; +// Termination is triggered from several independent paths (useCallStore.endCall, +// acceptNativeCall, and the MediaSessionInstance event handlers), so the same +// callId arrives more than once. Bounded because the app is long-lived. +const MAX_TRACKED_CALL_IDS = 32; +const terminatedCallIds = new Set(); + +function markTerminated(callId: string): void { + terminatedCallIds.add(callId); + while (terminatedCallIds.size > MAX_TRACKED_CALL_IDS) { + const oldest = terminatedCallIds.values().next().value; + if (oldest === undefined) { + break; + } + terminatedCallIds.delete(oldest); + } +} + export function terminateNativeCall(callId: string): void { + if (terminatedCallIds.has(callId)) { + return; + } + markTerminated(callId); + + // The native disconnect runs first: RNCallKeep.endCall removes the connection from + // VoiceConnectionService's map, which would leave nothing for this call to find. + if (Platform.OS === 'android') { + try { + NativeVoipModule.disconnectNativeCall(callId); + } catch { + // bridge unavailable pre-boot + } + } + try { + // No-op when the native disconnect above already tore the connection down. RNCallKeep.endCall(callId); } catch { - // CallKeep may be unavailable; still attempt to stop the Android service below + // CallKeep may be unavailable; still stop the Android service below } + if (Platform.OS === 'android') { - try { - // RNCallKeep.endCall no-ops when its JS-side setup() didn't run, stranding the connection - NativeVoipModule.disconnectNativeCall(callId); - } catch {} try { NativeVoipModule.stopVoipCallService(); } catch { @@ -21,3 +51,11 @@ export function terminateNativeCall(callId: string): void { } } } + +/** + * Resets module-scoped state for testing purposes. + * NOT intended for production use. + */ +export function resetTerminateNativeCallForTesting(): void { + terminatedCallIds.clear(); +} diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 9bd6a46706c..2ae76d75c76 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -310,7 +310,11 @@ export const useCallStore = create((set, get) => ({ } if (call) { - call.hangup(); + try { + call.hangup(); + } catch (e) { + log(e); + } } if (callUuid) { From 9febb2737f08496d30197c01c09670f006870c0b Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 19 Aug 2026 15:09:06 -0300 Subject: [PATCH 07/14] fix: tests --- .../NewMediaCall/VoipCallLifecycle.integration.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index bf538ddaf89..8763a8cfd4c 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -25,6 +25,7 @@ import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocom import { useCallStore } from '../../lib/services/voip/useCallStore'; import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; import { acceptNativeCallWithReadiness } from '../../lib/services/voip/acceptNativeCall'; +import { resetTerminateNativeCallForTesting } from '../../lib/services/voip/terminateNativeCall'; import { mockedStore } from '../../reducers/mockedStore'; import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; import type { InsideStackParamList } from '../../stacks/types'; @@ -397,6 +398,7 @@ describe('VoIP call lifecycle (integration)', () => { unexpectedConsoleErrors = []; usePeerAutocompleteStore.getState().reset(); useCallStore.getState().reset(); + resetTerminateNativeCallForTesting(); mediaSessionInstance.reset(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { From 006aec8e615a39bdd80086da03cf4ef4d85fc7cd Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 19 Aug 2026 15:48:01 -0300 Subject: [PATCH 08/14] fix: build --- app/lib/native/NativeVoip.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index b9fd34ebb74..a194c56d2bb 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -56,9 +56,6 @@ export interface Spec extends TurboModule { */ stopVoipCallService(): void; - /** Android: disconnects the VoiceConnection registered for `callId`, bypassing CallKeep. iOS: no-op. */ - disconnectNativeCall(callId: string): void; - /** * Disconnects the Telecom connection for a call without going through CallKeep. * iOS: No-op (CallKit teardown goes through RNCallKeep). From e3515c79c5d80d9d85c2913511122490a50adc04 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 19 Aug 2026 16:59:18 -0300 Subject: [PATCH 09/14] comment --- app/lib/native/NativeVoip.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index a194c56d2bb..d8bb6e650d8 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -56,15 +56,7 @@ export interface Spec extends TurboModule { */ stopVoipCallService(): void; - /** - * Disconnects the Telecom connection for a call without going through CallKeep. - * iOS: No-op (CallKit teardown goes through RNCallKeep). - * Android: Calls onDisconnect() on the VoiceConnection registered for `callId`, or no-ops when - * there is none. The connection is created natively (VoipNotification.registerCallWithTelecomManager), - * so teardown must not depend on RNCallKeep's JS-side module state being initialized — otherwise a - * failed/absent setup() strands the connection in ACTIVE and every later push is rejected as busy. - * Also covers a push arriving before JS has booted. - */ + /** Android: disconnects the VoiceConnection for `callId` directly, bypassing RNCallKeep's JS state. iOS: no-op. */ disconnectNativeCall(callId: string): void; /** From 56f3a8f6a89f5cb71d71c128df6f2ef345ecca3e Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 24 Aug 2026 18:42:32 -0300 Subject: [PATCH 10/14] fix: run the full teardown when the call ends on js --- .../rocket/reactnative/voip/VoipModule.kt | 9 +---- .../reactnative/voip/VoipNotification.kt | 39 +++++++++++++++++++ .../VoipCallLifecycle.integration.test.tsx | 6 +-- .../voip/MediaSessionInstance.test.ts | 3 +- app/lib/services/voip/docs/ARCHITECTURE.md | 3 ++ app/lib/services/voip/docs/FLOWS.md | 7 +++- app/lib/services/voip/docs/PLATFORMS.md | 4 ++ app/lib/services/voip/resetVoipState.test.ts | 21 +++++++++- app/lib/services/voip/resetVoipState.ts | 4 +- .../services/voip/terminateNativeCall.test.ts | 4 +- app/lib/services/voip/terminateNativeCall.ts | 6 +-- .../services/voip/useCallStore.ios.test.ts | 5 +++ app/lib/services/voip/useCallStore.test.ts | 5 +++ app/views/CallView/index.test.tsx | 5 +++ ios/Libraries/VoipModule.mm | 4 ++ 15 files changed, 104 insertions(+), 21 deletions(-) diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt index 594bad2f96f..14114b219ff 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt @@ -17,7 +17,6 @@ import com.facebook.react.bridge.WritableMap import com.facebook.react.bridge.Arguments import com.facebook.react.modules.core.DeviceEventManagerModule import chat.rocket.reactnative.networking.NativeVoipSpec -import io.wazo.callkeep.VoiceConnectionService import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicReference @@ -202,13 +201,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo override fun disconnectNativeCall(callId: String) { try { - when (val connection = VoiceConnectionService.getConnection(callId)) { - null -> Log.d(TAG, "disconnectNativeCall: no connection for $callId") - else -> { - connection.onDisconnect() - Log.d(TAG, "disconnectNativeCall: disconnected $callId") - } - } + VoipNotification.terminateIncomingCall(reactApplicationContext, callId) } catch (e: Exception) { Log.e(TAG, "disconnectNativeCall: failed to disconnect $callId", e) } diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt index f712195367c..dab6a15f56d 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt @@ -37,6 +37,7 @@ import chat.rocket.reactnative.BuildConfig import chat.rocket.reactnative.R import org.json.JSONArray import org.json.JSONObject +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean /** @@ -87,6 +88,40 @@ class VoipNotification(private val context: Context) { private fun deviceId(context: Context): String = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + /** + * Payload of every incoming call whose notification / full-screen UI is still up, keyed by + * callId. [terminateIncomingCall] needs it to send the same dismissal set the + * notification-action paths send; entries are dropped by the disconnect helpers below. + */ + private val activeIncomingPayloads = ConcurrentHashMap() + + /** + * JS-driven teardown (`NativeVoip.disconnectNativeCall`). Runs the same dismissal set as the + * notification-action paths — Telecom disconnect, foreground service, notification and + * [ACTION_DISMISS] — so a terminate that lands while the call is still ringing also finishes + * [IncomingCallActivity] instead of leaving it on screen. Always runs on the main looper, + * because the caller is a synchronous TurboModule method on an arbitrary JS thread. + */ + @JvmStatic + fun terminateIncomingCall(context: Context, callId: String) { + val appContext = context.applicationContext + Handler(Looper.getMainLooper()).post { + cancelTimeout(callId) + val payload = activeIncomingPayloads[callId] + disconnectIncomingCall(callId, false) + VoipCallService.stopService(appContext) + if (payload != null) { + cancelById(appContext, payload.notificationId) + LocalBroadcastManager.getInstance(appContext).sendBroadcast( + Intent(ACTION_DISMISS).apply { + putExtras(payload.toBundle()) + } + ) + } + ddpRegistry.stopClient(callId) + } + } + /** * Cancels a VoIP notification by ID. */ @@ -396,6 +431,7 @@ class VoipNotification(private val context: Context) { // TODO: unify these three functions and check VoiceConnectionService private fun disconnectTimedOutCall(callId: String) { + activeIncomingPayloads.remove(callId) val connection = VoiceConnectionService.getConnection(callId) when (connection) { is VoiceConnection -> connection.reportDisconnect(DISCONNECT_REASON_MISSED) @@ -407,6 +443,7 @@ class VoipNotification(private val context: Context) { } private fun rejectIncomingCall(callId: String) { + activeIncomingPayloads.remove(callId) val connection = VoiceConnectionService.getConnection(callId) when (connection) { is VoiceConnection -> connection.onReject() @@ -418,6 +455,7 @@ class VoipNotification(private val context: Context) { } private fun disconnectIncomingCall(callId: String, reportAsMissed: Boolean) { + activeIncomingPayloads.remove(callId) val connection = VoiceConnectionService.getConnection(callId) when (connection) { is VoiceConnection -> { @@ -749,6 +787,7 @@ class VoipNotification(private val context: Context) { registerCallWithTelecomManager(callId, caller) // Show notification with full-screen intent + activeIncomingPayloads[callId] = voipPayload showIncomingCallNotification(voipPayload) scheduleTimeout(context, voipPayload) startListeningForCallEnd(context, voipPayload) diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index 8763a8cfd4c..eda19d4bc22 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -25,7 +25,7 @@ import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocom import { useCallStore } from '../../lib/services/voip/useCallStore'; import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; import { acceptNativeCallWithReadiness } from '../../lib/services/voip/acceptNativeCall'; -import { resetTerminateNativeCallForTesting } from '../../lib/services/voip/terminateNativeCall'; +import { clearTerminateDedupeSentinels } from '../../lib/services/voip/terminateNativeCall'; import { mockedStore } from '../../reducers/mockedStore'; import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; import type { InsideStackParamList } from '../../stacks/types'; @@ -138,7 +138,7 @@ jest.mock('react-native-device-info', () => ({ })); jest.mock('../../lib/native/NativeVoip', () => ({ __esModule: true, - default: { stopNativeDDPClient: jest.fn() } + default: { stopNativeDDPClient: jest.fn(), disconnectNativeCall: jest.fn(), stopVoipCallService: jest.fn() } })); jest.mock('../../lib/methods/voipCallPermissions', () => ({ requestVoipCallPermissions: jest.fn().mockResolvedValue(true) @@ -398,7 +398,7 @@ describe('VoIP call lifecycle (integration)', () => { unexpectedConsoleErrors = []; usePeerAutocompleteStore.getState().reset(); useCallStore.getState().reset(); - resetTerminateNativeCallForTesting(); + clearTerminateDedupeSentinels(); mediaSessionInstance.reset(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 12ba1da9c40..564bddb47d3 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -137,8 +137,7 @@ jest.mock('../../native/NativeVoip', () => ({ default: { stopNativeDDPClient: jest.fn(), startVoipCallService: (callId: string) => mockStartVoipCallService(callId), - stopVoipCallService: () => mockStopVoipCallService(), - disconnectNativeCall: jest.fn() + stopVoipCallService: () => mockStopVoipCallService() } })); diff --git a/app/lib/services/voip/docs/ARCHITECTURE.md b/app/lib/services/voip/docs/ARCHITECTURE.md index 39b3c81c279..23647f2328a 100644 --- a/app/lib/services/voip/docs/ARCHITECTURE.md +++ b/app/lib/services/voip/docs/ARCHITECTURE.md @@ -138,6 +138,8 @@ JS subscribes to native events via a single emitter (`NativeEventEmitter(NativeV | `NativeVoipModule.startAudioRouteSync` | JS → native | Android only | Begin observing audio route changes for `VoipCommunicationDeviceChanged`. | | `NativeVoipModule.setSpeakerOn` | JS → native | Android only | Drive `AudioManager` directly (Android speaker toggle). | | `NativeVoipModule.getInitialEvents` / `clearInitialEvents` | JS → native | both | Cold-start handoff: read and clear the stashed accept event. | +| `NativeVoipModule.disconnectNativeCall` | JS → native | Android only | Run the incoming-call teardown for a `callId` (Telecom disconnect, FGS stop, notification + `ACTION_DISMISS`, per-call DDP stop). iOS stub is a no-op. Called from `terminateNativeCall`. | +| `NativeVoipModule.stopVoipCallService` | JS → native | Android only | Stop `VoipCallService` after the call is torn down. iOS stub is a no-op; the JS `Platform` check keeps it off the iOS path. | ### Cold start — initial events @@ -169,4 +171,5 @@ Each invariant is grounded in a test in `app/lib/services/voip/*.test.ts` or an - **Stale-session UUID gating** — `didPerformSetMutedCallAction` and `didToggleHoldCallAction` compare the event UUID (lowercased) against the active call UUID and drop the event on mismatch. Required because `setupMediaCallEvents` lives on Root and survives logout/server-switch. See inline comments in `MediaCallEvents.ts`. - **Auto-hold vs manual hold** — `wasAutoHeld` distinguishes OS-driven hold (a competing CallKit/Telecom call) from a user manually pressing hold; only auto-held calls are auto-resumed. See inline code in `MediaCallEvents.ts`. - **Optimistic `roomId` rollback** — `startCallByRoom` clears its optimistic `setRoomId` if `startCall` rejects, so a concurrent incoming call can resolve its own DM context. Verified by `roomId population` block ("startCallByRoom clears optimistic roomId when post-permission guard rejects"). +- **Terminate dedupe reset** — `terminateNativeCall`'s per-`callId` sentinel set is cleared by `resetVoipState`, so a reused `callId` after logout / account switch is not short-circuited. Verified by `resetVoipState.test.ts` ("after resetVoipState, a previously-terminated callId is torn down again (terminate sentinel cleared)"). Sibling of the accept-dedupe sentinel reset in the same function. - **Self-host signal gating** — `notification/accepted` signals are only acted on when `signedContractId === mobileDeviceId`; another device on the same account cannot trick this device into binding. Verified by the `stream-notify-user (notification/accepted gated)` block. diff --git a/app/lib/services/voip/docs/FLOWS.md b/app/lib/services/voip/docs/FLOWS.md index c17edc74d7d..d7612911afc 100644 --- a/app/lib/services/voip/docs/FLOWS.md +++ b/app/lib/services/voip/docs/FLOWS.md @@ -297,7 +297,12 @@ sequenceDiagram Note over Native,Store: Stale-session guards ensure a CallKit/Telecom event for an old call (after server switch / logout) is ignored: UUID mismatch drops the event before any state change. ``` -_Last verified: cd2faa00a_ +"Terminate native call" is `terminateNativeCall(callId)`, deduped per `callId` by a module-level sentinel set (cleared by `resetVoipState`). What it does differs by platform: + +- **iOS** — `RNCallKeep.endCall(callId)` only. +- **Android** — `NativeVoip.disconnectNativeCall(callId)` first, then `RNCallKeep.endCall(callId)`, then `NativeVoip.stopVoipCallService()`. `disconnectNativeCall` delegates to `VoipNotification.terminateIncomingCall`, which runs the full incoming-call teardown on the main looper: cancel the timeout, disconnect the Telecom connection, stop `VoipCallService`, and stop the per-call DDP client. If the incoming payload for that `callId` is still tracked in this process (`activeIncomingPayloads`, populated by `showIncomingCall`), it also cancels the notification and broadcasts `ACTION_DISMISS`, which finishes `IncomingCallActivity` — the same dismissal set the notification-action paths run, so terminating a still-ringing call clears the full-screen UI. + +_Last verified: e3515c79c_ --- diff --git a/app/lib/services/voip/docs/PLATFORMS.md b/app/lib/services/voip/docs/PLATFORMS.md index 116b198dd97..c843e1a510c 100644 --- a/app/lib/services/voip/docs/PLATFORMS.md +++ b/app/lib/services/voip/docs/PLATFORMS.md @@ -61,6 +61,10 @@ VoIP incoming calls arrive as FCM **data-only payloads** (so the app can wake wi `IncomingCallActivity` is the lock-screen activity that surfaces the incoming call UI when the device is locked. +### JS-driven teardown — `terminateIncomingCall` + +Everything above is native-owned, but JS can also end a call that native is still presenting (in-app hangup, peer hangup, a failed accept reconciliation). `terminateNativeCall` calls `NativeVoip.disconnectNativeCall(callId)`, which delegates to `VoipNotification.terminateIncomingCall` — the single shared teardown, posted to the main looper because the caller is a synchronous TurboModule method on the JS thread. It cancels the timeout, disconnects the Telecom connection, stops `VoipCallService`, and stops the per-call DDP client; when the incoming payload for that `callId` is still tracked in-process (`activeIncomingPayloads`), it also cancels the notification and broadcasts `ACTION_DISMISS` so `IncomingCallActivity` finishes. Without that dismissal a JS-side terminate would leave the full-screen incoming UI on screen. JS then calls `RNCallKeep.endCall(callId)` and `NativeVoip.stopVoipCallService()`. + ### Per-call DDP — `VoipPerCallDdpRegistry.kt` Mirrors the iOS rationale: a short-lived DDP client per incoming call so the REST accept and any inbound signaling can land before the app's main DDP socket exists. The registry is keyed by `callId`; clients close on accept-resolved, call-ended, or timeout. diff --git a/app/lib/services/voip/resetVoipState.test.ts b/app/lib/services/voip/resetVoipState.test.ts index 105ec87deba..462049558cf 100644 --- a/app/lib/services/voip/resetVoipState.test.ts +++ b/app/lib/services/voip/resetVoipState.test.ts @@ -2,6 +2,7 @@ import { DeviceEventEmitter } from 'react-native'; import { resetMediaCallEventsStateForTesting, setupMediaCallEvents, type MediaCallEventsAdapters } from './MediaCallEvents'; import { resetVoipState } from './resetVoipState'; +import { terminateNativeCall } from './terminateNativeCall'; import { useCallStore } from './useCallStore'; jest.mock('../../methods/helpers', () => ({ @@ -19,7 +20,9 @@ jest.mock('../../native/NativeVoip', () => ({ __esModule: true, default: { clearInitialEvents: jest.fn(), - getInitialEvents: jest.fn(() => null) + getInitialEvents: jest.fn(() => null), + disconnectNativeCall: jest.fn(), + stopVoipCallService: jest.fn() } })); @@ -29,6 +32,7 @@ jest.mock('react-native-callkeep', () => ({ addEventListener: jest.fn(() => ({ remove: jest.fn() })), clearInitialEvents: jest.fn(), setCurrentCallActive: jest.fn(), + endCall: jest.fn(), getInitialEvents: jest.fn(() => Promise.resolve([])) } })); @@ -146,4 +150,19 @@ describe('resetVoipState', () => { DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2); }); + + it('after resetVoipState, a previously-terminated callId is torn down again (terminate sentinel cleared)', () => { + (useCallStore.getState as jest.Mock).mockReturnValue({ resetNativeCallId: jest.fn(), reset: jest.fn() }); + const endCall = jest.requireMock('react-native-callkeep').default.endCall as jest.Mock; + endCall.mockClear(); + + terminateNativeCall('reused-terminate-id'); + terminateNativeCall('reused-terminate-id'); + expect(endCall).toHaveBeenCalledTimes(1); + + resetVoipState(); + + terminateNativeCall('reused-terminate-id'); + expect(endCall).toHaveBeenCalledTimes(2); + }); }); diff --git a/app/lib/services/voip/resetVoipState.ts b/app/lib/services/voip/resetVoipState.ts index c839e1fb9a6..c9fcaa01f0c 100644 --- a/app/lib/services/voip/resetVoipState.ts +++ b/app/lib/services/voip/resetVoipState.ts @@ -1,9 +1,11 @@ import { useCallStore } from './useCallStore'; import { clearVoipAcceptDedupeSentinels } from './MediaCallEvents'; +import { clearTerminateDedupeSentinels } from './terminateNativeCall'; -/** Resets VoIP UI / native-call-id state after accept failure or similar teardown (deep linking saga). Also clears accept-dedupe sentinels so Android cold-start and re-delivery paths are not poisoned by a prior call. */ +/** Resets VoIP UI / native-call-id state after accept failure or similar teardown (deep linking saga). Also clears the accept- and terminate-dedupe sentinels so Android cold-start and re-delivery paths are not poisoned by a prior call. */ export function resetVoipState(): void { clearVoipAcceptDedupeSentinels(); + clearTerminateDedupeSentinels(); const { resetNativeCallId, reset } = useCallStore.getState(); resetNativeCallId(); reset(); diff --git a/app/lib/services/voip/terminateNativeCall.test.ts b/app/lib/services/voip/terminateNativeCall.test.ts index a8b95041b4e..1e81187f519 100644 --- a/app/lib/services/voip/terminateNativeCall.test.ts +++ b/app/lib/services/voip/terminateNativeCall.test.ts @@ -2,7 +2,7 @@ import { Platform } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; import NativeVoipModule from '../../native/NativeVoip'; -import { resetTerminateNativeCallForTesting, terminateNativeCall } from './terminateNativeCall'; +import { clearTerminateDedupeSentinels, terminateNativeCall } from './terminateNativeCall'; jest.mock('../../native/NativeVoip', () => ({ __esModule: true, @@ -15,7 +15,7 @@ jest.mock('../../native/NativeVoip', () => ({ describe('terminateNativeCall', () => { beforeEach(() => { jest.clearAllMocks(); - resetTerminateNativeCallForTesting(); + clearTerminateDedupeSentinels(); Platform.OS = 'android'; }); diff --git a/app/lib/services/voip/terminateNativeCall.ts b/app/lib/services/voip/terminateNativeCall.ts index 41b42e0025f..afd77f859be 100644 --- a/app/lib/services/voip/terminateNativeCall.ts +++ b/app/lib/services/voip/terminateNativeCall.ts @@ -53,9 +53,9 @@ export function terminateNativeCall(callId: string): void { } /** - * Resets module-scoped state for testing purposes. - * NOT intended for production use. + * Clears the terminate-dedupe sentinels. Called from `resetVoipState` alongside the accept-dedupe + * sentinels so a logout / account switch cannot leave a reused callId short-circuited here. */ -export function resetTerminateNativeCallForTesting(): void { +export function clearTerminateDedupeSentinels(): void { terminatedCallIds.clear(); } diff --git a/app/lib/services/voip/useCallStore.ios.test.ts b/app/lib/services/voip/useCallStore.ios.test.ts index 6bc8c73921e..db632e660ba 100644 --- a/app/lib/services/voip/useCallStore.ios.test.ts +++ b/app/lib/services/voip/useCallStore.ios.test.ts @@ -4,6 +4,7 @@ import type { IClientMediaCall } from '@rocket.chat/media-signaling'; import { useCallStore } from './useCallStore'; +import { clearTerminateDedupeSentinels } from './terminateNativeCall'; const mockLog = jest.fn(); jest.mock('../../methods/helpers/log', () => ({ @@ -88,6 +89,10 @@ function createMockCall(callId: string) { return { call }; } +beforeEach(() => { + clearTerminateDedupeSentinels(); +}); + describe('useCallStore audio route sync (iOS, isIOS=true)', () => { beforeEach(() => { useCallStore.getState().resetNativeCallId(); diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index 3c2b9d72915..6e50ca6ea46 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -6,6 +6,7 @@ import InCallManager from 'react-native-incall-manager'; import NativeVoipModule from '../../native/NativeVoip'; import { pendingHangups } from './pendingHangups'; import { useCallStore } from './useCallStore'; +import { clearTerminateDedupeSentinels } from './terminateNativeCall'; const mockLog = jest.fn(); jest.mock('../../methods/helpers/log', () => ({ @@ -123,6 +124,10 @@ function createMockCall(callId: string, options?: { initialState?: string }) { return { call, emit }; } +beforeEach(() => { + clearTerminateDedupeSentinels(); +}); + describe('createMockCall emitter', () => { it('forwards variadic arguments to listeners', () => { const { call, emit } = createMockCall('e1'); diff --git a/app/views/CallView/index.test.tsx b/app/views/CallView/index.test.tsx index f8a8fbad31b..22fd996652d 100644 --- a/app/views/CallView/index.test.tsx +++ b/app/views/CallView/index.test.tsx @@ -9,6 +9,7 @@ import { useCallStore } from '../../lib/services/voip/useCallStore'; import { mockedStore } from '../../reducers/mockedStore'; import * as stories from './CallView.stories'; import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; +import { clearTerminateDedupeSentinels } from '../../lib/services/voip/terminateNativeCall'; const mockStartRingback = jest.fn(() => Promise.resolve()); const mockStopRingback = jest.fn(() => Promise.resolve()); @@ -160,6 +161,10 @@ const setStoreState = (overrides: Partial {children}; +beforeEach(() => { + clearTerminateDedupeSentinels(); +}); + describe('CallView/CallView', () => { beforeEach(() => { mockWindowWidth = 350; diff --git a/ios/Libraries/VoipModule.mm b/ios/Libraries/VoipModule.mm index 542432cd802..e8ab9c3e9cc 100644 --- a/ios/Libraries/VoipModule.mm +++ b/ios/Libraries/VoipModule.mm @@ -128,6 +128,10 @@ - (void)startVoipCallService:(NSString *)callId - (void)disconnectNativeCall:(NSString *)callId { } +// Android-only foreground service teardown; this stub satisfies the codegen spec. +- (void)stopVoipCallService { +} + // iOS keeps using InCallManager.setForceSpeakerphoneOn from JS; this stub satisfies the codegen spec. - (void)setSpeakerOn:(BOOL)on resolve:(RCTPromiseResolveBlock)resolve From 24788a989bfa37dea44a94565fea02899e470365 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Mon, 24 Aug 2026 21:46:49 +0000 Subject: [PATCH 11/14] chore: format code and fix lint issues --- app/lib/services/voip/docs/ARCHITECTURE.md | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/app/lib/services/voip/docs/ARCHITECTURE.md b/app/lib/services/voip/docs/ARCHITECTURE.md index 23647f2328a..bd90bd78f37 100644 --- a/app/lib/services/voip/docs/ARCHITECTURE.md +++ b/app/lib/services/voip/docs/ARCHITECTURE.md @@ -126,18 +126,18 @@ The RN app's main DDP socket is owned by `sdk` and is bound to the active worksp JS subscribes to native events via a single emitter (`NativeEventEmitter(NativeVoipModule)` on iOS, `DeviceEventEmitter` on Android) plus `RNCallKeep`'s emitter. The contract: -| Event | Direction | Carrier | Purpose | -| ---------------------------------------------------------- | ----------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VoipPushTokenRegistered` | native → JS | iOS only | A new PushKit token is available; JS calls `registerPushToken()`. | -| `VoipAcceptSucceeded` | native → JS | both | Native accept completed; payload `{ callId, host, type, username? }`; JS sets `nativeAcceptedCallId` and either replays REST (host matches) or hands off to deep linking (host differs). | -| `VoipAcceptFailed` | native → JS | both | Native accept failed; JS dispatches the deep-linking pipeline so the user lands on a usable state on the right workspace. | -| `VoipCommunicationDeviceChanged` | native → JS | Android only | OS audio route changed (speaker on/off); JS mirrors `isSpeakerOn`. | -| `RNCallKeep:endCall` | native → JS | both | User pressed end on the system UI; JS calls `mediaSessionInstance.endCall(callUUID)`. | -| `RNCallKeep:didPerformSetMutedCallAction` | native → JS | iOS | OS mute toggle; JS reconciles via the `if (muted !== isMuted) toggleMute()` echo guard. | -| `RNCallKeep:didToggleHoldCallAction` | native → JS | both | OS hold (e.g. competing call); JS uses `wasAutoHeld` to distinguish OS-driven hold from manual hold. | -| `NativeVoipModule.startAudioRouteSync` | JS → native | Android only | Begin observing audio route changes for `VoipCommunicationDeviceChanged`. | -| `NativeVoipModule.setSpeakerOn` | JS → native | Android only | Drive `AudioManager` directly (Android speaker toggle). | -| `NativeVoipModule.getInitialEvents` / `clearInitialEvents` | JS → native | both | Cold-start handoff: read and clear the stashed accept event. | +| Event | Direction | Carrier | Purpose | +| ---------------------------------------------------------- | ----------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `VoipPushTokenRegistered` | native → JS | iOS only | A new PushKit token is available; JS calls `registerPushToken()`. | +| `VoipAcceptSucceeded` | native → JS | both | Native accept completed; payload `{ callId, host, type, username? }`; JS sets `nativeAcceptedCallId` and either replays REST (host matches) or hands off to deep linking (host differs). | +| `VoipAcceptFailed` | native → JS | both | Native accept failed; JS dispatches the deep-linking pipeline so the user lands on a usable state on the right workspace. | +| `VoipCommunicationDeviceChanged` | native → JS | Android only | OS audio route changed (speaker on/off); JS mirrors `isSpeakerOn`. | +| `RNCallKeep:endCall` | native → JS | both | User pressed end on the system UI; JS calls `mediaSessionInstance.endCall(callUUID)`. | +| `RNCallKeep:didPerformSetMutedCallAction` | native → JS | iOS | OS mute toggle; JS reconciles via the `if (muted !== isMuted) toggleMute()` echo guard. | +| `RNCallKeep:didToggleHoldCallAction` | native → JS | both | OS hold (e.g. competing call); JS uses `wasAutoHeld` to distinguish OS-driven hold from manual hold. | +| `NativeVoipModule.startAudioRouteSync` | JS → native | Android only | Begin observing audio route changes for `VoipCommunicationDeviceChanged`. | +| `NativeVoipModule.setSpeakerOn` | JS → native | Android only | Drive `AudioManager` directly (Android speaker toggle). | +| `NativeVoipModule.getInitialEvents` / `clearInitialEvents` | JS → native | both | Cold-start handoff: read and clear the stashed accept event. | | `NativeVoipModule.disconnectNativeCall` | JS → native | Android only | Run the incoming-call teardown for a `callId` (Telecom disconnect, FGS stop, notification + `ACTION_DISMISS`, per-call DDP stop). iOS stub is a no-op. Called from `terminateNativeCall`. | | `NativeVoipModule.stopVoipCallService` | JS → native | Android only | Stop `VoipCallService` after the call is torn down. iOS stub is a no-op; the JS `Platform` check keeps it off the iOS path. | From b1aeef1563ed29eee123fd502605c9667c409861 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 26 Aug 2026 18:51:45 -0300 Subject: [PATCH 12/14] fix: prevent crash issue --- index.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index 149900bb2e2..b41c5c3222f 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,7 @@ import 'react-native-gesture-handler'; import 'react-native-console-time-polyfill'; import { AppRegistry, LogBox, PermissionsAndroid, Platform } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; +import DeviceInfo from 'react-native-device-info'; import { name as appName } from './app.json'; @@ -22,8 +23,12 @@ if (process.env.USE_STORYBOOK) { LogBox.ignoreAllLogs(); - // Never gate on FEATURE_TELECOM (API 33+): non-Telecom devices bail natively in registerPhoneAccount - if (Platform.OS === 'android') { + // FEATURE_TELECOM is only declared from API 33; trusting the query below that killed setup() and endCall (#7334) + const isAndroid = Platform.OS === 'android'; + const supportsTelecom = + isAndroid && (Number(Platform.Version) < 33 || DeviceInfo.hasSystemFeatureSync('android.software.telecom')); + + if (supportsTelecom) { const options = { android: { // TODO: i18n From e06e2a319e4dcfc506dd22a68112f80334c03295 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 26 Aug 2026 19:38:33 -0300 Subject: [PATCH 13/14] cleanup --- .../rocket/reactnative/voip/VoipModule.kt | 8 -- .../reactnative/voip/VoipNotification.kt | 39 -------- .../VoipCallLifecycle.integration.test.tsx | 4 +- app/lib/native/NativeVoip.ts | 4 - app/lib/services/voip/docs/ARCHITECTURE.md | 27 +++--- app/lib/services/voip/docs/FLOWS.md | 7 +- app/lib/services/voip/docs/PLATFORMS.md | 4 - app/lib/services/voip/resetVoipState.test.ts | 21 +---- app/lib/services/voip/resetVoipState.ts | 4 +- .../services/voip/terminateNativeCall.test.ts | 89 ------------------- app/lib/services/voip/terminateNativeCall.ts | 44 +-------- .../services/voip/useCallStore.ios.test.ts | 6 -- app/lib/services/voip/useCallStore.test.ts | 6 -- app/lib/services/voip/useCallStore.ts | 6 +- app/views/CallView/index.test.tsx | 6 -- index.js | 5 +- ios/Libraries/VoipModule.mm | 8 -- 17 files changed, 20 insertions(+), 268 deletions(-) delete mode 100644 app/lib/services/voip/terminateNativeCall.test.ts diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt index 14114b219ff..c0ec94430ee 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt @@ -199,14 +199,6 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo } } - override fun disconnectNativeCall(callId: String) { - try { - VoipNotification.terminateIncomingCall(reactApplicationContext, callId) - } catch (e: Exception) { - Log.e(TAG, "disconnectNativeCall: failed to disconnect $callId", e) - } - } - override fun startVoipCallService(callId: String, promise: Promise) { // Only valid for outgoing calls initiated from a visible activity. The incoming-accept // path starts the service from native (VoipNotification.handleAcceptAction) after Telecom diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt index dab6a15f56d..f712195367c 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt @@ -37,7 +37,6 @@ import chat.rocket.reactnative.BuildConfig import chat.rocket.reactnative.R import org.json.JSONArray import org.json.JSONObject -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean /** @@ -88,40 +87,6 @@ class VoipNotification(private val context: Context) { private fun deviceId(context: Context): String = Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) - /** - * Payload of every incoming call whose notification / full-screen UI is still up, keyed by - * callId. [terminateIncomingCall] needs it to send the same dismissal set the - * notification-action paths send; entries are dropped by the disconnect helpers below. - */ - private val activeIncomingPayloads = ConcurrentHashMap() - - /** - * JS-driven teardown (`NativeVoip.disconnectNativeCall`). Runs the same dismissal set as the - * notification-action paths — Telecom disconnect, foreground service, notification and - * [ACTION_DISMISS] — so a terminate that lands while the call is still ringing also finishes - * [IncomingCallActivity] instead of leaving it on screen. Always runs on the main looper, - * because the caller is a synchronous TurboModule method on an arbitrary JS thread. - */ - @JvmStatic - fun terminateIncomingCall(context: Context, callId: String) { - val appContext = context.applicationContext - Handler(Looper.getMainLooper()).post { - cancelTimeout(callId) - val payload = activeIncomingPayloads[callId] - disconnectIncomingCall(callId, false) - VoipCallService.stopService(appContext) - if (payload != null) { - cancelById(appContext, payload.notificationId) - LocalBroadcastManager.getInstance(appContext).sendBroadcast( - Intent(ACTION_DISMISS).apply { - putExtras(payload.toBundle()) - } - ) - } - ddpRegistry.stopClient(callId) - } - } - /** * Cancels a VoIP notification by ID. */ @@ -431,7 +396,6 @@ class VoipNotification(private val context: Context) { // TODO: unify these three functions and check VoiceConnectionService private fun disconnectTimedOutCall(callId: String) { - activeIncomingPayloads.remove(callId) val connection = VoiceConnectionService.getConnection(callId) when (connection) { is VoiceConnection -> connection.reportDisconnect(DISCONNECT_REASON_MISSED) @@ -443,7 +407,6 @@ class VoipNotification(private val context: Context) { } private fun rejectIncomingCall(callId: String) { - activeIncomingPayloads.remove(callId) val connection = VoiceConnectionService.getConnection(callId) when (connection) { is VoiceConnection -> connection.onReject() @@ -455,7 +418,6 @@ class VoipNotification(private val context: Context) { } private fun disconnectIncomingCall(callId: String, reportAsMissed: Boolean) { - activeIncomingPayloads.remove(callId) val connection = VoiceConnectionService.getConnection(callId) when (connection) { is VoiceConnection -> { @@ -787,7 +749,6 @@ class VoipNotification(private val context: Context) { registerCallWithTelecomManager(callId, caller) // Show notification with full-screen intent - activeIncomingPayloads[callId] = voipPayload showIncomingCallNotification(voipPayload) scheduleTimeout(context, voipPayload) startListeningForCallEnd(context, voipPayload) diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index eda19d4bc22..bf538ddaf89 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -25,7 +25,6 @@ import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocom import { useCallStore } from '../../lib/services/voip/useCallStore'; import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; import { acceptNativeCallWithReadiness } from '../../lib/services/voip/acceptNativeCall'; -import { clearTerminateDedupeSentinels } from '../../lib/services/voip/terminateNativeCall'; import { mockedStore } from '../../reducers/mockedStore'; import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; import type { InsideStackParamList } from '../../stacks/types'; @@ -138,7 +137,7 @@ jest.mock('react-native-device-info', () => ({ })); jest.mock('../../lib/native/NativeVoip', () => ({ __esModule: true, - default: { stopNativeDDPClient: jest.fn(), disconnectNativeCall: jest.fn(), stopVoipCallService: jest.fn() } + default: { stopNativeDDPClient: jest.fn() } })); jest.mock('../../lib/methods/voipCallPermissions', () => ({ requestVoipCallPermissions: jest.fn().mockResolvedValue(true) @@ -398,7 +397,6 @@ describe('VoIP call lifecycle (integration)', () => { unexpectedConsoleErrors = []; usePeerAutocompleteStore.getState().reset(); useCallStore.getState().reset(); - clearTerminateDedupeSentinels(); mediaSessionInstance.reset(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { diff --git a/app/lib/native/NativeVoip.ts b/app/lib/native/NativeVoip.ts index d8bb6e650d8..4aa6636b53d 100644 --- a/app/lib/native/NativeVoip.ts +++ b/app/lib/native/NativeVoip.ts @@ -56,9 +56,6 @@ export interface Spec extends TurboModule { */ stopVoipCallService(): void; - /** Android: disconnects the VoiceConnection for `callId` directly, bypassing RNCallKeep's JS state. iOS: no-op. */ - disconnectNativeCall(callId: string): void; - /** * Routes call audio between speakerphone and earpiece. * Android: API 31+ uses AudioManager.setCommunicationDevice(SPEAKER) for on, @@ -109,7 +106,6 @@ const NativeVoipModule = stopNativeDDPClient: () => undefined, startVoipCallService: () => Promise.resolve(), stopVoipCallService: () => undefined, - disconnectNativeCall: () => undefined, setSpeakerOn: () => Promise.resolve(false), startAudioRouteSync: () => Promise.resolve(), stopAudioRouteSync: () => Promise.resolve(), diff --git a/app/lib/services/voip/docs/ARCHITECTURE.md b/app/lib/services/voip/docs/ARCHITECTURE.md index bd90bd78f37..39b3c81c279 100644 --- a/app/lib/services/voip/docs/ARCHITECTURE.md +++ b/app/lib/services/voip/docs/ARCHITECTURE.md @@ -126,20 +126,18 @@ The RN app's main DDP socket is owned by `sdk` and is bound to the active worksp JS subscribes to native events via a single emitter (`NativeEventEmitter(NativeVoipModule)` on iOS, `DeviceEventEmitter` on Android) plus `RNCallKeep`'s emitter. The contract: -| Event | Direction | Carrier | Purpose | -| ---------------------------------------------------------- | ----------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `VoipPushTokenRegistered` | native → JS | iOS only | A new PushKit token is available; JS calls `registerPushToken()`. | -| `VoipAcceptSucceeded` | native → JS | both | Native accept completed; payload `{ callId, host, type, username? }`; JS sets `nativeAcceptedCallId` and either replays REST (host matches) or hands off to deep linking (host differs). | -| `VoipAcceptFailed` | native → JS | both | Native accept failed; JS dispatches the deep-linking pipeline so the user lands on a usable state on the right workspace. | -| `VoipCommunicationDeviceChanged` | native → JS | Android only | OS audio route changed (speaker on/off); JS mirrors `isSpeakerOn`. | -| `RNCallKeep:endCall` | native → JS | both | User pressed end on the system UI; JS calls `mediaSessionInstance.endCall(callUUID)`. | -| `RNCallKeep:didPerformSetMutedCallAction` | native → JS | iOS | OS mute toggle; JS reconciles via the `if (muted !== isMuted) toggleMute()` echo guard. | -| `RNCallKeep:didToggleHoldCallAction` | native → JS | both | OS hold (e.g. competing call); JS uses `wasAutoHeld` to distinguish OS-driven hold from manual hold. | -| `NativeVoipModule.startAudioRouteSync` | JS → native | Android only | Begin observing audio route changes for `VoipCommunicationDeviceChanged`. | -| `NativeVoipModule.setSpeakerOn` | JS → native | Android only | Drive `AudioManager` directly (Android speaker toggle). | -| `NativeVoipModule.getInitialEvents` / `clearInitialEvents` | JS → native | both | Cold-start handoff: read and clear the stashed accept event. | -| `NativeVoipModule.disconnectNativeCall` | JS → native | Android only | Run the incoming-call teardown for a `callId` (Telecom disconnect, FGS stop, notification + `ACTION_DISMISS`, per-call DDP stop). iOS stub is a no-op. Called from `terminateNativeCall`. | -| `NativeVoipModule.stopVoipCallService` | JS → native | Android only | Stop `VoipCallService` after the call is torn down. iOS stub is a no-op; the JS `Platform` check keeps it off the iOS path. | +| Event | Direction | Carrier | Purpose | +| ---------------------------------------------------------- | ----------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `VoipPushTokenRegistered` | native → JS | iOS only | A new PushKit token is available; JS calls `registerPushToken()`. | +| `VoipAcceptSucceeded` | native → JS | both | Native accept completed; payload `{ callId, host, type, username? }`; JS sets `nativeAcceptedCallId` and either replays REST (host matches) or hands off to deep linking (host differs). | +| `VoipAcceptFailed` | native → JS | both | Native accept failed; JS dispatches the deep-linking pipeline so the user lands on a usable state on the right workspace. | +| `VoipCommunicationDeviceChanged` | native → JS | Android only | OS audio route changed (speaker on/off); JS mirrors `isSpeakerOn`. | +| `RNCallKeep:endCall` | native → JS | both | User pressed end on the system UI; JS calls `mediaSessionInstance.endCall(callUUID)`. | +| `RNCallKeep:didPerformSetMutedCallAction` | native → JS | iOS | OS mute toggle; JS reconciles via the `if (muted !== isMuted) toggleMute()` echo guard. | +| `RNCallKeep:didToggleHoldCallAction` | native → JS | both | OS hold (e.g. competing call); JS uses `wasAutoHeld` to distinguish OS-driven hold from manual hold. | +| `NativeVoipModule.startAudioRouteSync` | JS → native | Android only | Begin observing audio route changes for `VoipCommunicationDeviceChanged`. | +| `NativeVoipModule.setSpeakerOn` | JS → native | Android only | Drive `AudioManager` directly (Android speaker toggle). | +| `NativeVoipModule.getInitialEvents` / `clearInitialEvents` | JS → native | both | Cold-start handoff: read and clear the stashed accept event. | ### Cold start — initial events @@ -171,5 +169,4 @@ Each invariant is grounded in a test in `app/lib/services/voip/*.test.ts` or an - **Stale-session UUID gating** — `didPerformSetMutedCallAction` and `didToggleHoldCallAction` compare the event UUID (lowercased) against the active call UUID and drop the event on mismatch. Required because `setupMediaCallEvents` lives on Root and survives logout/server-switch. See inline comments in `MediaCallEvents.ts`. - **Auto-hold vs manual hold** — `wasAutoHeld` distinguishes OS-driven hold (a competing CallKit/Telecom call) from a user manually pressing hold; only auto-held calls are auto-resumed. See inline code in `MediaCallEvents.ts`. - **Optimistic `roomId` rollback** — `startCallByRoom` clears its optimistic `setRoomId` if `startCall` rejects, so a concurrent incoming call can resolve its own DM context. Verified by `roomId population` block ("startCallByRoom clears optimistic roomId when post-permission guard rejects"). -- **Terminate dedupe reset** — `terminateNativeCall`'s per-`callId` sentinel set is cleared by `resetVoipState`, so a reused `callId` after logout / account switch is not short-circuited. Verified by `resetVoipState.test.ts` ("after resetVoipState, a previously-terminated callId is torn down again (terminate sentinel cleared)"). Sibling of the accept-dedupe sentinel reset in the same function. - **Self-host signal gating** — `notification/accepted` signals are only acted on when `signedContractId === mobileDeviceId`; another device on the same account cannot trick this device into binding. Verified by the `stream-notify-user (notification/accepted gated)` block. diff --git a/app/lib/services/voip/docs/FLOWS.md b/app/lib/services/voip/docs/FLOWS.md index d7612911afc..c17edc74d7d 100644 --- a/app/lib/services/voip/docs/FLOWS.md +++ b/app/lib/services/voip/docs/FLOWS.md @@ -297,12 +297,7 @@ sequenceDiagram Note over Native,Store: Stale-session guards ensure a CallKit/Telecom event for an old call (after server switch / logout) is ignored: UUID mismatch drops the event before any state change. ``` -"Terminate native call" is `terminateNativeCall(callId)`, deduped per `callId` by a module-level sentinel set (cleared by `resetVoipState`). What it does differs by platform: - -- **iOS** — `RNCallKeep.endCall(callId)` only. -- **Android** — `NativeVoip.disconnectNativeCall(callId)` first, then `RNCallKeep.endCall(callId)`, then `NativeVoip.stopVoipCallService()`. `disconnectNativeCall` delegates to `VoipNotification.terminateIncomingCall`, which runs the full incoming-call teardown on the main looper: cancel the timeout, disconnect the Telecom connection, stop `VoipCallService`, and stop the per-call DDP client. If the incoming payload for that `callId` is still tracked in this process (`activeIncomingPayloads`, populated by `showIncomingCall`), it also cancels the notification and broadcasts `ACTION_DISMISS`, which finishes `IncomingCallActivity` — the same dismissal set the notification-action paths run, so terminating a still-ringing call clears the full-screen UI. - -_Last verified: e3515c79c_ +_Last verified: cd2faa00a_ --- diff --git a/app/lib/services/voip/docs/PLATFORMS.md b/app/lib/services/voip/docs/PLATFORMS.md index c843e1a510c..116b198dd97 100644 --- a/app/lib/services/voip/docs/PLATFORMS.md +++ b/app/lib/services/voip/docs/PLATFORMS.md @@ -61,10 +61,6 @@ VoIP incoming calls arrive as FCM **data-only payloads** (so the app can wake wi `IncomingCallActivity` is the lock-screen activity that surfaces the incoming call UI when the device is locked. -### JS-driven teardown — `terminateIncomingCall` - -Everything above is native-owned, but JS can also end a call that native is still presenting (in-app hangup, peer hangup, a failed accept reconciliation). `terminateNativeCall` calls `NativeVoip.disconnectNativeCall(callId)`, which delegates to `VoipNotification.terminateIncomingCall` — the single shared teardown, posted to the main looper because the caller is a synchronous TurboModule method on the JS thread. It cancels the timeout, disconnects the Telecom connection, stops `VoipCallService`, and stops the per-call DDP client; when the incoming payload for that `callId` is still tracked in-process (`activeIncomingPayloads`), it also cancels the notification and broadcasts `ACTION_DISMISS` so `IncomingCallActivity` finishes. Without that dismissal a JS-side terminate would leave the full-screen incoming UI on screen. JS then calls `RNCallKeep.endCall(callId)` and `NativeVoip.stopVoipCallService()`. - ### Per-call DDP — `VoipPerCallDdpRegistry.kt` Mirrors the iOS rationale: a short-lived DDP client per incoming call so the REST accept and any inbound signaling can land before the app's main DDP socket exists. The registry is keyed by `callId`; clients close on accept-resolved, call-ended, or timeout. diff --git a/app/lib/services/voip/resetVoipState.test.ts b/app/lib/services/voip/resetVoipState.test.ts index 462049558cf..105ec87deba 100644 --- a/app/lib/services/voip/resetVoipState.test.ts +++ b/app/lib/services/voip/resetVoipState.test.ts @@ -2,7 +2,6 @@ import { DeviceEventEmitter } from 'react-native'; import { resetMediaCallEventsStateForTesting, setupMediaCallEvents, type MediaCallEventsAdapters } from './MediaCallEvents'; import { resetVoipState } from './resetVoipState'; -import { terminateNativeCall } from './terminateNativeCall'; import { useCallStore } from './useCallStore'; jest.mock('../../methods/helpers', () => ({ @@ -20,9 +19,7 @@ jest.mock('../../native/NativeVoip', () => ({ __esModule: true, default: { clearInitialEvents: jest.fn(), - getInitialEvents: jest.fn(() => null), - disconnectNativeCall: jest.fn(), - stopVoipCallService: jest.fn() + getInitialEvents: jest.fn(() => null) } })); @@ -32,7 +29,6 @@ jest.mock('react-native-callkeep', () => ({ addEventListener: jest.fn(() => ({ remove: jest.fn() })), clearInitialEvents: jest.fn(), setCurrentCallActive: jest.fn(), - endCall: jest.fn(), getInitialEvents: jest.fn(() => Promise.resolve([])) } })); @@ -150,19 +146,4 @@ describe('resetVoipState', () => { DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2); }); - - it('after resetVoipState, a previously-terminated callId is torn down again (terminate sentinel cleared)', () => { - (useCallStore.getState as jest.Mock).mockReturnValue({ resetNativeCallId: jest.fn(), reset: jest.fn() }); - const endCall = jest.requireMock('react-native-callkeep').default.endCall as jest.Mock; - endCall.mockClear(); - - terminateNativeCall('reused-terminate-id'); - terminateNativeCall('reused-terminate-id'); - expect(endCall).toHaveBeenCalledTimes(1); - - resetVoipState(); - - terminateNativeCall('reused-terminate-id'); - expect(endCall).toHaveBeenCalledTimes(2); - }); }); diff --git a/app/lib/services/voip/resetVoipState.ts b/app/lib/services/voip/resetVoipState.ts index c9fcaa01f0c..c839e1fb9a6 100644 --- a/app/lib/services/voip/resetVoipState.ts +++ b/app/lib/services/voip/resetVoipState.ts @@ -1,11 +1,9 @@ import { useCallStore } from './useCallStore'; import { clearVoipAcceptDedupeSentinels } from './MediaCallEvents'; -import { clearTerminateDedupeSentinels } from './terminateNativeCall'; -/** Resets VoIP UI / native-call-id state after accept failure or similar teardown (deep linking saga). Also clears the accept- and terminate-dedupe sentinels so Android cold-start and re-delivery paths are not poisoned by a prior call. */ +/** Resets VoIP UI / native-call-id state after accept failure or similar teardown (deep linking saga). Also clears accept-dedupe sentinels so Android cold-start and re-delivery paths are not poisoned by a prior call. */ export function resetVoipState(): void { clearVoipAcceptDedupeSentinels(); - clearTerminateDedupeSentinels(); const { resetNativeCallId, reset } = useCallStore.getState(); resetNativeCallId(); reset(); diff --git a/app/lib/services/voip/terminateNativeCall.test.ts b/app/lib/services/voip/terminateNativeCall.test.ts deleted file mode 100644 index 1e81187f519..00000000000 --- a/app/lib/services/voip/terminateNativeCall.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Platform } from 'react-native'; -import RNCallKeep from 'react-native-callkeep'; - -import NativeVoipModule from '../../native/NativeVoip'; -import { clearTerminateDedupeSentinels, terminateNativeCall } from './terminateNativeCall'; - -jest.mock('../../native/NativeVoip', () => ({ - __esModule: true, - default: { - disconnectNativeCall: jest.fn(), - stopVoipCallService: jest.fn() - } -})); - -describe('terminateNativeCall', () => { - beforeEach(() => { - jest.clearAllMocks(); - clearTerminateDedupeSentinels(); - Platform.OS = 'android'; - }); - - it('disconnects the Telecom connection natively so teardown does not depend on CallKeep setup', () => { - terminateNativeCall('call-1'); - - expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-1'); - expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledWith('call-1'); - expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); - }); - - it('disconnects natively before RNCallKeep.endCall removes the connection', () => { - const order: string[] = []; - (NativeVoipModule.disconnectNativeCall as jest.Mock).mockImplementationOnce(() => order.push('native')); - (RNCallKeep.endCall as jest.Mock).mockImplementationOnce(() => order.push('callkeep')); - - terminateNativeCall('call-order'); - - expect(order).toEqual(['native', 'callkeep']); - }); - - it('ignores repeat invocations for the same callId', () => { - terminateNativeCall('call-dup'); - terminateNativeCall('call-dup'); - - expect(RNCallKeep.endCall).toHaveBeenCalledTimes(1); - expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledTimes(1); - expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalledTimes(1); - }); - - it('still terminates a different callId after one was already terminated', () => { - terminateNativeCall('call-a'); - terminateNativeCall('call-b'); - - expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-a'); - expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-b'); - expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalledTimes(2); - }); - - it('still calls RNCallKeep.endCall when the native disconnect throws', () => { - (NativeVoipModule.disconnectNativeCall as jest.Mock).mockImplementationOnce(() => { - throw new Error('bridge unavailable'); - }); - - terminateNativeCall('call-2'); - - expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-2'); - expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); - }); - - it('still stops the foreground service when RNCallKeep.endCall throws', () => { - (RNCallKeep.endCall as jest.Mock).mockImplementationOnce(() => { - throw new Error('CallKeep unavailable'); - }); - - terminateNativeCall('call-3'); - - expect(NativeVoipModule.disconnectNativeCall).toHaveBeenCalledWith('call-3'); - expect(NativeVoipModule.stopVoipCallService).toHaveBeenCalled(); - }); - - it('does not touch the Android natives on iOS', () => { - Platform.OS = 'ios'; - - terminateNativeCall('call-4'); - - expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-4'); - expect(NativeVoipModule.disconnectNativeCall).not.toHaveBeenCalled(); - expect(NativeVoipModule.stopVoipCallService).not.toHaveBeenCalled(); - }); -}); diff --git a/app/lib/services/voip/terminateNativeCall.ts b/app/lib/services/voip/terminateNativeCall.ts index afd77f859be..c843f9fee40 100644 --- a/app/lib/services/voip/terminateNativeCall.ts +++ b/app/lib/services/voip/terminateNativeCall.ts @@ -3,46 +3,12 @@ import RNCallKeep from 'react-native-callkeep'; import NativeVoipModule from '../../native/NativeVoip'; -// Termination is triggered from several independent paths (useCallStore.endCall, -// acceptNativeCall, and the MediaSessionInstance event handlers), so the same -// callId arrives more than once. Bounded because the app is long-lived. -const MAX_TRACKED_CALL_IDS = 32; -const terminatedCallIds = new Set(); - -function markTerminated(callId: string): void { - terminatedCallIds.add(callId); - while (terminatedCallIds.size > MAX_TRACKED_CALL_IDS) { - const oldest = terminatedCallIds.values().next().value; - if (oldest === undefined) { - break; - } - terminatedCallIds.delete(oldest); - } -} - export function terminateNativeCall(callId: string): void { - if (terminatedCallIds.has(callId)) { - return; - } - markTerminated(callId); - - // The native disconnect runs first: RNCallKeep.endCall removes the connection from - // VoiceConnectionService's map, which would leave nothing for this call to find. - if (Platform.OS === 'android') { - try { - NativeVoipModule.disconnectNativeCall(callId); - } catch { - // bridge unavailable pre-boot - } - } - try { - // No-op when the native disconnect above already tore the connection down. RNCallKeep.endCall(callId); } catch { - // CallKeep may be unavailable; still stop the Android service below + // CallKeep may be unavailable; still attempt to stop the Android service below } - if (Platform.OS === 'android') { try { NativeVoipModule.stopVoipCallService(); @@ -51,11 +17,3 @@ export function terminateNativeCall(callId: string): void { } } } - -/** - * Clears the terminate-dedupe sentinels. Called from `resetVoipState` alongside the accept-dedupe - * sentinels so a logout / account switch cannot leave a reused callId short-circuited here. - */ -export function clearTerminateDedupeSentinels(): void { - terminatedCallIds.clear(); -} diff --git a/app/lib/services/voip/useCallStore.ios.test.ts b/app/lib/services/voip/useCallStore.ios.test.ts index db632e660ba..b5e8bf25250 100644 --- a/app/lib/services/voip/useCallStore.ios.test.ts +++ b/app/lib/services/voip/useCallStore.ios.test.ts @@ -4,7 +4,6 @@ import type { IClientMediaCall } from '@rocket.chat/media-signaling'; import { useCallStore } from './useCallStore'; -import { clearTerminateDedupeSentinels } from './terminateNativeCall'; const mockLog = jest.fn(); jest.mock('../../methods/helpers/log', () => ({ @@ -49,7 +48,6 @@ jest.mock('../../native/NativeVoip', () => ({ getLastVoipToken: jest.fn(() => ''), stopNativeDDPClient: jest.fn(), stopVoipCallService: jest.fn(), - disconnectNativeCall: jest.fn(), addListener: jest.fn(), removeListeners: jest.fn() } @@ -89,10 +87,6 @@ function createMockCall(callId: string) { return { call }; } -beforeEach(() => { - clearTerminateDedupeSentinels(); -}); - describe('useCallStore audio route sync (iOS, isIOS=true)', () => { beforeEach(() => { useCallStore.getState().resetNativeCallId(); diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index 6e50ca6ea46..b41b1cdef6e 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -6,7 +6,6 @@ import InCallManager from 'react-native-incall-manager'; import NativeVoipModule from '../../native/NativeVoip'; import { pendingHangups } from './pendingHangups'; import { useCallStore } from './useCallStore'; -import { clearTerminateDedupeSentinels } from './terminateNativeCall'; const mockLog = jest.fn(); jest.mock('../../methods/helpers/log', () => ({ @@ -54,7 +53,6 @@ jest.mock('../../native/NativeVoip', () => ({ getLastVoipToken: jest.fn(() => ''), stopNativeDDPClient: jest.fn(), stopVoipCallService: jest.fn(), - disconnectNativeCall: jest.fn(), addListener: jest.fn(), removeListeners: jest.fn() } @@ -124,10 +122,6 @@ function createMockCall(callId: string, options?: { initialState?: string }) { return { call, emit }; } -beforeEach(() => { - clearTerminateDedupeSentinels(); -}); - describe('createMockCall emitter', () => { it('forwards variadic arguments to listeners', () => { const { call, emit } = createMockCall('e1'); diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 2ae76d75c76..9bd6a46706c 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -310,11 +310,7 @@ export const useCallStore = create((set, get) => ({ } if (call) { - try { - call.hangup(); - } catch (e) { - log(e); - } + call.hangup(); } if (callUuid) { diff --git a/app/views/CallView/index.test.tsx b/app/views/CallView/index.test.tsx index 22fd996652d..70e8fec9a7a 100644 --- a/app/views/CallView/index.test.tsx +++ b/app/views/CallView/index.test.tsx @@ -9,7 +9,6 @@ import { useCallStore } from '../../lib/services/voip/useCallStore'; import { mockedStore } from '../../reducers/mockedStore'; import * as stories from './CallView.stories'; import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; -import { clearTerminateDedupeSentinels } from '../../lib/services/voip/terminateNativeCall'; const mockStartRingback = jest.fn(() => Promise.resolve()); const mockStopRingback = jest.fn(() => Promise.resolve()); @@ -33,7 +32,6 @@ jest.mock('../../lib/native/NativeVoip', () => ({ getLastVoipToken: jest.fn(() => ''), stopNativeDDPClient: jest.fn(), stopVoipCallService: jest.fn(), - disconnectNativeCall: jest.fn(), setSpeakerOn: jest.fn(() => Promise.resolve(true)), startAudioRouteSync: jest.fn(() => Promise.resolve()), stopAudioRouteSync: jest.fn(() => Promise.resolve()), @@ -161,10 +159,6 @@ const setStoreState = (overrides: Partial {children}; -beforeEach(() => { - clearTerminateDedupeSentinels(); -}); - describe('CallView/CallView', () => { beforeEach(() => { mockWindowWidth = 350; diff --git a/index.js b/index.js index b41c5c3222f..d335f4880ad 100644 --- a/index.js +++ b/index.js @@ -23,10 +23,9 @@ if (process.env.USE_STORYBOOK) { LogBox.ignoreAllLogs(); - // FEATURE_TELECOM is only declared from API 33; trusting the query below that killed setup() and endCall (#7334) - const isAndroid = Platform.OS === 'android'; + // android.software.telecom is only declared from API 33, so querying it skipped setup() on older devices (#7334) const supportsTelecom = - isAndroid && (Number(Platform.Version) < 33 || DeviceInfo.hasSystemFeatureSync('android.software.telecom')); + Platform.OS === 'android' && (Number(Platform.Version) < 33 || DeviceInfo.hasSystemFeatureSync('android.software.telecom')); if (supportsTelecom) { const options = { diff --git a/ios/Libraries/VoipModule.mm b/ios/Libraries/VoipModule.mm index e8ab9c3e9cc..0183db4aeea 100644 --- a/ios/Libraries/VoipModule.mm +++ b/ios/Libraries/VoipModule.mm @@ -124,14 +124,6 @@ - (void)startVoipCallService:(NSString *)callId resolve(nil); } -// Android-only Telecom teardown fallback. iOS tears CallKit down through RNCallKeep. -- (void)disconnectNativeCall:(NSString *)callId { -} - -// Android-only foreground service teardown; this stub satisfies the codegen spec. -- (void)stopVoipCallService { -} - // iOS keeps using InCallManager.setForceSpeakerphoneOn from JS; this stub satisfies the codegen spec. - (void)setSpeakerOn:(BOOL)on resolve:(RCTPromiseResolveBlock)resolve From fcbe3159d825c5ec2cc1a06a6a05d868ae20f525 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 28 Aug 2026 13:11:13 -0300 Subject: [PATCH 14/14] fix: detect telecom support on Android below API 33 --- index.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index d335f4880ad..10f7832bbfb 100644 --- a/index.js +++ b/index.js @@ -23,9 +23,11 @@ if (process.env.USE_STORYBOOK) { LogBox.ignoreAllLogs(); - // android.software.telecom is only declared from API 33, so querying it skipped setup() on older devices (#7334) + // FEATURE_TELECOM is only declared from API 33; older releases declare its predecessor, FEATURE_CONNECTION_SERVICE. const supportsTelecom = - Platform.OS === 'android' && (Number(Platform.Version) < 33 || DeviceInfo.hasSystemFeatureSync('android.software.telecom')); + Platform.OS === 'android' && + (DeviceInfo.hasSystemFeatureSync('android.software.telecom') || + DeviceInfo.hasSystemFeatureSync('android.software.connectionservice')); if (supportsTelecom) { const options = {