From 5dae4b58c7f9545bfd12440f80a13b114bbd8f30 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 27 Mar 2026 17:49:08 -0300 Subject: [PATCH 1/7] first attempt --- .../reactnative/voip/VoipNotification.kt | 51 ++++++- .../voip/MediaSessionInstance.test.ts | 127 +++++++++++++++++- app/lib/services/voip/MediaSessionInstance.ts | 12 ++ app/sagas/videoConf.ts | 5 + ios/Libraries/AppDelegate+Voip.swift | 9 ++ ios/Libraries/VoipService.swift | 28 ++++ 6 files changed, 230 insertions(+), 2 deletions(-) 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 f3f24fc6094..81c061cd32f 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 @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.media.AudioAttributes +import android.media.AudioManager import android.media.RingtoneManager import android.os.Build import android.os.Bundle @@ -477,6 +478,48 @@ class VoipNotification(private val context: Context) { } } + /** + * True when the user is already in a call: this app's Telecom connections (active/hold), + * any system in-call state (API 26+), or audio in communication mode (fallback before API 26). + */ + private fun hasActiveCall(context: Context): Boolean { + val ownBusy = VoiceConnectionService.currentConnections.values.any { connection -> + connection.state == android.telecom.Connection.STATE_ACTIVE || + connection.state == android.telecom.Connection.STATE_HOLDING + } + if (ownBusy) { + return true + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + if (telecom?.isInCall == true) { + return true + } + } else { + val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION) { + return true + } + } + return false + } + + /** + * Rejects an incoming call because the user is already on another call. + * Sends a reject signal via DDP and cleans up without showing any UI. + */ + @JvmStatic + fun rejectBusyCall(context: Context, payload: VoipPayload) { + Log.d(TAG, "Rejected busy call ${payload.callId} — user already on a call") + cancelTimeout(payload.callId) + startListeningForCallEnd(context, payload) + if (isDdpLoggedIn) { + sendRejectSignal(context, payload) + } else { + queueRejectSignal(context, payload) + } + } + // -- Native DDP Listener (Call End Detection) -- @JvmStatic @@ -613,7 +656,13 @@ class VoipNotification(private val context: Context) { fun onMessageReceived(voipPayload: VoipPayload) { when { - voipPayload.isVoipIncomingCall() -> showIncomingCall(voipPayload) + voipPayload.isVoipIncomingCall() -> { + if (hasActiveCall(context)) { + rejectBusyCall(context, voipPayload) + } else { + showIncomingCall(voipPayload) + } + } else -> Log.w(TAG, "Ignoring unsupported VoIP payload type: ${voipPayload.type}") } } diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index ccca424a58b..f6172cb495a 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -1,3 +1,5 @@ +import type { IClientMediaCall } from '@rocket.chat/media-signaling'; + import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import { mediaSessionStore } from './MediaSessionStore'; import { mediaSessionInstance } from './MediaSessionInstance'; @@ -47,7 +49,15 @@ jest.mock('react-native-webrtc', () => ({ mediaDevices: { getUserMedia: jest.fn() } })); -jest.mock('react-native-callkeep', () => ({})); +const mockRNCallKeepEndCall = jest.fn(); +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + endCall: (...args: unknown[]) => mockRNCallKeepEndCall(...args), + setCurrentCallActive: jest.fn(), + setAvailable: jest.fn() + } +})); jest.mock('react-native-device-info', () => ({ getUniqueId: jest.fn(() => 'test-device-id'), @@ -110,6 +120,41 @@ function getStreamNotifyHandler(): (ddpMessage: IDDPMessage) => void { throw new Error('stream-notify-user handler not registered'); } +function getNewCallHandler(): (payload: { call: IClientMediaCall }) => void { + const session = createdSessions[0]; + if (!session) { + throw new Error('no session created'); + } + const calls = session.on.mock.calls as [string, (payload: { call: IClientMediaCall }) => void][]; + const entry = calls.find(([eventName]) => eventName === 'newCall'); + if (!entry) { + throw new Error('newCall handler not registered'); + } + return entry[1]; +} + +function createMockIncomingCall(callId: string): IClientMediaCall { + const reject = jest.fn(); + return { + callId, + role: 'callee', + hidden: false, + reject, + emitter: { on: jest.fn() } + } as unknown as IClientMediaCall; +} + +function createMockOutgoingCall(callId: string): IClientMediaCall { + const reject = jest.fn(); + return { + callId, + role: 'caller', + hidden: false, + reject, + emitter: { on: jest.fn() } + } as unknown as IClientMediaCall; +} + describe('MediaSessionInstance', () => { beforeEach(() => { jest.clearAllMocks(); @@ -314,4 +359,84 @@ describe('MediaSessionInstance', () => { answerSpy.mockRestore(); }); }); + + describe('newCall busy guard', () => { + it('rejects incoming call and ends CallKeep when already on a call', () => { + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: { callId: 'existing' } as any, + callId: 'existing', + nativeAcceptedCallId: null + }); + mediaSessionInstance.init('user-1'); + const newCallHandler = getNewCallHandler(); + const incoming = createMockIncomingCall('incoming-second'); + newCallHandler({ call: incoming }); + expect(incoming.reject).toHaveBeenCalled(); + expect(mockRNCallKeepEndCall).toHaveBeenCalledWith('incoming-second'); + }); + + it('rejects incoming call when nativeAcceptedCallId is set but incoming callId differs', () => { + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: 'pending-call' + }); + mediaSessionInstance.init('user-1'); + const newCallHandler = getNewCallHandler(); + const incoming = createMockIncomingCall('incoming-second'); + newCallHandler({ call: incoming }); + expect(incoming.reject).toHaveBeenCalled(); + expect(mockRNCallKeepEndCall).toHaveBeenCalledWith('incoming-second'); + }); + + it('allows incoming newCall when nativeAcceptedCallId matches incoming callId', () => { + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: 'same-native-id' + }); + mediaSessionInstance.init('user-1'); + const newCallHandler = getNewCallHandler(); + const incoming = createMockIncomingCall('same-native-id'); + newCallHandler({ call: incoming }); + expect(incoming.reject).not.toHaveBeenCalled(); + expect(incoming.emitter.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + }); + + it('does not reject outgoing newCall when store already has a call', () => { + const mockSetCall = jest.fn(); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: mockSetCall, + resetNativeCallId: jest.fn(), + call: { callId: 'existing' } as any, + callId: 'existing', + nativeAcceptedCallId: null + }); + mediaSessionInstance.init('user-1'); + const newCallHandler = getNewCallHandler(); + const outgoing = createMockOutgoingCall('out-new'); + newCallHandler({ call: outgoing }); + expect(outgoing.reject).not.toHaveBeenCalled(); + expect(mockSetCall).toHaveBeenCalledWith(outgoing); + }); + + it('allows incoming call when store is empty and registers stateChange listener', () => { + mediaSessionInstance.init('user-1'); + const newCallHandler = getNewCallHandler(); + const incoming = createMockIncomingCall('incoming-first'); + newCallHandler({ call: incoming }); + expect(incoming.reject).not.toHaveBeenCalled(); + expect(incoming.emitter.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + }); + }); }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index f60af626c28..22df210da9d 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -84,6 +84,18 @@ class MediaSessionInstance { this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { if (call && !call.hidden) { + if (call.role === 'callee') { + // media-signaling emits newCall only when session goes from no call to has call; reject if app is busy with another id. + const { call: existingCall, nativeAcceptedCallId } = useCallStore.getState(); + const activeCallId = existingCall?.callId ?? nativeAcceptedCallId ?? null; + if (activeCallId != null && call.callId !== activeCallId) { + console.log('[VoIP] Rejecting incoming call — busy with different call:', call.callId); + call.reject(); + RNCallKeep.endCall(call.callId); + return; + } + } + call.emitter.on('stateChange', oldState => { console.log(`📊 ${oldState} → ${call.state}`); console.log('🤙 [VoIP] New call data:', call); diff --git a/app/sagas/videoConf.ts b/app/sagas/videoConf.ts index d9ca57c4f2a..c9212057422 100644 --- a/app/sagas/videoConf.ts +++ b/app/sagas/videoConf.ts @@ -20,6 +20,7 @@ import { showToast } from '../lib/methods/helpers/showToast'; import { videoConfJoin } from '../lib/methods/videoConf'; import { videoConferenceCancel, notifyUser, videoConferenceStart } from '../lib/services/restApi'; import { type ICallInfo } from '../reducers/videoConf'; +import { useCallStore } from '../lib/services/voip/useCallStore'; interface IGenericAction extends Action { type: string; @@ -47,6 +48,10 @@ const CALL_INTERVAL = 3000; const CALL_ATTEMPT_LIMIT = 10; function* onDirectCall(payload: ICallInfo) { + // Reject if already on a VoIP call or native accept is still binding to JS + const { call: activeVoipCall, nativeAcceptedCallId } = useCallStore.getState(); + if (activeVoipCall != null || nativeAcceptedCallId != null) return; + const calls = yield* appSelector(state => state.videoConf.calls); const currentCall = calls.find(c => c.callId === payload.callId); const hasAnotherCall = calls.find(c => c.action === 'call'); diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift index 81b7f7ccc3c..9b572794955 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -40,6 +40,10 @@ extension AppDelegate: PKPushRegistryDelegate { return } + // Check BEFORE reporting — if the user is already on a call, we still must + // report to CallKit (PushKit requirement) but will immediately reject it. + let isBusy = VoipService.hasActiveCall() + VoipService.prepareIncomingCall(voipPayload) RNCallKeep.reportNewIncomingCall( @@ -56,6 +60,11 @@ extension AppDelegate: PKPushRegistryDelegate { payload: payloadDict, withCompletionHandler: {} ) + + if isBusy { + VoipService.rejectBusyCall(voipPayload) + } + completion() } } diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index ae8ce33af2a..50fcc5cf1c4 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -135,6 +135,13 @@ public final class VoipService: NSObject { #endif } + /// Returns `true` when CXCallObserver reports any non-ended call (ringing or connected), + /// including phone, FaceTime, and third-party VoIP. + public static func hasActiveCall() -> Bool { + configureCallObserverIfNeeded() + return callObserver.calls.contains { !$0.hasEnded } + } + public static func prepareIncomingCall(_ payload: VoipPayload) { storeInitialEvents(payload) scheduleIncomingCallTimeout(for: payload) @@ -518,6 +525,27 @@ public final class VoipService: NSObject { } } + /// Rejects an incoming call because the user is already on another call. + /// Must be called **after** `reportNewIncomingCall` (PushKit requirement). + public static func rejectBusyCall(_ payload: VoipPayload) { + cancelIncomingCallTimeout(for: payload.callId) + clearTrackedIncomingCall(for: payload.callUUID) + + // End the just-reported CallKit call immediately (reason 2 = unanswered / declined). + RNCallKeep.endCall(withUUID: payload.callId, reason: 2) + + // Send reject signal via native DDP if available, otherwise queue it. + if isDdpLoggedIn { + sendRejectSignal(payload: payload) + } else { + queueRejectSignal(payload: payload) + } + + #if DEBUG + print("[\(TAG)] Rejected busy call \(payload.callId) — user already on a call") + #endif + } + private static func sendRejectSignal(payload: VoipPayload) { guard let client = ddpClient else { #if DEBUG From d324a860d87b7018103177bc38d24fc66569dc10 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 30 Mar 2026 11:09:43 -0300 Subject: [PATCH 2/7] fix(android): guard hasActiveCall Telecom check for READ_PHONE_STATE Check permission before TelecomManager.isInCall on API 26+, catch SecurityException, and use AudioManager MODE_IN_COMMUNICATION fallback on all API levels when Telecom is unavailable or denied. Made-with: Cursor --- .../reactnative/voip/VoipNotification.kt | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) 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 81c061cd32f..e179b5ee55e 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 @@ -1,5 +1,6 @@ package chat.rocket.reactnative.voip +import android.Manifest import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager @@ -7,6 +8,7 @@ import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Bitmap import android.media.AudioAttributes import android.media.AudioManager @@ -18,6 +20,7 @@ import android.os.Looper import android.provider.Settings import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import androidx.localbroadcastmanager.content.LocalBroadcastManager import android.content.ComponentName import android.net.Uri @@ -480,7 +483,8 @@ class VoipNotification(private val context: Context) { /** * True when the user is already in a call: this app's Telecom connections (active/hold), - * any system in-call state (API 26+), or audio in communication mode (fallback before API 26). + * any system in-call state (API 26+ when READ_PHONE_STATE is granted), or audio in + * communication mode (fallback on all API levels when Telecom is unavailable or denied). */ private fun hasActiveCall(context: Context): Boolean { val ownBusy = VoiceConnectionService.currentConnections.values.any { connection -> @@ -490,17 +494,32 @@ class VoipNotification(private val context: Context) { if (ownBusy) { return true } + return hasSystemLevelActiveCallIndicators(context) + } + + /** + * Telecom in-call check (API 26+) requires [READ_PHONE_STATE]; without it, [TelecomManager.isInCall] + * can throw [SecurityException]. Always falls back to [AudioManager.MODE_IN_COMMUNICATION] on all APIs. + */ + private fun hasSystemLevelActiveCallIndicators(context: Context): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager - if (telecom?.isInCall == true) { - return true - } - } else { - val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager - if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION) { - return true + val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == + PackageManager.PERMISSION_GRANTED + if (granted) { + val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager + try { + if (telecom?.isInCall == true) { + return true + } + } catch (e: SecurityException) { + Log.w(TAG, "TelecomManager.isInCall not allowed", e) + } } } + val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION) { + return true + } return false } From 266922e5499eeccab54675dfe229f71b14c3171e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 30 Mar 2026 11:16:09 -0300 Subject: [PATCH 3/7] feat(android): add READ_PHONE_STATE runtime request helper for VoIP Add requestPhoneStatePermission() with session-scoped prompt, i18n rationale, and Jest coverage. English strings only per issue scope. Made-with: Cursor --- app/i18n/locales/en.json | 2 + .../methods/voipPhoneStatePermission.test.ts | 62 +++++++++++++++++++ app/lib/methods/voipPhoneStatePermission.ts | 22 +++++++ 3 files changed, 86 insertions(+) create mode 100644 app/lib/methods/voipPhoneStatePermission.test.ts create mode 100644 app/lib/methods/voipPhoneStatePermission.ts diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 1424e485c6a..1ef59cbd817 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -662,6 +662,8 @@ "Permalink_copied_to_clipboard": "Permalink copied to clipboard!", "Person_or_channel": "Person or channel", "Phone": "Phone", + "Phone_state_permission_message": "This lets Rocket.Chat detect when you are already on a phone or VoIP call so incoming calls can be handled correctly.", + "Phone_state_permission_title": "Allow phone state access", "Pin": "Pin", "Pinned": "Pinned", "Pinned_a_message": "Pinned a message:", diff --git a/app/lib/methods/voipPhoneStatePermission.test.ts b/app/lib/methods/voipPhoneStatePermission.test.ts new file mode 100644 index 00000000000..0b1c062ebb8 --- /dev/null +++ b/app/lib/methods/voipPhoneStatePermission.test.ts @@ -0,0 +1,62 @@ +import { PermissionsAndroid } from 'react-native'; + +describe('requestPhoneStatePermission', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('does not call PermissionsAndroid.request when not on Android', () => { + jest.resetModules(); + jest.doMock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + isAndroid: false + })); + const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); + const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); + + requestPhoneStatePermission(); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('requests READ_PHONE_STATE on Android with i18n rationale keys', () => { + jest.resetModules(); + jest.doMock('../../i18n', () => ({ + __esModule: true, + default: { t: (key: string) => key } + })); + jest.doMock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + isAndroid: true + })); + const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); + const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); + + requestPhoneStatePermission(); + + expect(spy).toHaveBeenCalledWith(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { + buttonPositive: 'Ok', + message: 'Phone_state_permission_message', + title: 'Phone_state_permission_title' + }); + }); + + it('does not prompt again in the same session on Android', () => { + jest.resetModules(); + jest.doMock('../../i18n', () => ({ + __esModule: true, + default: { t: (key: string) => key } + })); + jest.doMock('./helpers', () => ({ + ...jest.requireActual('./helpers'), + isAndroid: true + })); + const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); + const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); + + requestPhoneStatePermission(); + requestPhoneStatePermission(); + + expect(spy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/lib/methods/voipPhoneStatePermission.ts b/app/lib/methods/voipPhoneStatePermission.ts new file mode 100644 index 00000000000..f1911947690 --- /dev/null +++ b/app/lib/methods/voipPhoneStatePermission.ts @@ -0,0 +1,22 @@ +import { PermissionsAndroid } from 'react-native'; + +import i18n from '../../i18n'; +import { isAndroid } from './helpers'; + +let askedThisSession = false; + +export const requestPhoneStatePermission = (): void => { + if (!isAndroid) { + return; + } + if (askedThisSession) { + return; + } + askedThisSession = true; + + PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { + buttonPositive: 'Ok', + message: i18n.t('Phone_state_permission_message'), + title: i18n.t('Phone_state_permission_title') + }); +}; From bd7158c4cb91081d4a5e0c9c2bce3c8d34beddae Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 30 Mar 2026 11:24:33 -0300 Subject: [PATCH 4/7] feat(voip): request READ_PHONE_STATE when starting a VoIP call Call requestPhoneStatePermission() at the start of MediaSessionInstance.startCall() so Android can use TelecomManager for busy detection. Videoconf is unchanged. Tests: extend MediaSessionInstance.test with startCall permission assertion. Made-with: Cursor --- .../services/voip/MediaSessionInstance.test.ts | 16 ++++++++++++++++ app/lib/services/voip/MediaSessionInstance.ts | 2 ++ 2 files changed, 18 insertions(+) diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index f6172cb495a..024f08f79fa 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -74,6 +74,11 @@ jest.mock('../../navigation/appNavigation', () => ({ default: { navigate: jest.fn() } })); +const mockRequestPhoneStatePermission = jest.fn(); +jest.mock('../../methods/voipPhoneStatePermission', () => ({ + requestPhoneStatePermission: () => mockRequestPhoneStatePermission() +})); + type MockMediaSignalingSession = { userId: string; sessionId: string; @@ -439,4 +444,15 @@ describe('MediaSessionInstance', () => { expect(incoming.emitter.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); }); }); + + describe('startCall', () => { + it('requests phone state permission fire-and-forget when starting a call', () => { + mediaSessionInstance.init('user-1'); + mockRequestPhoneStatePermission.mockClear(); + const session = createdSessions[0]; + mediaSessionInstance.startCall('peer-1', 'user'); + expect(mockRequestPhoneStatePermission).toHaveBeenCalledTimes(1); + expect(session.startCall).toHaveBeenCalledWith('user', 'peer-1'); + }); + }); }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 22df210da9d..cf7d9e387f7 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -20,6 +20,7 @@ import type { IceServer } from '../../../definitions/Voip'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import type { ISubscription, TSubscriptionModel } from '../../../definitions'; import { getUidDirectMessage } from '../../methods/helpers/helpers'; +import { requestPhoneStatePermission } from '../../methods/voipPhoneStatePermission'; class MediaSessionInstance { private iceServers: IceServer[] = []; @@ -149,6 +150,7 @@ class MediaSessionInstance { }; public startCall = (userId: string, actor: CallActorType) => { + requestPhoneStatePermission(); console.log('[VoIP] Starting call:', userId); this.instance?.startCall(actor, userId); }; From 309cbbad91517a4a9901e814c2fb3789067d2829 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 30 Mar 2026 11:29:03 -0300 Subject: [PATCH 5/7] fix(android): treat ringing/dialing CallKeep connections as busy for VoIP Align native busy detection with iOS (non-ended calls): reject a second incoming push while the first is still ringing or dialing, not only active/hold. Made-with: Cursor --- .../rocket/reactnative/voip/VoipNotification.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) 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 e179b5ee55e..439bcac9ba7 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 @@ -482,14 +482,20 @@ class VoipNotification(private val context: Context) { } /** - * True when the user is already in a call: this app's Telecom connections (active/hold), - * any system in-call state (API 26+ when READ_PHONE_STATE is granted), or audio in - * communication mode (fallback on all API levels when Telecom is unavailable or denied). + * True when the user is already in a call: this app's Telecom connections (ringing, dialing, + * active, hold — same idea as iOS CXCallObserver "any non-ended"), any system in-call state + * (API 26+ when READ_PHONE_STATE is granted), or audio in communication mode (fallback on all + * API levels when Telecom is unavailable or denied). */ private fun hasActiveCall(context: Context): Boolean { val ownBusy = VoiceConnectionService.currentConnections.values.any { connection -> - connection.state == android.telecom.Connection.STATE_ACTIVE || - connection.state == android.telecom.Connection.STATE_HOLDING + when (connection.state) { + android.telecom.Connection.STATE_RINGING, + android.telecom.Connection.STATE_DIALING, + android.telecom.Connection.STATE_ACTIVE, + android.telecom.Connection.STATE_HOLDING -> true + else -> false + } } if (ownBusy) { return true From 328b64670bad326c3a0f452fa8ba2f800b23dfa6 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 30 Mar 2026 11:29:31 -0300 Subject: [PATCH 6/7] fix(ios): skip initial-events stash for busy VoIP push When the user is already in a call, still satisfy PushKit by reporting to CallKit then rejecting, but do not store the payload for getInitialEvents. Keeps DDP listener/timeout so the reject signal can be sent. Clear matching initial events in rejectBusyCall as a safeguard. Made-with: Cursor --- ios/Libraries/AppDelegate+Voip.swift | 3 ++- ios/Libraries/VoipService.swift | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/ios/Libraries/AppDelegate+Voip.swift b/ios/Libraries/AppDelegate+Voip.swift index 9b572794955..747af706941 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -44,7 +44,8 @@ extension AppDelegate: PKPushRegistryDelegate { // report to CallKit (PushKit requirement) but will immediately reject it. let isBusy = VoipService.hasActiveCall() - VoipService.prepareIncomingCall(voipPayload) + // Keep DDP + timeout for busy reject, but do not expose this call via getInitialEvents. + VoipService.prepareIncomingCall(voipPayload, storeEventsForJs: !isBusy) RNCallKeep.reportNewIncomingCall( callId, diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index 50fcc5cf1c4..74e783ef9c5 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -142,8 +142,13 @@ public final class VoipService: NSObject { return callObserver.calls.contains { !$0.hasEnded } } - public static func prepareIncomingCall(_ payload: VoipPayload) { - storeInitialEvents(payload) + /// Prepares DDP listener and timeout for an incoming VoIP push. When `storeEventsForJs` is false + /// (e.g. user is already on a call and we will `rejectBusyCall` immediately), skip stashing payload + /// for `getInitialEvents` so JS does not treat an auto-rejected call as a real incoming ring. + public static func prepareIncomingCall(_ payload: VoipPayload, storeEventsForJs: Bool = true) { + if storeEventsForJs { + storeInitialEvents(payload) + } scheduleIncomingCallTimeout(for: payload) startListeningForCallEnd(payload: payload) } @@ -531,6 +536,10 @@ public final class VoipService: NSObject { cancelIncomingCallTimeout(for: payload.callId) clearTrackedIncomingCall(for: payload.callUUID) + if initialEventsData?.callId == payload.callId { + clearInitialEventsInternal() + } + // End the just-reported CallKit call immediately (reason 2 = unanswered / declined). RNCallKeep.endCall(withUUID: payload.callId, reason: 2) From 8fc00f52c162240f3df388198c75d8da0d271c08 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 30 Mar 2026 11:42:11 -0300 Subject: [PATCH 7/7] feat(voip): implement videoconference blocking logic and related tests Added a new function to determine if incoming videoconference calls should be blocked based on active VoIP calls or pending native accept states. Updated the videoConf saga to utilize this new logic. Additionally, created unit tests for the new functionality to ensure correct behavior in various scenarios. --- .../voip/MediaSessionInstance.test.ts | 17 ++++++++ .../voip/voipBlocksIncomingVideoconf.test.ts | 42 +++++++++++++++++++ .../voip/voipBlocksIncomingVideoconf.ts | 7 ++++ app/sagas/videoConf.ts | 6 +-- 4 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts create mode 100644 app/lib/services/voip/voipBlocksIncomingVideoconf.ts diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 024f08f79fa..62ce6b659f0 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -417,6 +417,23 @@ describe('MediaSessionInstance', () => { expect(incoming.emitter.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); }); + it('allows incoming newCall when store call object matches incoming callId', () => { + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: { callId: 'same-bound' } as any, + callId: 'same-bound', + nativeAcceptedCallId: null + }); + mediaSessionInstance.init('user-1'); + const newCallHandler = getNewCallHandler(); + const incoming = createMockIncomingCall('same-bound'); + newCallHandler({ call: incoming }); + expect(incoming.reject).not.toHaveBeenCalled(); + expect(incoming.emitter.on).toHaveBeenCalledWith('stateChange', expect.any(Function)); + }); + it('does not reject outgoing newCall when store already has a call', () => { const mockSetCall = jest.fn(); mockUseCallStoreGetState.mockReturnValue({ diff --git a/app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts b/app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts new file mode 100644 index 00000000000..260e0d136ab --- /dev/null +++ b/app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts @@ -0,0 +1,42 @@ +import { voipBlocksIncomingVideoconf } from './voipBlocksIncomingVideoconf'; + +const mockGetState = jest.fn(() => ({ + call: null as unknown, + nativeAcceptedCallId: null as string | null +})); + +jest.mock('./useCallStore', () => ({ + useCallStore: { + getState: () => mockGetState() + } +})); + +describe('voipBlocksIncomingVideoconf', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetState.mockReturnValue({ + call: null, + nativeAcceptedCallId: null + }); + }); + + it('returns true when VoIP store has an active call', () => { + mockGetState.mockReturnValue({ + call: { callId: 'voip-1' } as any, + nativeAcceptedCallId: null + }); + expect(voipBlocksIncomingVideoconf()).toBe(true); + }); + + it('returns true when nativeAcceptedCallId is set (pending native bind)', () => { + mockGetState.mockReturnValue({ + call: null, + nativeAcceptedCallId: 'pending' + }); + expect(voipBlocksIncomingVideoconf()).toBe(true); + }); + + it('returns false when there is no active VoIP call and no pending native accept', () => { + expect(voipBlocksIncomingVideoconf()).toBe(false); + }); +}); diff --git a/app/lib/services/voip/voipBlocksIncomingVideoconf.ts b/app/lib/services/voip/voipBlocksIncomingVideoconf.ts new file mode 100644 index 00000000000..b80d37a2748 --- /dev/null +++ b/app/lib/services/voip/voipBlocksIncomingVideoconf.ts @@ -0,0 +1,7 @@ +import { useCallStore } from './useCallStore'; + +/** When true, incoming direct videoconf "call" handling should no-op (VoIP already active or native accept not yet bound). */ +export function voipBlocksIncomingVideoconf(): boolean { + const { call, nativeAcceptedCallId } = useCallStore.getState(); + return call != null || nativeAcceptedCallId != null; +} diff --git a/app/sagas/videoConf.ts b/app/sagas/videoConf.ts index c9212057422..19126bf0131 100644 --- a/app/sagas/videoConf.ts +++ b/app/sagas/videoConf.ts @@ -20,7 +20,7 @@ import { showToast } from '../lib/methods/helpers/showToast'; import { videoConfJoin } from '../lib/methods/videoConf'; import { videoConferenceCancel, notifyUser, videoConferenceStart } from '../lib/services/restApi'; import { type ICallInfo } from '../reducers/videoConf'; -import { useCallStore } from '../lib/services/voip/useCallStore'; +import { voipBlocksIncomingVideoconf } from '../lib/services/voip/voipBlocksIncomingVideoconf'; interface IGenericAction extends Action { type: string; @@ -48,9 +48,7 @@ const CALL_INTERVAL = 3000; const CALL_ATTEMPT_LIMIT = 10; function* onDirectCall(payload: ICallInfo) { - // Reject if already on a VoIP call or native accept is still binding to JS - const { call: activeVoipCall, nativeAcceptedCallId } = useCallStore.getState(); - if (activeVoipCall != null || nativeAcceptedCallId != null) return; + if (voipBlocksIncomingVideoconf()) return; const calls = yield* appSelector(state => state.videoConf.calls); const currentCall = calls.find(c => c.callId === payload.callId);