From 251d1afc0b64811fc200ab346f1e89645d93465c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 10 Apr 2026 12:04:50 -0300 Subject: [PATCH 1/4] fix(voip): show CallKit UI when call is active in background When a VoIP call is active and the app is backgrounded, iOS was not showing the call in the system UI (lock screen, Control Center, Dynamic Island) because two CallKit actions were missing. - Call RNCallKeep.setCurrentCallActive() in handleStateChange when callState transitions to 'active', so iOS shows the ongoing call. - Wire performSetMutedCallAction to toggleMute() so the mute button in the system UI syncs correctly with the WebRTC layer. --- app/lib/services/voip/MediaCallEvents.ts | 10 ++++++++++ app/lib/services/voip/useCallStore.test.ts | 10 +++++++++- app/lib/services/voip/useCallStore.ts | 6 ++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 86cfc73d96a..65fb5c1031a 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -89,6 +89,16 @@ export const setupMediaCallEvents = (): (() => void) => { }) ); + subscriptions.push( + RNCallKeep.addEventListener('performSetMutedCallAction', ({ muted, callUUID: _callUUID }) => { + const { toggleMute, isMuted } = useCallStore.getState(); + // Sync mute state if it doesn't match what the OS is reporting + if (muted !== isMuted) { + toggleMute(); + } + }) + ); + // Note: there is intentionally no 'answerCall' listener here. // VoipService.swift handles accept natively: handleObservedCallChanged detects // hasConnected = true and calls handleNativeAccept(), which sends the DDP accept diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index 706c79dd541..69feb829e69 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -11,7 +11,15 @@ jest.mock('../../../containers/ActionSheet', () => ({ hideActionSheetRef: jest.fn() })); -jest.mock('react-native-callkeep', () => ({})); +jest.mock('react-native-callkeep', () => ({ + setCurrentCallActive: jest.fn(), + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + endCall: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + setForceSpeakerphoneOn: jest.fn(), + setAvailable: jest.fn() +})); function createMockCall(callId: string) { const listeners: Record void>> = {}; diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 1ed618f4893..75f8c217518 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -175,6 +175,12 @@ export const useCallStore = create((set, get) => ({ if (newState === 'active' && !get().callStartTime) { set({ callStartTime: Date.now() }); } + + // Tell CallKit the call is active so iOS shows it in the system UI (lock screen, Control Center, Dynamic Island) + if (newState === 'active') { + const { callId, nativeAcceptedCallId } = get(); + RNCallKeep.setCurrentCallActive(callId ?? nativeAcceptedCallId ?? ''); + } }; const handleTrackStateChange = () => { From 0f40eb7428b84981fad5ade10aaf913dbfabaa25 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 10 Apr 2026 18:00:37 -0300 Subject: [PATCH 2/4] fix(voip): guard mute sync by callUUID in performSetMutedCallAction Prevent a stale/other CallKit session from flipping mute on the active JS call by mirroring the UUID check pattern used in didToggleHoldCallAction. Also adds iOS-specific tests for the mute handler. --- .../services/voip/MediaCallEvents.ios.test.ts | 128 ++++++++++++++++++ app/lib/services/voip/MediaCallEvents.test.ts | 8 ++ app/lib/services/voip/MediaCallEvents.ts | 12 +- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 app/lib/services/voip/MediaCallEvents.ios.test.ts diff --git a/app/lib/services/voip/MediaCallEvents.ios.test.ts b/app/lib/services/voip/MediaCallEvents.ios.test.ts new file mode 100644 index 00000000000..823abf30088 --- /dev/null +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -0,0 +1,128 @@ +/** + * @jest-environment node + * + * iOS-only mute tests: requires isIOS = true so performSetMutedCallAction listener is registered. + */ +import { DeviceEventEmitter } from 'react-native'; +import RNCallKeep from 'react-native-callkeep'; + +import { DEEP_LINKING } from '../../../actions/actionsTypes'; +import type { VoipPayload } from '../../../definitions/Voip'; +import NativeVoipModule from '../../native/NativeVoip'; +import { getInitialMediaCallEvents, setupMediaCallEvents } from './MediaCallEvents'; +import { useCallStore } from './useCallStore'; + +const mockDispatch = jest.fn(); +const mockSetNativeAcceptedCallId = jest.fn(); +const mockAddEventListener = jest.fn(); +const mockRNCallKeepClearInitialEvents = jest.fn(); +const mockSetCurrentCallActive = jest.fn(); + +jest.mock('../../methods/helpers', () => ({ + ...jest.requireActual('../../methods/helpers'), + isIOS: true +})); + +jest.mock('../../store', () => ({ + __esModule: true, + default: { + dispatch: (...args: unknown[]) => mockDispatch(...args) + } +})); + +jest.mock('./useCallStore', () => ({ + useCallStore: { + getState: jest.fn() + } +})); + +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => null) + } +})); + +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: (...args: unknown[]) => mockAddEventListener(...args), + clearInitialEvents: (...args: unknown[]) => mockRNCallKeepClearInitialEvents(...args), + setCurrentCallActive: (...args: unknown[]) => mockSetCurrentCallActive(...args), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); + +jest.mock('./MediaSessionInstance', () => ({ + mediaSessionInstance: { + endCall: jest.fn() + } +})); + +jest.mock('../restApi', () => ({ + registerPushToken: jest.fn(() => Promise.resolve()) +})); + +const activeCallBase = { + call: {} as object, + callId: 'uuid-1', + nativeAcceptedCallId: null as string | null +}; + +function getMuteHandler(): (payload: { muted: boolean; callUUID: string }) => void { + const call = mockAddEventListener.mock.calls.find(([name]) => name === 'performSetMutedCallAction'); + if (!call) { + throw new Error('performSetMutedCallAction listener not registered'); + } + return call[1] as (payload: { muted: boolean; callUUID: string }) => void; +} + +describe('setupMediaCallEvents — performSetMutedCallAction (iOS)', () => { + const toggleMute = jest.fn(); + const getState = useCallStore.getState as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + toggleMute.mockClear(); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute }); + }); + + it('registers performSetMutedCallAction via RNCallKeep.addEventListener', () => { + setupMediaCallEvents(); + expect(mockAddEventListener).toHaveBeenCalledWith('performSetMutedCallAction', expect.any(Function)); + }); + + it('calls toggleMute when muted state differs from OS and UUIDs match', () => { + setupMediaCallEvents(); + getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); + expect(toggleMute).toHaveBeenCalledTimes(1); + }); + + it('does not call toggleMute when muted state already matches OS even if UUIDs match', () => { + getState.mockReturnValue({ ...activeCallBase, isMuted: true, toggleMute }); + setupMediaCallEvents(); + getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); + expect(toggleMute).not.toHaveBeenCalled(); + }); + + it('drops event when callUUID does not match active call id', () => { + setupMediaCallEvents(); + getMuteHandler()({ muted: true, callUUID: 'uuid-2' }); + expect(toggleMute).not.toHaveBeenCalled(); + }); + + it('drops event when there is no active call object even if UUIDs match', () => { + getState.mockReturnValue({ + call: null, + callId: 'uuid-1', + nativeAcceptedCallId: null, + isMuted: false, + toggleMute + }); + setupMediaCallEvents(); + getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); + expect(toggleMute).not.toHaveBeenCalled(); + }); +}); diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index 32d2367caa7..abfba37aa65 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -87,6 +87,14 @@ const activeCallBase = { nativeAcceptedCallId: null as string | null }; +function getMuteHandler(): (payload: { muted: boolean; callUUID: string }) => void { + const call = mockAddEventListener.mock.calls.find(([name]) => name === 'performSetMutedCallAction'); + if (!call) { + throw new Error('performSetMutedCallAction listener not registered'); + } + return call[1] as (payload: { muted: boolean; callUUID: string }) => void; +} + describe('MediaCallEvents cross-server accept (slice 3)', () => { const getState = useCallStore.getState as jest.Mock; diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 65fb5c1031a..a81a40b6bd6 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -90,8 +90,16 @@ export const setupMediaCallEvents = (): (() => void) => { ); subscriptions.push( - RNCallKeep.addEventListener('performSetMutedCallAction', ({ muted, callUUID: _callUUID }) => { - const { toggleMute, isMuted } = useCallStore.getState(); + RNCallKeep.addEventListener('performSetMutedCallAction', ({ muted, callUUID }) => { + const { call, callId, nativeAcceptedCallId, toggleMute, isMuted } = useCallStore.getState(); + const eventUuid = callUUID.toLowerCase(); + const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase(); + + // No active media call or event is for another CallKit/Telecom session — drop stale closure state + if (!call || !activeUuid || eventUuid !== activeUuid) { + return; + } + // Sync mute state if it doesn't match what the OS is reporting if (muted !== isMuted) { toggleMute(); From 05f6cb7ecf2ae613c817599b273d18ac1cc4c3a5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Fri, 10 Apr 2026 18:14:01 -0300 Subject: [PATCH 3/4] fix(voip): correct event name and clean up unused test code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change performSetMutedCallAction → didPerformSetMutedCallAction in MediaCallEvents.ts so CallKit mute events actually fire - Remove unused imports/variables from MediaCallEvents.ios.test.ts (DeviceEventEmitter, RNCallKeep, DEEP_LINKING, VoipPayload, NativeVoipModule, getInitialMediaCallEvents, mockDispatch, etc.) - Remove unused getMuteHandler helper from MediaCallEvents.test.ts --- .../services/voip/MediaCallEvents.ios.test.ts | 53 ++++--------------- app/lib/services/voip/MediaCallEvents.test.ts | 8 --- app/lib/services/voip/MediaCallEvents.ts | 2 +- 3 files changed, 10 insertions(+), 53 deletions(-) diff --git a/app/lib/services/voip/MediaCallEvents.ios.test.ts b/app/lib/services/voip/MediaCallEvents.ios.test.ts index 823abf30088..00230c554e5 100644 --- a/app/lib/services/voip/MediaCallEvents.ios.test.ts +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -1,69 +1,34 @@ /** * @jest-environment node * - * iOS-only mute tests: requires isIOS = true so performSetMutedCallAction listener is registered. + * iOS-only mute tests: requires isIOS = true so didPerformSetMutedCallAction listener is registered. */ -import { DeviceEventEmitter } from 'react-native'; -import RNCallKeep from 'react-native-callkeep'; - -import { DEEP_LINKING } from '../../../actions/actionsTypes'; -import type { VoipPayload } from '../../../definitions/Voip'; -import NativeVoipModule from '../../native/NativeVoip'; -import { getInitialMediaCallEvents, setupMediaCallEvents } from './MediaCallEvents'; +import { setupMediaCallEvents } from './MediaCallEvents'; import { useCallStore } from './useCallStore'; -const mockDispatch = jest.fn(); -const mockSetNativeAcceptedCallId = jest.fn(); const mockAddEventListener = jest.fn(); -const mockRNCallKeepClearInitialEvents = jest.fn(); -const mockSetCurrentCallActive = jest.fn(); jest.mock('../../methods/helpers', () => ({ ...jest.requireActual('../../methods/helpers'), isIOS: true })); -jest.mock('../../store', () => ({ - __esModule: true, - default: { - dispatch: (...args: unknown[]) => mockDispatch(...args) - } -})); - jest.mock('./useCallStore', () => ({ useCallStore: { getState: jest.fn() } })); -jest.mock('../../native/NativeVoip', () => ({ - __esModule: true, - default: { - clearInitialEvents: jest.fn(), - getInitialEvents: jest.fn(() => null) - } -})); - jest.mock('react-native-callkeep', () => ({ __esModule: true, default: { addEventListener: (...args: unknown[]) => mockAddEventListener(...args), - clearInitialEvents: (...args: unknown[]) => mockRNCallKeepClearInitialEvents(...args), - setCurrentCallActive: (...args: unknown[]) => mockSetCurrentCallActive(...args), + clearInitialEvents: jest.fn(), + setCurrentCallActive: jest.fn(), getInitialEvents: jest.fn(() => Promise.resolve([])) } })); -jest.mock('./MediaSessionInstance', () => ({ - mediaSessionInstance: { - endCall: jest.fn() - } -})); - -jest.mock('../restApi', () => ({ - registerPushToken: jest.fn(() => Promise.resolve()) -})); - const activeCallBase = { call: {} as object, callId: 'uuid-1', @@ -71,14 +36,14 @@ const activeCallBase = { }; function getMuteHandler(): (payload: { muted: boolean; callUUID: string }) => void { - const call = mockAddEventListener.mock.calls.find(([name]) => name === 'performSetMutedCallAction'); + const call = mockAddEventListener.mock.calls.find(([name]) => name === 'didPerformSetMutedCallAction'); if (!call) { - throw new Error('performSetMutedCallAction listener not registered'); + throw new Error('didPerformSetMutedCallAction listener not registered'); } return call[1] as (payload: { muted: boolean; callUUID: string }) => void; } -describe('setupMediaCallEvents — performSetMutedCallAction (iOS)', () => { +describe('setupMediaCallEvents — didPerformSetMutedCallAction (iOS)', () => { const toggleMute = jest.fn(); const getState = useCallStore.getState as jest.Mock; @@ -89,9 +54,9 @@ describe('setupMediaCallEvents — performSetMutedCallAction (iOS)', () => { getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute }); }); - it('registers performSetMutedCallAction via RNCallKeep.addEventListener', () => { + it('registers didPerformSetMutedCallAction via RNCallKeep.addEventListener', () => { setupMediaCallEvents(); - expect(mockAddEventListener).toHaveBeenCalledWith('performSetMutedCallAction', expect.any(Function)); + expect(mockAddEventListener).toHaveBeenCalledWith('didPerformSetMutedCallAction', expect.any(Function)); }); it('calls toggleMute when muted state differs from OS and UUIDs match', () => { diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index abfba37aa65..32d2367caa7 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -87,14 +87,6 @@ const activeCallBase = { nativeAcceptedCallId: null as string | null }; -function getMuteHandler(): (payload: { muted: boolean; callUUID: string }) => void { - const call = mockAddEventListener.mock.calls.find(([name]) => name === 'performSetMutedCallAction'); - if (!call) { - throw new Error('performSetMutedCallAction listener not registered'); - } - return call[1] as (payload: { muted: boolean; callUUID: string }) => void; -} - describe('MediaCallEvents cross-server accept (slice 3)', () => { const getState = useCallStore.getState as jest.Mock; diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index a81a40b6bd6..74dc10e7b37 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -90,7 +90,7 @@ export const setupMediaCallEvents = (): (() => void) => { ); subscriptions.push( - RNCallKeep.addEventListener('performSetMutedCallAction', ({ muted, callUUID }) => { + RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { const { call, callId, nativeAcceptedCallId, toggleMute, isMuted } = useCallStore.getState(); const eventUuid = callUUID.toLowerCase(); const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase(); From d8fbf665b1c4a70b9b1598d8ffad250679055053 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 13 Apr 2026 11:47:50 -0300 Subject: [PATCH 4/4] fix(voip): add missing mocks to iOS mute test to fix CI MediaCallEvents.ios.test.ts was missing mocks for store, NativeVoip, MediaSessionInstance, and restApi, causing Jest to traverse the full import chain into @rocket.chat/media-signaling (ESM) which fails to parse in the node test environment. --- .../services/voip/MediaCallEvents.ios.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/lib/services/voip/MediaCallEvents.ios.test.ts b/app/lib/services/voip/MediaCallEvents.ios.test.ts index 00230c554e5..b6850417f11 100644 --- a/app/lib/services/voip/MediaCallEvents.ios.test.ts +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -19,6 +19,31 @@ jest.mock('./useCallStore', () => ({ } })); +jest.mock('../../store', () => ({ + __esModule: true, + default: { + dispatch: jest.fn() + } +})); + +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => null) + } +})); + +jest.mock('./MediaSessionInstance', () => ({ + mediaSessionInstance: { + endCall: jest.fn() + } +})); + +jest.mock('../restApi', () => ({ + registerPushToken: jest.fn(() => Promise.resolve()) +})); + jest.mock('react-native-callkeep', () => ({ __esModule: true, default: {