Skip to content

Commit 408cced

Browse files
committed
feat(voip): auto-hold RC call on OS hold (CallKeep didToggleHoldCallAction)
Listen for didToggleHoldCallAction outside the iOS-only block so Android Telecom and iOS CallKit both drive hold/unhold. Use a closure flag to auto-resume only after OS-initiated hold, not manual hold. Reset bookkeeping when there is no active media call or callUUID does not match callId/nativeAcceptedCallId so workspace or call changes cannot leave stale wasAutoHeld state. Tests mock RNCallKeep and assert toggleHold behavior and cleanup. Made-with: Cursor
1 parent 72ab82e commit 408cced

2 files changed

Lines changed: 163 additions & 8 deletions

File tree

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

Lines changed: 134 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import { DEEP_LINKING } from '../../../actions/actionsTypes';
55
import type { VoipPayload } from '../../../definitions/Voip';
66
import NativeVoipModule from '../../native/NativeVoip';
77
import { getInitialMediaCallEvents, setupMediaCallEvents } from './MediaCallEvents';
8+
import { useCallStore } from './useCallStore';
89

910
const mockDispatch = jest.fn();
1011
const mockSetNativeAcceptedCallId = jest.fn();
12+
const mockAddEventListener = jest.fn();
13+
const mockRNCallKeepClearInitialEvents = jest.fn();
1114

1215
jest.mock('../../methods/helpers', () => ({
1316
...jest.requireActual('../../methods/helpers'),
@@ -23,9 +26,7 @@ jest.mock('../../store', () => ({
2326

2427
jest.mock('./useCallStore', () => ({
2528
useCallStore: {
26-
getState: () => ({
27-
setNativeAcceptedCallId: mockSetNativeAcceptedCallId
28-
})
29+
getState: jest.fn()
2930
}
3031
}));
3132

@@ -37,12 +38,13 @@ jest.mock('../../native/NativeVoip', () => ({
3738
}
3839
}));
3940

40-
const mockRNCallKeepClearInitialEvents = jest.fn();
41-
4241
jest.mock('react-native-callkeep', () => ({
43-
addEventListener: jest.fn(),
44-
clearInitialEvents: (...args: unknown[]) => mockRNCallKeepClearInitialEvents(...args),
45-
getInitialEvents: jest.fn(() => Promise.resolve([]))
42+
__esModule: true,
43+
default: {
44+
addEventListener: (...args: unknown[]) => mockAddEventListener(...args),
45+
clearInitialEvents: (...args: unknown[]) => mockRNCallKeepClearInitialEvents(...args),
46+
getInitialEvents: jest.fn(() => Promise.resolve([]))
47+
}
4648
}));
4749

4850
jest.mock('./MediaSessionInstance', () => ({
@@ -51,6 +53,10 @@ jest.mock('./MediaSessionInstance', () => ({
5153
}
5254
}));
5355

56+
jest.mock('../restApi', () => ({
57+
registerPushToken: jest.fn(() => Promise.resolve())
58+
}));
59+
5460
function buildIncomingPayload(overrides: Partial<VoipPayload> = {}): VoipPayload {
5561
return {
5662
callId: 'call-b-uuid',
@@ -64,13 +70,32 @@ function buildIncomingPayload(overrides: Partial<VoipPayload> = {}): VoipPayload
6470
};
6571
}
6672

73+
function getToggleHoldHandler(): (payload: { hold: boolean; callUUID: string }) => void {
74+
const call = mockAddEventListener.mock.calls.find(([name]) => name === 'didToggleHoldCallAction');
75+
if (!call) {
76+
throw new Error('didToggleHoldCallAction listener not registered');
77+
}
78+
return call[1] as (payload: { hold: boolean; callUUID: string }) => void;
79+
}
80+
81+
/** Minimal store slice: handler only runs hold logic when call + matching callId/native id exist. */
82+
const activeCallBase = {
83+
call: {} as object,
84+
callId: 'uuid-1',
85+
nativeAcceptedCallId: null as string | null
86+
};
87+
6788
describe('MediaCallEvents cross-server accept (slice 3)', () => {
89+
const getState = useCallStore.getState as jest.Mock;
90+
6891
describe('VoipAccept via setupMediaCallEvents', () => {
6992
let teardown: (() => void) | undefined;
7093

7194
beforeEach(() => {
7295
jest.clearAllMocks();
96+
mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() }));
7397
(NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue(null);
98+
getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId });
7499
teardown = setupMediaCallEvents();
75100
});
76101

@@ -163,10 +188,12 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => {
163188
describe('getInitialMediaCallEvents', () => {
164189
beforeEach(() => {
165190
jest.clearAllMocks();
191+
mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() }));
166192
mockRNCallKeepClearInitialEvents.mockClear();
167193
(NativeVoipModule.getInitialEvents as jest.Mock).mockReset();
168194
(NativeVoipModule.clearInitialEvents as jest.Mock).mockClear();
169195
(RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([]);
196+
getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId });
170197
});
171198

172199
it('returns true and dispatches failure deep link when stash has voipAcceptFailed + host + callId', async () => {
@@ -220,3 +247,102 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => {
220247
});
221248
});
222249
});
250+
251+
describe('setupMediaCallEvents — didToggleHoldCallAction', () => {
252+
const toggleHold = jest.fn();
253+
const getState = useCallStore.getState as jest.Mock;
254+
255+
beforeEach(() => {
256+
jest.clearAllMocks();
257+
toggleHold.mockClear();
258+
mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() }));
259+
getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold });
260+
});
261+
262+
it('registers didToggleHoldCallAction via RNCallKeep.addEventListener', () => {
263+
setupMediaCallEvents();
264+
expect(mockAddEventListener).toHaveBeenCalledWith('didToggleHoldCallAction', expect.any(Function));
265+
});
266+
267+
it('hold: true when isOnHold is false calls toggleHold once', () => {
268+
setupMediaCallEvents();
269+
getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' });
270+
expect(toggleHold).toHaveBeenCalledTimes(1);
271+
});
272+
273+
it('hold: true when isOnHold is true does not call toggleHold', () => {
274+
getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold });
275+
setupMediaCallEvents();
276+
getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' });
277+
expect(toggleHold).not.toHaveBeenCalled();
278+
});
279+
280+
it('hold: false after OS-initiated hold calls toggleHold once (auto-resume)', () => {
281+
setupMediaCallEvents();
282+
const handler = getToggleHoldHandler();
283+
handler({ hold: true, callUUID: 'uuid-1' });
284+
getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold });
285+
handler({ hold: false, callUUID: 'uuid-1' });
286+
expect(toggleHold).toHaveBeenCalledTimes(2);
287+
});
288+
289+
it('hold: false without prior OS-initiated hold does not call toggleHold', () => {
290+
setupMediaCallEvents();
291+
getToggleHoldHandler()({ hold: false, callUUID: 'uuid-1' });
292+
expect(toggleHold).not.toHaveBeenCalled();
293+
});
294+
295+
it('consecutive hold: true events call toggleHold only once', () => {
296+
setupMediaCallEvents();
297+
const handler = getToggleHoldHandler();
298+
handler({ hold: true, callUUID: 'uuid-1' });
299+
getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold });
300+
handler({ hold: true, callUUID: 'uuid-1' });
301+
expect(toggleHold).toHaveBeenCalledTimes(1);
302+
});
303+
304+
it('clears stale auto-hold when callUUID does not match current call id (e.g. new workspace / call)', () => {
305+
setupMediaCallEvents();
306+
const handler = getToggleHoldHandler();
307+
handler({ hold: true, callUUID: 'uuid-1' });
308+
expect(toggleHold).toHaveBeenCalledTimes(1);
309+
getState.mockReturnValue({
310+
call: {},
311+
callId: 'uuid-2',
312+
nativeAcceptedCallId: null,
313+
isOnHold: true,
314+
toggleHold
315+
});
316+
handler({ hold: false, callUUID: 'uuid-1' });
317+
expect(toggleHold).toHaveBeenCalledTimes(1);
318+
handler({ hold: false, callUUID: 'uuid-2' });
319+
expect(toggleHold).toHaveBeenCalledTimes(1);
320+
});
321+
322+
it('does not toggle when there is no active call object even if ids match', () => {
323+
setupMediaCallEvents();
324+
const handler = getToggleHoldHandler();
325+
getState.mockReturnValue({
326+
call: null,
327+
callId: 'uuid-1',
328+
nativeAcceptedCallId: null,
329+
isOnHold: false,
330+
toggleHold
331+
});
332+
handler({ hold: true, callUUID: 'uuid-1' });
333+
expect(toggleHold).not.toHaveBeenCalled();
334+
});
335+
336+
it('cleanup removes didToggleHoldCallAction subscription', () => {
337+
const remove = jest.fn();
338+
mockAddEventListener.mockImplementation((event: string) => {
339+
if (event === 'didToggleHoldCallAction') {
340+
return { remove };
341+
}
342+
return { remove: jest.fn() };
343+
});
344+
const cleanup = setupMediaCallEvents();
345+
cleanup();
346+
expect(remove).toHaveBeenCalled();
347+
});
348+
});

app/lib/services/voip/MediaCallEvents.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,35 @@ export const setupMediaCallEvents = (): (() => void) => {
9595
// signal before JS runs. JS receives VoipAcceptSucceeded after success.
9696
}
9797

98+
/** Tracks OS-driven hold (competing call) so we only auto-resume that path, not manual hold. */
99+
let wasAutoHeld = false;
100+
subscriptions.push(
101+
RNCallKeep.addEventListener('didToggleHoldCallAction', ({ hold, callUUID }) => {
102+
const { call, callId, nativeAcceptedCallId, isOnHold, toggleHold } = useCallStore.getState();
103+
const eventUuid = callUUID.toLowerCase();
104+
const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase();
105+
106+
// No active media call or event is for another CallKit/Telecom session — drop stale closure state
107+
// (e.g. workspace/server switch, logout, or call ended while setupMediaCallEvents still lives on Root).
108+
if (!call || !activeUuid || eventUuid !== activeUuid) {
109+
wasAutoHeld = false;
110+
return;
111+
}
112+
113+
if (hold) {
114+
if (!isOnHold) {
115+
toggleHold();
116+
wasAutoHeld = true;
117+
}
118+
return;
119+
}
120+
if (wasAutoHeld) {
121+
toggleHold();
122+
wasAutoHeld = false;
123+
}
124+
})
125+
);
126+
98127
subscriptions.push(
99128
Emitter.addListener(EVENT_VOIP_ACCEPT_SUCCEEDED, (data: VoipPayload) => {
100129
try {

0 commit comments

Comments
 (0)