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..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 @@ -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,8 +8,10 @@ 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 import android.media.RingtoneManager import android.os.Build import android.os.Bundle @@ -17,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 @@ -477,6 +481,70 @@ class VoipNotification(private val context: Context) { } } + /** + * 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 -> + 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 + } + 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 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 + } + + /** + * 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 +681,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/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') + }); +}; diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index ccca424a58b..62ce6b659f0 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'), @@ -64,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; @@ -110,6 +125,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 +364,112 @@ 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('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({ + 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)); + }); + }); + + 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 f60af626c28..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[] = []; @@ -84,6 +85,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); @@ -137,6 +150,7 @@ class MediaSessionInstance { }; public startCall = (userId: string, actor: CallActorType) => { + requestPhoneStatePermission(); console.log('[VoIP] Starting call:', userId); this.instance?.startCall(actor, userId); }; 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 d9ca57c4f2a..19126bf0131 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 { voipBlocksIncomingVideoconf } from '../lib/services/voip/voipBlocksIncomingVideoconf'; interface IGenericAction extends Action { type: string; @@ -47,6 +48,8 @@ const CALL_INTERVAL = 3000; const CALL_ATTEMPT_LIMIT = 10; function* onDirectCall(payload: ICallInfo) { + if (voipBlocksIncomingVideoconf()) 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..747af706941 100644 --- a/ios/Libraries/AppDelegate+Voip.swift +++ b/ios/Libraries/AppDelegate+Voip.swift @@ -40,7 +40,12 @@ extension AppDelegate: PKPushRegistryDelegate { return } - VoipService.prepareIncomingCall(voipPayload) + // 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() + + // Keep DDP + timeout for busy reject, but do not expose this call via getInitialEvents. + VoipService.prepareIncomingCall(voipPayload, storeEventsForJs: !isBusy) RNCallKeep.reportNewIncomingCall( callId, @@ -56,6 +61,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..74e783ef9c5 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -135,8 +135,20 @@ public final class VoipService: NSObject { #endif } - public static func prepareIncomingCall(_ payload: VoipPayload) { - storeInitialEvents(payload) + /// 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 } + } + + /// 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) } @@ -518,6 +530,31 @@ 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) + + if initialEventsData?.callId == payload.callId { + clearInitialEventsInternal() + } + + // 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