Skip to content

Commit 2a098f7

Browse files
authored
fix(voip): show CallKit UI when call is active in background (#7128)
1 parent 2e9831c commit 2a098f7

File tree

4 files changed

+151
-1
lines changed

4 files changed

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

app/lib/services/voip/MediaCallEvents.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ export const setupMediaCallEvents = (): (() => void) => {
8989
})
9090
);
9191

92+
subscriptions.push(
93+
RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ 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+
103+
// Sync mute state if it doesn't match what the OS is reporting
104+
if (muted !== isMuted) {
105+
toggleMute();
106+
}
107+
})
108+
);
109+
92110
// Note: there is intentionally no 'answerCall' listener here.
93111
// VoipService.swift handles accept natively: handleObservedCallChanged detects
94112
// hasConnected = true and calls handleNativeAccept(), which sends the DDP accept

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,15 @@ jest.mock('../../../containers/ActionSheet', () => ({
1111
hideActionSheetRef: jest.fn()
1212
}));
1313

14-
jest.mock('react-native-callkeep', () => ({}));
14+
jest.mock('react-native-callkeep', () => ({
15+
setCurrentCallActive: jest.fn(),
16+
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
17+
endCall: jest.fn(),
18+
start: jest.fn(),
19+
stop: jest.fn(),
20+
setForceSpeakerphoneOn: jest.fn(),
21+
setAvailable: jest.fn()
22+
}));
1523

1624
function createMockCall(callId: string) {
1725
const listeners: Record<string, Set<(...args: unknown[]) => void>> = {};

app/lib/services/voip/useCallStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ export const useCallStore = create<CallStore>((set, get) => ({
175175
if (newState === 'active' && !get().callStartTime) {
176176
set({ callStartTime: Date.now() });
177177
}
178+
179+
// Tell CallKit the call is active so iOS shows it in the system UI (lock screen, Control Center, Dynamic Island)
180+
if (newState === 'active') {
181+
const { callId, nativeAcceptedCallId } = get();
182+
RNCallKeep.setCurrentCallActive(callId ?? nativeAcceptedCallId ?? '');
183+
}
178184
};
179185

180186
const handleTrackStateChange = () => {

0 commit comments

Comments
 (0)