Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
be92410
fix(voip): run RNCallKeep.setup on all Android versions
OtavioStasiak Aug 17, 2026
2a4b655
fix(voip): disconnect the Telecom connection natively on Android tear…
OtavioStasiak Aug 17, 2026
a4674e3
fix(voip): run RNCallKeep.setup on all Android versions
OtavioStasiak Aug 17, 2026
ff54116
fix(voip): disconnect the Telecom connection natively on Android tear…
OtavioStasiak Aug 17, 2026
e6f276f
Merge branch 'develop' into fix.ending-call-blocks-all-later-calls
OtavioStasiak Aug 18, 2026
b2bbe5c
Merge branch 'develop' into fix.ending-call-blocks-all-later-calls
OtavioStasiak Aug 19, 2026
55a853a
chore: format code and fix lint issues
OtavioStasiak Aug 19, 2026
2eaab9f
fix(voip): disconnect natively before CallKeep clears the connection
OtavioStasiak Aug 19, 2026
9febb27
fix: tests
OtavioStasiak Aug 19, 2026
006aec8
fix: build
OtavioStasiak Aug 19, 2026
e3515c7
comment
OtavioStasiak Aug 19, 2026
56f3a8f
fix: run the full teardown when the call ends on js
OtavioStasiak Aug 24, 2026
24788a9
chore: format code and fix lint issues
OtavioStasiak Aug 24, 2026
82e0912
Merge branch 'develop' into fix.ending-call-blocks-all-later-calls
OtavioStasiak Aug 24, 2026
b1aeef1
fix: prevent crash issue
OtavioStasiak Aug 26, 2026
e06e2a3
cleanup
OtavioStasiak Aug 26, 2026
6d22210
Merge branch 'develop' into fix.ending-call-blocks-all-later-calls
OtavioStasiak Aug 26, 2026
fcbe315
fix: detect telecom support on Android below API 33
OtavioStasiak Aug 28, 2026
36c5821
Merge branch 'develop' into fix.ending-call-blocks-all-later-calls
OtavioStasiak Aug 28, 2026
0789e1c
Merge branch 'develop' into fix.ending-call-blocks-all-later-calls
OtavioStasiak Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -199,6 +200,20 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
}
}

override fun disconnectNativeCall(callId: String) {
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
try {
when (val connection = VoiceConnectionService.getConnection(callId)) {
null -> Log.d(TAG, "disconnectNativeCall: no connection for $callId")
else -> {
connection.onDisconnect()
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
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
Expand Down
6 changes: 5 additions & 1 deletion app/containers/UIKit/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ const UIKitButton: FC<IUIKitButtonProps> = ({ title, onPress, type = 'primary',
accessibilityLabel={title}
accessibilityRole='button'
style={({ pressed }) => [styles.container, { backgroundColor }, style, pressed && styles.pressed]}>
{loading ? <ActivityIndicator color={color} style={{ padding: 0 }} /> : <Text style={[styles.text, { color }]}>{title}</Text>}
{loading ? (
<ActivityIndicator color={color} style={{ padding: 0 }} />
) : (
<Text style={[styles.text, { color }]}>{title}</Text>
)}
</Pressable>
);
};
Expand Down
15 changes: 15 additions & 0 deletions app/lib/native/NativeVoip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@ 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).
* 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,
Expand Down Expand Up @@ -106,6 +120,7 @@ const NativeVoipModule =
stopNativeDDPClient: () => undefined,
startVoipCallService: () => Promise.resolve(),
stopVoipCallService: () => undefined,
disconnectNativeCall: () => undefined,
setSpeakerOn: () => Promise.resolve(false),
startAudioRouteSync: () => Promise.resolve(),
stopAudioRouteSync: () => Promise.resolve(),
Expand Down
3 changes: 2 additions & 1 deletion app/lib/services/voip/MediaSessionInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ jest.mock('../../native/NativeVoip', () => ({
default: {
stopNativeDDPClient: jest.fn(),
startVoipCallService: (callId: string) => mockStartVoipCallService(callId),
stopVoipCallService: () => mockStopVoipCallService()
stopVoipCallService: () => mockStopVoipCallService(),
disconnectNativeCall: jest.fn()
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
}
}));

Expand Down
59 changes: 59 additions & 0 deletions app/lib/services/voip/terminateNativeCall.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions app/lib/services/voip/terminateNativeCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ export function terminateNativeCall(callId: string): void {
// CallKeep may be unavailable; still attempt to 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 {
Expand Down
1 change: 1 addition & 0 deletions app/lib/services/voip/useCallStore.ios.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions app/lib/services/voip/useCallStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
1 change: 1 addition & 0 deletions app/views/CallView/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
4 changes: 2 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -23,7 +22,8 @@ if (process.env.USE_STORYBOOK) {

LogBox.ignoreAllLogs();

if (Platform.OS === 'android' && DeviceInfo.hasSystemFeatureSync('android.software.telecom')) {
Comment thread
OtavioStasiak marked this conversation as resolved.
// Never gate on FEATURE_TELECOM (API 33+): non-Telecom devices bail natively in registerPhoneAccount
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
if (Platform.OS === 'android') {
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
const options = {
android: {
// TODO: i18n
Expand Down
4 changes: 4 additions & 0 deletions ios/Libraries/VoipModule.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
OtavioStasiak marked this conversation as resolved.
Outdated
}

// iOS keeps using InCallManager.setForceSpeakerphoneOn from JS; this stub satisfies the codegen spec.
- (void)setSpeakerOn:(BOOL)on
resolve:(RCTPromiseResolveBlock)resolve
Expand Down