Skip to content

Commit 0f40eb7

Browse files
committed
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.
1 parent 251d1af commit 0f40eb7

3 files changed

Lines changed: 146 additions & 2 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @jest-environment node
3+
*
4+
* iOS-only mute tests: requires isIOS = true so performSetMutedCallAction listener is registered.
5+
*/
6+
import { DeviceEventEmitter } from 'react-native';
7+
import RNCallKeep from 'react-native-callkeep';
8+
9+
import { DEEP_LINKING } from '../../../actions/actionsTypes';
10+
import type { VoipPayload } from '../../../definitions/Voip';
11+
import NativeVoipModule from '../../native/NativeVoip';
12+
import { getInitialMediaCallEvents, setupMediaCallEvents } from './MediaCallEvents';
13+
import { useCallStore } from './useCallStore';
14+
15+
const mockDispatch = jest.fn();
16+
const mockSetNativeAcceptedCallId = jest.fn();
17+
const mockAddEventListener = jest.fn();
18+
const mockRNCallKeepClearInitialEvents = jest.fn();
19+
const mockSetCurrentCallActive = jest.fn();
20+
21+
jest.mock('../../methods/helpers', () => ({
22+
...jest.requireActual('../../methods/helpers'),
23+
isIOS: true
24+
}));
25+
26+
jest.mock('../../store', () => ({
27+
__esModule: true,
28+
default: {
29+
dispatch: (...args: unknown[]) => mockDispatch(...args)
30+
}
31+
}));
32+
33+
jest.mock('./useCallStore', () => ({
34+
useCallStore: {
35+
getState: jest.fn()
36+
}
37+
}));
38+
39+
jest.mock('../../native/NativeVoip', () => ({
40+
__esModule: true,
41+
default: {
42+
clearInitialEvents: jest.fn(),
43+
getInitialEvents: jest.fn(() => null)
44+
}
45+
}));
46+
47+
jest.mock('react-native-callkeep', () => ({
48+
__esModule: true,
49+
default: {
50+
addEventListener: (...args: unknown[]) => mockAddEventListener(...args),
51+
clearInitialEvents: (...args: unknown[]) => mockRNCallKeepClearInitialEvents(...args),
52+
setCurrentCallActive: (...args: unknown[]) => mockSetCurrentCallActive(...args),
53+
getInitialEvents: jest.fn(() => Promise.resolve([]))
54+
}
55+
}));
56+
57+
jest.mock('./MediaSessionInstance', () => ({
58+
mediaSessionInstance: {
59+
endCall: jest.fn()
60+
}
61+
}));
62+
63+
jest.mock('../restApi', () => ({
64+
registerPushToken: jest.fn(() => Promise.resolve())
65+
}));
66+
67+
const activeCallBase = {
68+
call: {} as object,
69+
callId: 'uuid-1',
70+
nativeAcceptedCallId: null as string | null
71+
};
72+
73+
function getMuteHandler(): (payload: { muted: boolean; callUUID: string }) => void {
74+
const call = mockAddEventListener.mock.calls.find(([name]) => name === 'performSetMutedCallAction');
75+
if (!call) {
76+
throw new Error('performSetMutedCallAction listener not registered');
77+
}
78+
return call[1] as (payload: { muted: boolean; callUUID: string }) => void;
79+
}
80+
81+
describe('setupMediaCallEvents — performSetMutedCallAction (iOS)', () => {
82+
const toggleMute = jest.fn();
83+
const getState = useCallStore.getState as jest.Mock;
84+
85+
beforeEach(() => {
86+
jest.clearAllMocks();
87+
toggleMute.mockClear();
88+
mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() }));
89+
getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute });
90+
});
91+
92+
it('registers performSetMutedCallAction via RNCallKeep.addEventListener', () => {
93+
setupMediaCallEvents();
94+
expect(mockAddEventListener).toHaveBeenCalledWith('performSetMutedCallAction', expect.any(Function));
95+
});
96+
97+
it('calls toggleMute when muted state differs from OS and UUIDs match', () => {
98+
setupMediaCallEvents();
99+
getMuteHandler()({ muted: true, callUUID: 'uuid-1' });
100+
expect(toggleMute).toHaveBeenCalledTimes(1);
101+
});
102+
103+
it('does not call toggleMute when muted state already matches OS even if UUIDs match', () => {
104+
getState.mockReturnValue({ ...activeCallBase, isMuted: true, toggleMute });
105+
setupMediaCallEvents();
106+
getMuteHandler()({ muted: true, callUUID: 'uuid-1' });
107+
expect(toggleMute).not.toHaveBeenCalled();
108+
});
109+
110+
it('drops event when callUUID does not match active call id', () => {
111+
setupMediaCallEvents();
112+
getMuteHandler()({ muted: true, callUUID: 'uuid-2' });
113+
expect(toggleMute).not.toHaveBeenCalled();
114+
});
115+
116+
it('drops event when there is no active call object even if UUIDs match', () => {
117+
getState.mockReturnValue({
118+
call: null,
119+
callId: 'uuid-1',
120+
nativeAcceptedCallId: null,
121+
isMuted: false,
122+
toggleMute
123+
});
124+
setupMediaCallEvents();
125+
getMuteHandler()({ muted: true, callUUID: 'uuid-1' });
126+
expect(toggleMute).not.toHaveBeenCalled();
127+
});
128+
});

app/lib/services/voip/MediaCallEvents.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ const activeCallBase = {
8787
nativeAcceptedCallId: null as string | null
8888
};
8989

90+
function getMuteHandler(): (payload: { muted: boolean; callUUID: string }) => void {
91+
const call = mockAddEventListener.mock.calls.find(([name]) => name === 'performSetMutedCallAction');
92+
if (!call) {
93+
throw new Error('performSetMutedCallAction listener not registered');
94+
}
95+
return call[1] as (payload: { muted: boolean; callUUID: string }) => void;
96+
}
97+
9098
describe('MediaCallEvents cross-server accept (slice 3)', () => {
9199
const getState = useCallStore.getState as jest.Mock;
92100

app/lib/services/voip/MediaCallEvents.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,16 @@ export const setupMediaCallEvents = (): (() => void) => {
9090
);
9191

9292
subscriptions.push(
93-
RNCallKeep.addEventListener('performSetMutedCallAction', ({ muted, callUUID: _callUUID }) => {
94-
const { toggleMute, isMuted } = useCallStore.getState();
93+
RNCallKeep.addEventListener('performSetMutedCallAction', ({ muted, callUUID }) => {
94+
const { call, callId, nativeAcceptedCallId, toggleMute, isMuted } = useCallStore.getState();
95+
const eventUuid = callUUID.toLowerCase();
96+
const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase();
97+
98+
// No active media call or event is for another CallKit/Telecom session — drop stale closure state
99+
if (!call || !activeUuid || eventUuid !== activeUuid) {
100+
return;
101+
}
102+
95103
// Sync mute state if it doesn't match what the OS is reporting
96104
if (muted !== isMuted) {
97105
toggleMute();

0 commit comments

Comments
 (0)