diff --git a/app/AppContainer.tsx b/app/AppContainer.tsx index 53701f36def..e2980781312 100644 --- a/app/AppContainer.tsx +++ b/app/AppContainer.tsx @@ -20,6 +20,7 @@ import { setCurrentScreen } from './lib/methods/helpers/log'; import { themes } from './lib/constants/colors'; import { emitter } from './lib/methods/helpers'; import MediaCallHeader from './containers/MediaCallHeader/MediaCallHeader'; +import { CallNavRouter } from './lib/services/voip/CallNavRouter'; const createStackNavigator = createNativeStackNavigator; @@ -36,6 +37,11 @@ const Stack = createStackNavigator(); const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: boolean }) => { const { theme } = useContext(ThemeContext); + useEffect(() => { + // Mount CallNavRouter once — it subscribes to CallLifecycle after NavigationContainer is ready. + CallNavRouter.mount(); + }, []); + useEffect(() => { if (root) { const state = Navigation.navigationRef.current?.getRootState(); diff --git a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx index a9c2e902f5e..d2b4ae81959 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -15,7 +15,6 @@ import React from 'react'; import { act, fireEvent, render } from '@testing-library/react-native'; import { Provider } from 'react-redux'; import RNCallKeep from 'react-native-callkeep'; -import InCallManager from 'react-native-incall-manager'; import type { IClientMediaCall } from '@rocket.chat/media-signaling'; import { NewMediaCall } from './NewMediaCall'; @@ -24,6 +23,7 @@ import Navigation from '../../lib/navigation/appNavigation'; import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore'; import { useCallStore } from '../../lib/services/voip/useCallStore'; import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; +import { voipNative, type InMemoryVoipNative } from '../../lib/services/voip/VoipNative'; import { mockedStore } from '../../reducers/mockedStore'; import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; import type { InsideStackParamList } from '../../stacks/types'; @@ -387,6 +387,7 @@ describe('VoIP call lifecycle (integration)', () => { usePeerAutocompleteStore.getState().reset(); useCallStore.getState().reset(); mediaSessionInstance.reset(); + (voipNative as InMemoryVoipNative).reset(); consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { const message = formatConsoleArgs(args); @@ -447,12 +448,18 @@ describe('VoIP call lifecycle (integration)', () => { const { call } = useCallStore.getState(); expect(call?.callId).toBe('call-user-1'); - // Firing 'ended' triggers RNCallKeep cleanup and navigation back via real handlers. - act(() => { + // Firing 'ended' triggers CallLifecycle teardown via the handleEnded listener. + // Navigation.back() is now handled by CallNavRouter (not wired in this integration test). + // We verify the teardown sequence runs: store cleared, native end issued. + // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it. + await act(async () => { (call!.emitter as unknown as ReturnType).emit('ended'); + await Promise.resolve(); }); - expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('call-user-1'); - expect(Navigation.back).toHaveBeenCalled(); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' }); + // Navigation.back() is now owned by CallNavRouter after callEnded emits. + // In this test environment, CallNavRouter is not mounted, so we assert the store cleared instead. + expect(useCallStore.getState().call).toBeNull(); }); it('SIP peer: press Call → startCall(sip, number) → navigates to CallView', async () => { @@ -563,7 +570,7 @@ describe('VoIP call lifecycle (integration)', () => { await flushMicrotasks(); }); - expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('incoming-1'); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: 'incoming-1' }); expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); expect(useCallStore.getState().call?.callId).toBe('incoming-1'); }); @@ -586,7 +593,7 @@ describe('VoIP call lifecycle (integration)', () => { await flushMicrotasks(); }); - expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('missing-1'); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'missing-1' }); expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); expect(Navigation.navigate).not.toHaveBeenCalled(); expect(useCallStore.getState().call).toBeNull(); @@ -622,7 +629,7 @@ describe('VoIP call lifecycle (integration)', () => { // app/views/CallView/components/CallButtons.tsx), NOT MediaSessionInstance.endCall. // The latter is invoked from native CallKit "end" events. Both need coverage. describe('UI store contract: Hang up', () => { - it('B1: useCallStore.endCall clears store and triggers RNCallKeep.endCall', async () => { + it('B1: useCallStore.endCall clears store and triggers voipNative.call.end', async () => { setSelectedPeer({ type: 'user', value: 'user-1', label: 'Alice', username: 'alice' }); const { getByTestId } = render( @@ -634,43 +641,58 @@ describe('VoIP call lifecycle (integration)', () => { await act(() => Promise.resolve()); expect(useCallStore.getState().call?.callId).toBe('call-user-1'); - act(() => { + // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it. + await act(async () => { useCallStore.getState().endCall(); + await Promise.resolve(); }); - expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('call-user-1'); - expect(InCallManager.stop as jest.Mock).toHaveBeenCalled(); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' }); + // stopAudio is now issued by CallLifecycle.end (step 6) via voipNative.call.stopAudio(), + // which in the test environment records to InMemoryVoipNative.recorded rather than calling InCallManager.stop. + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'stopAudio' }); expect(useCallStore.getState().call).toBeNull(); expect(useCallStore.getState().callId).toBeNull(); }); - it('B2: MediaSessionInstance.endCall during active state → RNCallKeep cleanup, store reset', () => { - const session = createdSessions[createdSessions.length - 1]; + it('B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset', async () => { + // endCall now delegates to callLifecycle.end('local'). CallLifecycle reads the + // active call from useCallStore, so the call must be set there first. const activeCall = makeCall({ callId: 'active-1', state: 'active' }); - session.getCallData.mockReturnValue(activeCall); - act(() => { + useCallStore.getState().setCall(activeCall); + }); + + // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it. + await act(async () => { mediaSessionInstance.endCall('active-1'); + await Promise.resolve(); }); - expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('active-1'); - expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith(''); - expect(RNCallKeep.setAvailable as jest.Mock).toHaveBeenCalledWith(true); + // CallLifecycle.end() steps 2-4 run via InMemoryVoipNative (records commands instead of calling RNCallKeep). + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'active-1' }); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'active-1' }); expect(useCallStore.getState().call).toBeNull(); }); - it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + RNCallKeep cleanup', () => { - const session = createdSessions[createdSessions.length - 1]; - const ringingCall = makeCall({ callId: 'ringing-1' }); - session.getCallData.mockReturnValue(ringingCall); - + it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup', async () => { + // CallLifecycle reads the active call from useCallStore to decide reject vs hangup. + // The ringing call must be in the store for reject() to be called. + const ringingCall = makeCall({ callId: 'ringing-1', state: 'ringing' }); act(() => { + useCallStore.getState().setCall(ringingCall); + }); + + // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it. + await act(async () => { mediaSessionInstance.endCall('ringing-1'); + await Promise.resolve(); }); expect(ringingCall.reject).toHaveBeenCalled(); expect(ringingCall.hangup).not.toHaveBeenCalled(); - expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('ringing-1'); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'ringing-1' }); expect(useCallStore.getState().call).toBeNull(); }); }); @@ -747,13 +769,13 @@ describe('VoIP call lifecycle (integration)', () => { await act(async () => { await useCallStore.getState().toggleSpeaker(); }); - expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(true); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'setSpeaker', on: true }); expect(useCallStore.getState().isSpeakerOn).toBe(true); await act(async () => { await useCallStore.getState().toggleSpeaker(); }); - expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(false); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'setSpeaker', on: false }); expect(useCallStore.getState().isSpeakerOn).toBe(false); }); }); @@ -800,7 +822,7 @@ describe('VoIP call lifecycle (integration)', () => { expect(useCallStore.getState().isOnHold).toBe(true); }); - it('D3: press end button → call.hangup, RNCallKeep.endCall, store cleared', () => { + it('D3: press end button → call.hangup, voipNative.call.end, store cleared', async () => { const call = makeCall({ callId: 'btn-end', role: 'caller', state: 'active' }); act(() => { useCallStore.getState().setCall(call); @@ -812,12 +834,14 @@ describe('VoIP call lifecycle (integration)', () => { ); - act(() => { + // CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it. + await act(async () => { fireEvent.press(getByTestId('call-view-end')); + await Promise.resolve(); }); expect(call.hangup).toHaveBeenCalled(); - expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('btn-end'); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'btn-end' }); expect(useCallStore.getState().call).toBeNull(); }); }); @@ -844,7 +868,7 @@ describe('VoIP call lifecycle (integration)', () => { expect(useCallStore.getState().callState).toBe('active'); expect(useCallStore.getState().callStartTime).not.toBeNull(); - expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('state-1'); + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: 'state-1' }); }); }); diff --git a/app/index.tsx b/app/index.tsx index 3c3ceeef2d0..9a8a4254c2e 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -33,11 +33,8 @@ import { } from './lib/methods/helpers/theme'; import { initializePushNotifications, onNotification } from './lib/notifications'; import { getInitialNotification, setupVideoConfActionListener } from './lib/notifications/videoConf/getInitialNotification'; -import { - getInitialMediaCallEvents, - setupMediaCallEvents, - type MediaCallEventsAdapters -} from './lib/services/voip/MediaCallEvents'; +import { createVoipEventDispatcher, type MediaCallEventsAdapters } from './lib/services/voip/MediaCallEvents'; +import { voipNative } from './lib/services/voip/VoipNative'; import store from './lib/store'; import { initStore } from './lib/store/auxStore'; import { type TSupportedThemes, ThemeContext } from './theme'; @@ -133,8 +130,6 @@ export default class Root extends React.Component<{}, IState> { // Set up video conf action listener for background accept/decline this.videoConfActionCleanup = setupVideoConfActionListener(); - // Set up media call event listeners for incoming calls - this.mediaCallEventCleanup = setupMediaCallEvents(this.getMediaCallEventsAdapters()); } componentWillUnmount() { @@ -164,7 +159,17 @@ export default class Root extends React.Component<{}, IState> { return; } - const voipInitialHandled = await getInitialMediaCallEvents(this.getMediaCallEventsAdapters()); + // Single VoIP attach: sets up live listeners and drains cold-start events before resolving. + let voipInitialHandled = false; + const dispatchVoipEvent = createVoipEventDispatcher(this.getMediaCallEventsAdapters()); + const { detach } = await voipNative.attach({ + onEvent: e => { + if (dispatchVoipEvent(e)) { + voipInitialHandled = true; + } + } + }); + this.mediaCallEventCleanup = detach; if (voipInitialHandled) { // VoIP path already dispatched navigation (or will via deep linking); do not call appInit() in parallel return; diff --git a/app/lib/services/voip/CallLifecycle.test.ts b/app/lib/services/voip/CallLifecycle.test.ts new file mode 100644 index 00000000000..460a02b722d --- /dev/null +++ b/app/lib/services/voip/CallLifecycle.test.ts @@ -0,0 +1,644 @@ +/** + * CallLifecycle.test.ts + * + * Tests for CallLifecycle.end(reason): + * - Teardown ordering verified via InMemoryVoipNative.recorded + * - Idempotency: concurrent end() calls → one observable teardown + * - callEnded emits exactly once per call + * - callId ?? nativeAcceptedCallId resolution (Pre-bind-safe) + * - reason payload threading + */ + +import type { IClientMediaCall } from '@rocket.chat/media-signaling'; + +import { callLifecycle } from './CallLifecycle'; +import type { CallEndReason } from './CallLifecycle'; +import { InMemoryVoipNative } from './VoipNative'; +import { useCallStore } from './useCallStore'; + +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + endCall: jest.fn(), + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); +jest.mock('react-native-webrtc', () => ({ registerGlobals: jest.fn() })); +jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn() } +})); +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + registerVoipToken: jest.fn(), + getInitialEvents: jest.fn(() => null), + clearInitialEvents: jest.fn(), + getLastVoipToken: jest.fn(() => ''), + stopNativeDDPClient: jest.fn(), + stopVoipCallService: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn() + } +})); +jest.mock('../../../containers/ActionSheet', () => ({ + hideActionSheetRef: jest.fn() +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeNative(): InMemoryVoipNative { + const native = new InMemoryVoipNative(); + callLifecycle.attach(native); + return native; +} + +function makeCall(options: { callId: string; state?: string }): IClientMediaCall { + return { + callId: options.callId, + state: options.state ?? 'active', + hidden: false, + localParticipant: { + local: true, + role: 'caller', + muted: false, + held: false, + contact: {} + }, + remoteParticipants: [ + { + local: false, + role: 'callee', + muted: false, + held: false, + contact: { id: 'u', displayName: 'U', username: 'u', sipExtension: '' } + } + ], + hangup: jest.fn(), + reject: jest.fn(), + sendDTMF: jest.fn(), + emitter: { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + } as unknown as IClientMediaCall; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('CallLifecycle.end(reason)', () => { + let native: InMemoryVoipNative; + + beforeEach(() => { + // Reset store state before each test + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + native = makeNative(); + native.reset(); + }); + + afterEach(() => { + // Clean up any listeners + }); + + describe('teardown ordering', () => { + it('records commands in the documented order (steps 2-4, 6)', async () => { + // Arrange: set up an active call in store + const call = makeCall({ callId: 'order-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + + // Act + await callLifecycle.end('local'); + + // Assert: step 2 (end), step 3 (markActive ''), step 4 (markAvailable), step 6 (stopAudio) + const { recorded } = native; + const endIdx = recorded.findIndex(c => c.cmd === 'end'); + const markActiveIdx = recorded.findIndex(c => c.cmd === 'markActive'); + const markAvailableIdx = recorded.findIndex(c => c.cmd === 'markAvailable'); + const stopAudioIdx = recorded.findIndex(c => c.cmd === 'stopAudio'); + + expect(endIdx).toBeGreaterThanOrEqual(0); + expect(markActiveIdx).toBeGreaterThan(endIdx); + expect(markAvailableIdx).toBeGreaterThan(markActiveIdx); + expect(stopAudioIdx).toBeGreaterThan(markAvailableIdx); + }); + + it('step 2: issues end with callId', async () => { + const call = makeCall({ callId: 'end-test-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + + await callLifecycle.end('local'); + + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'end-test-1' }); + }); + + it('step 3: issues markActive with empty string', async () => { + const call = makeCall({ callId: 'mark-1' }); + useCallStore.getState().setCall(call); + native.reset(); + + await callLifecycle.end('local'); + + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + }); + + it('step 4: issues markAvailable with callId', async () => { + const call = makeCall({ callId: 'avail-1' }); + useCallStore.getState().setCall(call); + native.reset(); + + await callLifecycle.end('local'); + + expect(native.recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'avail-1' }); + }); + + it('step 5: store is cleared (reset called)', async () => { + const call = makeCall({ callId: 'store-1' }); + useCallStore.getState().setCall(call); + + await callLifecycle.end('local'); + + expect(useCallStore.getState().call).toBeNull(); + expect(useCallStore.getState().callId).toBeNull(); + }); + + it('step 6: stopAudio fires after store is cleared', async () => { + const call = makeCall({ callId: 'stop-1' }); + useCallStore.getState().setCall(call); + native.reset(); + + let storeStateAtStopAudio: unknown = 'not-captured'; + const origStopAudio = native.call.stopAudio.bind(native.call); + jest.spyOn(native.call, 'stopAudio').mockImplementation(() => { + storeStateAtStopAudio = useCallStore.getState().call; + origStopAudio(); + }); + + await callLifecycle.end('local'); + + // Store should already be reset when stopAudio fires. + expect(storeStateAtStopAudio).toBeNull(); + }); + + it('step 1a: calls hangup() on active call', async () => { + const call = makeCall({ callId: 'hang-1', state: 'active' }); + useCallStore.getState().setCall(call); + + await callLifecycle.end('local'); + + expect(call.hangup).toHaveBeenCalled(); + expect(call.reject).not.toHaveBeenCalled(); + }); + + it('step 1b: calls reject() on ringing call', async () => { + const call = makeCall({ callId: 'ring-1', state: 'ringing' }); + useCallStore.getState().setCall(call); + + await callLifecycle.end('rejected'); + + expect(call.reject).toHaveBeenCalled(); + expect(call.hangup).not.toHaveBeenCalled(); + }); + + it('skips step 1 when no active call in store', async () => { + // No call set; should not throw and should still run native steps. + native.reset(); + await callLifecycle.end('remote'); + + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect(native.recorded).toContainEqual({ cmd: 'stopAudio' }); + }); + }); + + describe('callEnded event', () => { + it('emits callEnded exactly once', async () => { + const call = makeCall({ callId: 'emit-1' }); + useCallStore.getState().setCall(call); + + const listener = jest.fn(); + callLifecycle.emitter.on('callEnded', listener); + + await callLifecycle.end('local'); + + callLifecycle.emitter.off('callEnded', listener); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('callEnded carries the callId from store', async () => { + const call = makeCall({ callId: 'payload-1' }); + useCallStore.getState().setCall(call); + + const events: unknown[] = []; + const unsub = callLifecycle.emitter.on('callEnded', e => events.push(e)); + + await callLifecycle.end('remote'); + + unsub(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ callId: 'payload-1', reason: 'remote' }); + }); + + it('callEnded carries the reason', async () => { + const reasons: CallEndReason[] = ['local', 'remote', 'rejected', 'error']; + + for (const reason of reasons) { + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + native.reset(); + + const events: unknown[] = []; + const unsub = callLifecycle.emitter.on('callEnded', e => events.push(e)); + // eslint-disable-next-line no-await-in-loop + await callLifecycle.end(reason); + unsub(); + + expect(events[0]).toMatchObject({ reason }); + } + }); + }); + + describe('callId ?? nativeAcceptedCallId (Pre-bind-safe)', () => { + it('uses callId when both callId and nativeAcceptedCallId are present', async () => { + const call = makeCall({ callId: 'cid-1' }); + useCallStore.getState().setNativeAcceptedCallId('native-1'); + useCallStore.getState().setCall(call); + // After setCall, nativeAcceptedCallId is cleared; simulate pre-bind where both exist + useCallStore.setState({ callId: 'cid-1', nativeAcceptedCallId: 'native-1' }); + native.reset(); + + const events: unknown[] = []; + const unsub = callLifecycle.emitter.on('callEnded', e => events.push(e)); + await callLifecycle.end('local'); + unsub(); + + // callId takes precedence + expect(events[0]).toMatchObject({ callId: 'cid-1' }); + }); + + it('falls back to nativeAcceptedCallId when callId is null (Pre-bind)', async () => { + // Pre-bind state: native accepted the call but no MediaCall yet + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + useCallStore.getState().setNativeAcceptedCallId('native-prebind'); + native.reset(); + + const events: unknown[] = []; + const unsub = callLifecycle.emitter.on('callEnded', e => events.push(e)); + await callLifecycle.end('error'); + unsub(); + + expect(events[0]).toMatchObject({ callId: 'native-prebind' }); + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'native-prebind' }); + }); + + it('emits callId: null when both ids are null', async () => { + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + native.reset(); + + const events: unknown[] = []; + const unsub = callLifecycle.emitter.on('callEnded', e => events.push(e)); + await callLifecycle.end('remote'); + unsub(); + + expect(events[0]).toMatchObject({ callId: null }); + }); + }); + + describe('idempotency under concurrent end()', () => { + it('concurrent end() calls share the in-flight promise — one teardown', async () => { + const call = makeCall({ callId: 'concurrent-1' }); + useCallStore.getState().setCall(call); + native.reset(); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + // Fire two concurrent end() calls. + const [p1, p2] = [callLifecycle.end('local'), callLifecycle.end('remote')]; + + // Both callers receive a promise. + expect(p1).toBeInstanceOf(Promise); + expect(p2).toBeInstanceOf(Promise); + + // Both promises should be the same (in-flight sharing). + expect(p1).toBe(p2); + + await Promise.all([p1, p2]); + + unsub(); + + // callEnded fires exactly once (one teardown). + expect(callEndedListener).toHaveBeenCalledTimes(1); + + // End command issues exactly once. + const endCmds = native.recorded.filter(c => c.cmd === 'end'); + expect(endCmds).toHaveLength(1); + }); + + it('end() is callable again after first teardown completes', async () => { + const call = makeCall({ callId: 'seq-1' }); + useCallStore.getState().setCall(call); + + await callLifecycle.end('local'); + + // Second call (new lifecycle scenario): should not throw. + await expect(callLifecycle.end('remote')).resolves.toBeUndefined(); + }); + }); + + describe('native seam fallback', () => { + it('end() uses module-level voipNative as default when no override is set', async () => { + // The singleton voipNative is InMemoryVoipNative in test env (NODE_ENV=test). + // Create a fresh lifecycle instance without calling attach(). + const freshLifecycle = new (callLifecycle.constructor as new () => typeof callLifecycle)(); + // Should resolve without throwing (uses module-level InMemoryVoipNative). + await expect((freshLifecycle as any)._runTeardown('local')).resolves.toBeUndefined(); + }); + }); + + // Blocker 1 regression: faithful spy whose hangup() synchronously emits 'ended' + // (mirrors @rocket.chat/media-signaling/dist/lib/Call.js behavior at line 703). + // The 'ended' listener at useCallStore.ts handleEnded re-enters callLifecycle.end('remote'). + // The re-entry guard MUST be set BEFORE _runTeardown body runs, otherwise re-entrant + // teardown happens (callEnded fires twice, end command issues twice). + describe('re-entry guard against synchronous ended emission from hangup()', () => { + function makeCallWithSyncEndedOnHangup(callId: string): IClientMediaCall { + const listeners: Record void>> = {}; + const emitter = { + on: (ev: string, fn: (...args: unknown[]) => void) => { + if (!listeners[ev]) listeners[ev] = new Set(); + listeners[ev].add(fn); + return () => listeners[ev].delete(fn); + }, + off: (ev: string, fn: (...args: unknown[]) => void) => { + listeners[ev]?.delete(fn); + }, + emit: (ev: string, ...args: unknown[]) => { + listeners[ev]?.forEach(fn => fn(...args)); + } + }; + const hangup = jest.fn(() => { + // Mirror Call.js line 703: changeState('hangup') → emitter.emit('ended') + emitter.emit('ended'); + }); + return { + callId, + state: 'active', + hidden: false, + localParticipant: { + local: true, + role: 'caller', + muted: false, + held: false, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }, + remoteParticipants: [ + { + local: false, + role: 'callee', + muted: false, + held: false, + contact: { id: 'u', displayName: 'U', username: 'u', sipExtension: '' } + } + ], + hangup, + reject: jest.fn(), + sendDTMF: jest.fn(), + emitter + } as unknown as IClientMediaCall; + } + + it('end() called from inside hangup() synchronous ended emit hits the re-entry guard', async () => { + const call = makeCallWithSyncEndedOnHangup('reentry-1'); + useCallStore.getState().setCall(call); + native.reset(); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + // Outer end('local') triggers hangup() → 'ended' → handleEnded → end('remote') + // Re-entrant call MUST hit the guard and return the in-flight promise. + await callLifecycle.end('local'); + + unsub(); + + // callEnded fires exactly once — guard worked. + expect(callEndedListener).toHaveBeenCalledTimes(1); + // End command issued exactly once. + const endCmds = native.recorded.filter(c => c.cmd === 'end'); + expect(endCmds).toHaveLength(1); + // hangup invoked exactly once. + expect(call.hangup).toHaveBeenCalledTimes(1); + }); + }); + + // Blocker 3 regression: step 1 (mediaCall.reject/hangup) is wrapped in try/catch + // so a throw doesn't abort subsequent steps (markActive, markAvailable, store reset, stopAudio, callEnded). + describe('step 1 throw isolation', () => { + it('continues teardown when mediaCall.hangup() throws', async () => { + const call = makeCall({ callId: 'throw-1', state: 'active' }); + (call.hangup as jest.Mock).mockImplementationOnce(() => { + throw new Error('hangup boom'); + }); + useCallStore.getState().setCall(call); + native.reset(); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + await expect(callLifecycle.end('local')).resolves.toBeUndefined(); + + unsub(); + + // All subsequent steps still ran. + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'throw-1' }); + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect(native.recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'throw-1' }); + expect(native.recorded).toContainEqual({ cmd: 'stopAudio' }); + expect(useCallStore.getState().call).toBeNull(); + expect(callEndedListener).toHaveBeenCalledTimes(1); + }); + + it('continues teardown when mediaCall.reject() throws (ringing path)', async () => { + const call = makeCall({ callId: 'throw-rej-1', state: 'ringing' }); + (call.reject as jest.Mock).mockImplementationOnce(() => { + throw new Error('reject boom'); + }); + useCallStore.getState().setCall(call); + native.reset(); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + await expect(callLifecycle.end('rejected')).resolves.toBeUndefined(); + + unsub(); + + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'throw-rej-1' }); + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect(native.recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'throw-rej-1' }); + expect(native.recorded).toContainEqual({ cmd: 'stopAudio' }); + expect(useCallStore.getState().call).toBeNull(); + expect(callEndedListener).toHaveBeenCalledTimes(1); + }); + }); + + // CodeRabbit follow-up: steps 2-6 must also be guarded so a throw in any of + // them does not abort the rest of teardown or skip the callEnded emit. + describe('steps 2-6 throw isolation', () => { + it('continues teardown when native.call.end throws', async () => { + const call = makeCall({ callId: 'throw-end-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + jest.spyOn(native.call, 'end').mockImplementationOnce(() => { + throw new Error('end boom'); + }); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + await expect(callLifecycle.end('local')).resolves.toBeUndefined(); + + unsub(); + + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect(native.recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'throw-end-1' }); + expect(native.recorded).toContainEqual({ cmd: 'stopAudio' }); + expect(useCallStore.getState().call).toBeNull(); + expect(callEndedListener).toHaveBeenCalledTimes(1); + }); + + it('continues teardown when native.call.markActive throws', async () => { + const call = makeCall({ callId: 'throw-ma-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + jest.spyOn(native.call, 'markActive').mockImplementationOnce(() => { + throw new Error('markActive boom'); + }); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + await expect(callLifecycle.end('local')).resolves.toBeUndefined(); + + unsub(); + + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'throw-ma-1' }); + expect(native.recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'throw-ma-1' }); + expect(native.recorded).toContainEqual({ cmd: 'stopAudio' }); + expect(useCallStore.getState().call).toBeNull(); + expect(callEndedListener).toHaveBeenCalledTimes(1); + }); + + it('continues teardown when native.call.markAvailable throws', async () => { + const call = makeCall({ callId: 'throw-mv-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + jest.spyOn(native.call, 'markAvailable').mockImplementationOnce(() => { + throw new Error('markAvailable boom'); + }); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + await expect(callLifecycle.end('local')).resolves.toBeUndefined(); + + unsub(); + + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'throw-mv-1' }); + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect(native.recorded).toContainEqual({ cmd: 'stopAudio' }); + expect(useCallStore.getState().call).toBeNull(); + expect(callEndedListener).toHaveBeenCalledTimes(1); + }); + + it('continues teardown when useCallStore.reset throws', async () => { + const call = makeCall({ callId: 'throw-reset-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + const resetSpy = jest.spyOn(useCallStore.getState(), 'reset').mockImplementationOnce(() => { + throw new Error('reset boom'); + }); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + await expect(callLifecycle.end('local')).resolves.toBeUndefined(); + + unsub(); + resetSpy.mockRestore(); + + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'throw-reset-1' }); + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect(native.recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'throw-reset-1' }); + expect(native.recorded).toContainEqual({ cmd: 'stopAudio' }); + expect(callEndedListener).toHaveBeenCalledTimes(1); + }); + + it('continues teardown when native.call.stopAudio throws', async () => { + const call = makeCall({ callId: 'throw-stop-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + jest.spyOn(native.call, 'stopAudio').mockImplementationOnce(() => { + throw new Error('stopAudio boom'); + }); + + const callEndedListener = jest.fn(); + const unsub = callLifecycle.emitter.on('callEnded', callEndedListener); + + await expect(callLifecycle.end('local')).resolves.toBeUndefined(); + + unsub(); + + expect(native.recorded).toContainEqual({ cmd: 'end', callUuid: 'throw-stop-1' }); + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: '' }); + expect(native.recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'throw-stop-1' }); + expect(useCallStore.getState().call).toBeNull(); + // callEnded MUST still emit even though stopAudio threw. + expect(callEndedListener).toHaveBeenCalledTimes(1); + }); + }); + + // CodeRabbit follow-up: emit() must isolate per-listener throws so a single + // failing subscriber neither aborts later listeners nor propagates up to + // _runTeardown and rejects the _endPromise after teardown already finished. + describe('callEnded listener throw isolation', () => { + it('await end() resolves and remaining listeners still fire when an earlier listener throws', async () => { + const call = makeCall({ callId: 'listener-throw-1', state: 'active' }); + useCallStore.getState().setCall(call); + native.reset(); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + const throwingListener = jest.fn(() => { + throw new Error('listener boom'); + }); + const survivingListener = jest.fn(); + + const unsub1 = callLifecycle.emitter.on('callEnded', throwingListener); + const unsub2 = callLifecycle.emitter.on('callEnded', survivingListener); + + // 1. end() must resolve, not reject — teardown completed before emit. + await expect(callLifecycle.end('local')).resolves.toBeUndefined(); + + unsub1(); + unsub2(); + + // 2. Both listeners ran; the throw did not abort the loop. + expect(throwingListener).toHaveBeenCalledTimes(1); + expect(survivingListener).toHaveBeenCalledTimes(1); + expect(survivingListener).toHaveBeenCalledWith(expect.objectContaining({ callId: 'listener-throw-1', reason: 'local' })); + + // 3. The failure was logged via logger.warn → console.warn. + expect(warnSpy).toHaveBeenCalled(); + const warnCalls = warnSpy.mock.calls.map(args => String(args[0] ?? '')); + expect(warnCalls.some(msg => msg.includes('callEnded listener failed'))).toBe(true); + + warnSpy.mockRestore(); + }); + }); +}); diff --git a/app/lib/services/voip/CallLifecycle.ts b/app/lib/services/voip/CallLifecycle.ts new file mode 100644 index 00000000000..0b23776b91f --- /dev/null +++ b/app/lib/services/voip/CallLifecycle.ts @@ -0,0 +1,198 @@ +/** + * CallLifecycle — orchestrates the end-of-call teardown sequence. + * + * Teardown order (documented here and verified in tests): + * 1. mediaCall.reject() if state === 'ringing', else mediaCall.hangup() + * 2. voipNative.call.end(callUuid) + * 3. voipNative.call.markActive('') + * 4. voipNative.call.markAvailable(callUuid) + * 5. useCallStore.reset() ← clears JS state; stopAudio removed from here (step 6 owns it) + * 6. voipNative.call.stopAudio() ← fires after store reset so subscribers see consistent state + * 7. emit callEnded { callId, reason } + * + * Idempotency: concurrent callers receive the in-flight Promise (no double teardown). + * + * `callId` in the `callEnded` event uses `callId ?? nativeAcceptedCallId` (Pre-bind-safe). + */ + +import { MediaCallLogger } from './MediaCallLogger'; +import { voipNative, type VoipNativePort } from './VoipNative'; +import { useCallStore } from './useCallStore'; + +const logger = new MediaCallLogger(); +const TAG = '[CallLifecycle]'; + +// ── Event types ─────────────────────────────────────────────────────────────── + +export type CallEndReason = 'local' | 'remote' | 'rejected' | 'error' | 'cleanup'; // 'cleanup' reserved for slice 08 Pre-bind FSM cleanupAt elapse + +export type CallEndedEvent = { + callId: string | null; + reason: CallEndReason; +}; + +export type CallBeganEvent = { + callId: string; +}; + +export type PreBindFailedEvent = { + callId: string | null; +}; + +export type CallLifecycleListener = (event: T) => void; + +type EventMap = { + callBegan: CallBeganEvent; // type-only — no producer in this slice + callEnded: CallEndedEvent; + preBindFailed: PreBindFailedEvent; // type-only — no producer in this slice +}; + +// ── Typed event emitter ─────────────────────────────────────────────────────── + +class CallLifecycleEmitter { + private _listeners: { [K in keyof EventMap]?: Set> } = {}; + + on(event: K, listener: CallLifecycleListener): () => void { + if (!this._listeners[event]) { + (this._listeners as any)[event] = new Set(); + } + (this._listeners[event] as Set>).add(listener); + return () => this.off(event, listener); + } + + off(event: K, listener: CallLifecycleListener): void { + (this._listeners[event] as Set> | undefined)?.delete(listener); + } + + emit(event: K, payload: EventMap[K]): void { + const set = this._listeners[event] as Set> | undefined; + if (!set) return; + // Snapshot the set before iterating so listeners can safely add/remove + // other listeners mid-emit. Wrap each invocation in try/catch so a + // throwing listener does not skip subsequent listeners or propagate up + // to `_runTeardown` and reject the `_endPromise` after teardown completed. + for (const listener of [...set]) { + try { + listener(payload); + } catch (error) { + logger.warn(`${TAG} ${String(event)} listener failed; continuing emit`, error); + } + } + } +} + +// ── CallLifecycle ───────────────────────────────────────────────────────────── + +class CallLifecycle { + /** Typed event emitter for lifecycle events. */ + readonly emitter = new CallLifecycleEmitter(); + + /** + * Optional override for the native seam — defaults to the module-level `voipNative` singleton. + * Use `attach()` to inject a custom adapter (e.g., a test double). + */ + private _voipNativeOverride: VoipNativePort | null = null; + + /** Re-entry guard: in-flight teardown promise, or null when idle. */ + private _endPromise: Promise | null = null; + + /** + * Attach a custom native seam (optional). If not called, the module-level + * `voipNative` singleton is used. Call once per session for explicit injection. + * + * The active MediaCall is read directly from useCallStore.getState().call — + * MediaSessionInstance remains the owner; CallLifecycle only reads it. + */ + attach(nativeOverride: VoipNativePort): void { + this._voipNativeOverride = nativeOverride; + } + + /** + * End the current call with the given reason. + * + * Idempotent: if a teardown is already in progress, concurrent callers + * receive the same in-flight Promise (one observable teardown sequence). + * + * Returns a Promise that resolves when teardown is complete. + */ + end(reason: CallEndReason): Promise { + if (this._endPromise) { + // Concurrent caller — share the in-flight teardown. + return this._endPromise; + } + // Defer the teardown body to a microtask so `_endPromise` is assigned BEFORE + // `_runTeardown` runs. This guarantees that any synchronous re-entry from + // inside teardown (e.g. mediaCall.hangup() emits 'ended' synchronously and + // useCallStore's handleEnded re-calls callLifecycle.end('remote')) hits the + // guard above and shares the in-flight promise instead of starting a second + // teardown. See @rocket.chat/media-signaling Call.js line 703. + this._endPromise = Promise.resolve() + .then(() => this._runTeardown(reason)) + .finally(() => { + this._endPromise = null; + }); + return this._endPromise; + } + + // eslint-disable-next-line require-await + private async _runTeardown(reason: CallEndReason): Promise { + // Use explicit override if provided, otherwise fall back to the module-level singleton. + const native = this._voipNativeOverride ?? voipNative; + + const { callId, nativeAcceptedCallId } = useCallStore.getState(); + // Pre-bind-safe: use whichever id is available. + const effectiveCallId = callId ?? nativeAcceptedCallId; + + // `safe` wraps each teardown step so that a throw is logged but does not + // abort the rest of the sequence. Without this, a single failure (e.g. + // native.call.end throwing) would skip subsequent steps and leak the + // `callEnded` emit, leaving listeners subscribed and native state stale. + const safe = (label: string, fn: () => void) => { + try { + fn(); + } catch (error) { + logger.warn(`${TAG} ${label} failed; continuing teardown`, error); + } + }; + + // Step 1: Hang up the MediaCall (reject if ringing, hangup otherwise). + // Read the active call from useCallStore — MediaSessionInstance owns it. + const mediaCall = useCallStore.getState().call; + if (mediaCall) { + const isRinging = (mediaCall as any).state === 'ringing'; + safe(`mediaCall.${isRinging ? 'reject' : 'hangup'}`, () => { + if (isRinging) { + mediaCall.reject(); + } else { + mediaCall.hangup(); + } + }); + } + + // Step 2: End the native CallKit / Telecom session. + if (effectiveCallId) { + safe('native.call.end', () => native.call.end(effectiveCallId)); + } + + // Step 3: Clear the "active" indicator in the native UI. + safe('native.call.markActive', () => native.call.markActive('')); + + // Step 4: Mark the device as available for new calls. + safe('native.call.markAvailable', () => native.call.markAvailable(effectiveCallId ?? '')); + + // Step 5: Reset JS call state (store clears call, callId, etc.). + // NOTE: stopAudio is intentionally NOT called here — step 6 owns it so + // that all subscribers see consistent JS state when callEnded emits. + safe('useCallStore.reset', () => useCallStore.getState().reset()); + + // Step 6: Stop audio after store is cleared. + safe('native.call.stopAudio', () => native.call.stopAudio()); + + // Step 7: Notify subscribers. + this.emitter.emit('callEnded', { callId: effectiveCallId, reason }); + } +} + +// ── Singleton ───────────────────────────────────────────────────────────────── + +export const callLifecycle = new CallLifecycle(); diff --git a/app/lib/services/voip/CallNavRouter.test.ts b/app/lib/services/voip/CallNavRouter.test.ts new file mode 100644 index 00000000000..621bb8ebb2a --- /dev/null +++ b/app/lib/services/voip/CallNavRouter.test.ts @@ -0,0 +1,212 @@ +/** + * CallNavRouter.test.ts + * + * Tests for CallNavRouter: + * - On callEnded when current route is CallView → Navigation.back() called + * - On callEnded when current route is NOT CallView → Navigation.back() NOT called + * - Subscription happens only after navigationReady emits + * - Multiple mount() calls are idempotent + */ + +import { callLifecycle } from './CallLifecycle'; +import { CallNavRouter } from './CallNavRouter'; +import { emitter } from '../../methods/helpers'; +import Navigation from '../../navigation/appNavigation'; + +// Mock navigation BEFORE importing the module under test. +// jest.mock is auto-hoisted so it runs before the imports above resolve. +const mockGetCurrentRoute = jest.fn(); +const mockBack = jest.fn(); + +jest.mock('../../navigation/appNavigation', () => ({ + __esModule: true, + default: { + back: (...args: unknown[]) => mockBack(...args), + getCurrentRoute: (...args: unknown[]) => mockGetCurrentRoute(...args), + // Start with no navigation ref (not ready). + navigationRef: { current: null } + }, + waitForNavigationReady: jest.fn().mockResolvedValue(undefined) +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function setNavigationRef(ready: boolean): void { + (Navigation.navigationRef as any).current = ready ? {} : null; +} + +function emitNavigationReady(): void { + emitter.emit('navigationReady', undefined); +} + +function emitCallEnded(callId: string | null = 'test-call'): void { + callLifecycle.emitter.emit('callEnded', { callId, reason: 'local' }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('CallNavRouter', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Reset the router between tests. + CallNavRouter.unmount(); + // Default: navigation not yet ready. + setNavigationRef(false); + }); + + afterEach(() => { + CallNavRouter.unmount(); + }); + + describe('subscription after navigationReady', () => { + it('does not call back before navigationReady fires', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + CallNavRouter.mount(); + + // Emit callEnded before nav is ready — should be ignored. + emitCallEnded(); + + expect(mockBack).not.toHaveBeenCalled(); + }); + + it('calls back when callEnded fires AFTER navigationReady (on CallView route)', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + CallNavRouter.mount(); + + // Navigation becomes ready. + emitNavigationReady(); + + // callEnded fires. + emitCallEnded(); + + expect(mockBack).toHaveBeenCalledTimes(1); + }); + + it('subscribes immediately if navigationRef.current is already set at mount time', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + setNavigationRef(true); + + CallNavRouter.mount(); + + // No navigationReady needed — should already be subscribed. + emitCallEnded(); + + expect(mockBack).toHaveBeenCalledTimes(1); + }); + }); + + describe('navigation guard on callEnded', () => { + beforeEach(() => { + CallNavRouter.mount(); + emitNavigationReady(); + }); + + it('calls Navigation.back() when current route is CallView', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + + emitCallEnded(); + + expect(mockBack).toHaveBeenCalledTimes(1); + }); + + it('does NOT call Navigation.back() when current route is NOT CallView', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'RoomsListView' }); + + emitCallEnded(); + + expect(mockBack).not.toHaveBeenCalled(); + }); + + it('does NOT call Navigation.back() when getCurrentRoute returns undefined', () => { + mockGetCurrentRoute.mockReturnValue(undefined); + + emitCallEnded(); + + expect(mockBack).not.toHaveBeenCalled(); + }); + + it('does NOT call Navigation.back() when getCurrentRoute returns null', () => { + mockGetCurrentRoute.mockReturnValue(null); + + emitCallEnded(); + + expect(mockBack).not.toHaveBeenCalled(); + }); + + it('calls back once per callEnded event', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + + emitCallEnded('call-a'); + emitCallEnded('call-b'); + + // Two callEnded events → two back() calls (different calls). + expect(mockBack).toHaveBeenCalledTimes(2); + }); + }); + + describe('mount() idempotency', () => { + it('multiple mount() calls do not cause duplicate back() calls', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + + CallNavRouter.mount(); + CallNavRouter.mount(); + CallNavRouter.mount(); + + emitNavigationReady(); + emitCallEnded(); + + // Only one back() call despite multiple mount() calls. + expect(mockBack).toHaveBeenCalledTimes(1); + }); + }); + + describe('unmount()', () => { + it('stops responding to callEnded after unmount()', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + CallNavRouter.mount(); + emitNavigationReady(); + + CallNavRouter.unmount(); + + emitCallEnded(); + + expect(mockBack).not.toHaveBeenCalled(); + }); + + it('can be re-mounted after unmount', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + + CallNavRouter.mount(); + emitNavigationReady(); + CallNavRouter.unmount(); + + // Re-mount and re-subscribe. + CallNavRouter.mount(); + emitNavigationReady(); + emitCallEnded(); + + expect(mockBack).toHaveBeenCalledTimes(1); + }); + + // Regression: deferred navigationReady listener was leaking when unmount + // happened before navigationReady fired. The listener stayed alive and + // could re-subscribe callEnded later, causing routing while "unmounted". + it('does not subscribe callEnded if unmount() runs before navigationReady fires', () => { + mockGetCurrentRoute.mockReturnValue({ name: 'CallView' }); + // Nav not ready at mount time. + CallNavRouter.mount(); + + // Unmount BEFORE navigationReady is emitted. + CallNavRouter.unmount(); + + // Now nav becomes ready — the deferred listener (if leaked) would + // subscribe callEnded here, causing the next emission to call back(). + emitNavigationReady(); + + // callEnded should be ignored — router is unmounted. + emitCallEnded(); + + expect(mockBack).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/lib/services/voip/CallNavRouter.ts b/app/lib/services/voip/CallNavRouter.ts new file mode 100644 index 00000000000..a1a110a18aa --- /dev/null +++ b/app/lib/services/voip/CallNavRouter.ts @@ -0,0 +1,69 @@ +/** + * CallNavRouter — subscribes to CallLifecycle events and handles post-call navigation. + * + * Subscribes ONLY after the NavigationContainer is ready (listens for the + * `navigationReady` emitter event fired from AppContainer.tsx onReady). + * + * On `callEnded`: if the current route is `CallView`, calls `Navigation.goBack()`. + * + * Mount point: AppContainer.tsx (after NavigationContainer renders). + */ + +import Navigation from '../../navigation/appNavigation'; +import { emitter } from '../../methods/helpers'; +import { callLifecycle } from './CallLifecycle'; + +let _unsubscribeCallEnded: (() => void) | null = null; +let _unsubscribeNavigationReady: (() => void) | null = null; +let _mounted = false; + +/** + * Mount the router. Should be called once from AppContainer (or equivalent). + * Safe to call multiple times — subsequent calls are no-ops. + */ +function mount(): void { + if (_mounted) return; + _mounted = true; + + // Wait for NavigationContainer to be ready before subscribing. + // The `navigationReady` event is emitted from AppContainer.tsx onReady(). + function onNavigationReady(): void { + // Unsubscribe previous callEnded listener if somehow re-mounted. + _unsubscribeCallEnded?.(); + + _unsubscribeCallEnded = callLifecycle.emitter.on('callEnded', () => { + const currentRoute = Navigation.getCurrentRoute(); + if (currentRoute?.name === 'CallView') { + Navigation.back(); + } + }); + } + + // If navigation is already ready (e.g., hot-reload), subscribe immediately. + if (Navigation.navigationRef.current) { + onNavigationReady(); + } else { + // mitt does not have `once`; implement it manually. + const onceNavigationReady = () => { + _unsubscribeNavigationReady?.(); + _unsubscribeNavigationReady = null; + onNavigationReady(); + }; + emitter.on('navigationReady', onceNavigationReady); + _unsubscribeNavigationReady = () => emitter.off('navigationReady', onceNavigationReady); + } +} + +/** + * Unmount the router. Cleans up event listeners. + * Useful for testing or if the router needs to be reset. + */ +function unmount(): void { + _unsubscribeNavigationReady?.(); + _unsubscribeNavigationReady = null; + _unsubscribeCallEnded?.(); + _unsubscribeCallEnded = null; + _mounted = false; +} + +export const CallNavRouter = { mount, unmount }; diff --git a/app/lib/services/voip/MediaCallEvents.ios.test.ts b/app/lib/services/voip/MediaCallEvents.ios.test.ts index 4f22b9b00be..d782f2e43a9 100644 --- a/app/lib/services/voip/MediaCallEvents.ios.test.ts +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -1,59 +1,47 @@ /** * @jest-environment node * - * iOS-only paths: isIOS = true, NativeEventEmitter for VoIP events, CallKit listeners. + * iOS-only paths: isIOS = true, pushTokenRegistered, mute, endCall. */ -import RNCallKeep from 'react-native-callkeep'; import type { VoipPayload } from '../../../definitions/Voip'; -import NativeVoipModule from '../../native/NativeVoip'; import { registerPushToken } from '../restApi'; -import { - getInitialMediaCallEvents, - resetMediaCallEventsStateForTesting, - setupMediaCallEvents, - type MediaCallEventsAdapters -} from './MediaCallEvents'; +import { createVoipEventDispatcher, type MediaCallEventsAdapters } from './MediaCallEvents'; import { useCallStore } from './useCallStore'; -/** Shared bucket for NativeEventEmitter / DeviceEventEmitter VoIP listeners (Jest allows `mock*` refs inside factories). */ -const mockNativeVoipListeners: Record void)[]> = {}; - -/** Factory: returns an addListener implementation that stores listeners in `bucket`. - * Named with `mock` prefix so Jest factory scope rules allow it inside jest.mock() calls. */ -function mockMakeAddListener(bucket: Record void)[]>) { - return (eventType: string, listener: (payload: unknown) => void) => { - bucket[eventType] = bucket[eventType] || []; - bucket[eventType].push(listener); - return { - remove() { - const list = bucket[eventType]; - if (!list) { - return; - } - const idx = list.indexOf(listener); - if (idx >= 0) { - list.splice(idx, 1); - } - } - }; - }; -} - -/** Minimal RN surface for MediaCallEvents — avoid `requireActual('react-native')` in @jest-environment node. - * addListener is defined as a method (not a field initializer) so that mockNativeVoipListeners is - * captured lazily at call time rather than eagerly when the mock factory / class field runs. */ jest.mock('react-native', () => ({ Platform: { OS: 'ios' }, - DeviceEventEmitter: { - addListener(eventType: string, listener: (payload: unknown) => void) { - return mockMakeAddListener(mockNativeVoipListeners)(eventType, listener); - } - }, + DeviceEventEmitter: { addListener: jest.fn(() => ({ remove: jest.fn() })) }, NativeEventEmitter: class { - addListener(eventType: string, listener: (payload: unknown) => void) { - return mockMakeAddListener(mockNativeVoipListeners)(eventType, listener); - } + addListener = jest.fn(() => ({ remove: jest.fn() })); + } +})); + +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + clearInitialEvents: jest.fn(), + setCurrentCallActive: jest.fn(), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); +jest.mock('react-native-webrtc', () => ({ registerGlobals: jest.fn() })); +jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn() } +})); +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + registerVoipToken: jest.fn(), + getInitialEvents: jest.fn(() => null), + clearInitialEvents: jest.fn(), + getLastVoipToken: jest.fn(() => ''), + stopNativeDDPClient: jest.fn(), + stopVoipCallService: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn() } })); @@ -106,20 +94,24 @@ jest.mock('./MediaCallLogger', () => { }; }); -const mockAddEventListener = jest.fn(); - -jest.mock('react-native-callkeep', () => ({ - __esModule: true, - default: { - addEventListener: (...args: unknown[]) => mockAddEventListener(...args), - clearInitialEvents: jest.fn(), - setCurrentCallActive: jest.fn(), - getInitialEvents: jest.fn(() => Promise.resolve([])) +jest.mock('./VoipNative', () => ({ + ...jest.requireActual('./VoipNative'), + voipNative: { + call: { + markActive: jest.fn(), + end: jest.fn(), + markAvailable: jest.fn(), + setSpeaker: jest.fn(), + startAudio: jest.fn(), + stopAudio: jest.fn() + }, + attach: jest.fn() } })); const mockOnOpenDeepLink = jest.fn(); const mockServerSelector = jest.fn(() => 'https://workspace-ios.example.com'); +const mockSetNativeAcceptedCallId = jest.fn(); function makeTestAdapters(): MediaCallEventsAdapters { return { @@ -128,28 +120,6 @@ function makeTestAdapters(): MediaCallEventsAdapters { }; } -function emitNativeVoipEvent(eventType: string, payload: unknown): void { - mockNativeVoipListeners[eventType]?.forEach(fn => { - fn(payload); - }); -} - -function getEndCallHandler(): (payload: { callUUID: string }) => void { - const call = mockAddEventListener.mock.calls.find(([name]) => name === 'endCall'); - if (!call) { - throw new Error('endCall listener not registered'); - } - return call[1] as (payload: { callUUID: string }) => void; -} - -function getMuteHandler(): (p: { muted: boolean; callUUID: string }) => void { - const call = mockAddEventListener.mock.calls.find(([name]) => name === 'didPerformSetMutedCallAction'); - if (!call) { - throw new Error('didPerformSetMutedCallAction listener not registered'); - } - return call[1] as (p: { muted: boolean; callUUID: string }) => void; -} - function buildIncomingPayload(overrides: Partial = {}): VoipPayload { return { callId: 'ios-call-uuid', @@ -169,186 +139,120 @@ const activeCallBase = { nativeAcceptedCallId: null as string | null }; -describe('setupMediaCallEvents — didPerformSetMutedCallAction (iOS)', () => { +describe('createVoipEventDispatcher — mute (iOS)', () => { const toggleMute = jest.fn(); const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); toggleMute.mockClear(); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute }); }); - it('registers didPerformSetMutedCallAction via RNCallKeep.addEventListener', () => { - setupMediaCallEvents(makeTestAdapters()); - expect(mockAddEventListener).toHaveBeenCalledWith('didPerformSetMutedCallAction', expect.any(Function)); - }); - it('calls toggleMute when muted state differs from OS and UUIDs match', () => { - setupMediaCallEvents(makeTestAdapters()); - getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', 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(makeTestAdapters()); - getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); expect(toggleMute).not.toHaveBeenCalled(); }); it('drops event when callUUID does not match active call id', () => { - setupMediaCallEvents(makeTestAdapters()); - getMuteHandler()({ muted: true, callUUID: 'uuid-2' }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', 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(makeTestAdapters()); - getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); + getState.mockReturnValue({ call: null, callId: 'uuid-1', nativeAcceptedCallId: null, isMuted: false, toggleMute }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); expect(toggleMute).not.toHaveBeenCalled(); }); }); -describe('setupMediaCallEvents — VoipPushTokenRegistered (iOS)', () => { +describe('createVoipEventDispatcher — pushTokenRegistered (iOS)', () => { const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); getState.mockReturnValue({}); }); - it('registers push token with no arguments when native emits VoipPushTokenRegistered', async () => { - setupMediaCallEvents(makeTestAdapters()); - emitNativeVoipEvent('VoipPushTokenRegistered', { token: 'voip-token-xyz' }); + it('registers push token when pushTokenRegistered event fires', async () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'pushTokenRegistered', token: 'voip-token-xyz' }); await Promise.resolve(); expect(registerPushToken).toHaveBeenCalledWith(); }); - it('calls debug() with the token but NOT log() when VoipPushTokenRegistered is fired', () => { - setupMediaCallEvents(makeTestAdapters()); - emitNativeVoipEvent('VoipPushTokenRegistered', { token: 'voip-token-sensitive' }); + it('calls debug() with the token when pushTokenRegistered fires', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'pushTokenRegistered', token: 'voip-token-sensitive' }); - // Access the mock functions that were passed to the MediaCallLogger class const { __mockDebug, __mockLog } = jest.requireMock('./MediaCallLogger'); - - // debug() must be called (sensitive data goes through debug level) expect(__mockDebug).toHaveBeenCalledWith(expect.stringContaining('Registered VoIP push token:'), 'voip-token-sensitive'); - // log() must NOT be called (sensitive data must not reach ungated log()) expect(__mockLog).not.toHaveBeenCalled(); }); }); -describe('getInitialMediaCallEvents — iOS cold start', () => { +describe('createVoipEventDispatcher — acceptSucceeded (iOS)', () => { const getState = useCallStore.getState as jest.Mock; - const mockSetNativeAcceptedCallId = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); - (NativeVoipModule.getInitialEvents as jest.Mock).mockReset(); - (RNCallKeep.getInitialEvents as jest.Mock).mockReset(); getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); }); - it('returns true and applies REST signals when CallKit shows answered and host matches workspace', async () => { + it('applies REST signals and returns true for iOS cold-start same-workspace', () => { const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); const callId = 'answered-ios-uuid'; mockServerSelector.mockReturnValue('https://same.example.com'); - (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( - buildIncomingPayload({ - callId, - host: 'https://same.example.com' - }) - ); - (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([ - { name: 'RNCallKeepPerformAnswerCallAction', data: { callUUID: callId } } - ]); - - const result = await getInitialMediaCallEvents(makeTestAdapters()); - - expect(result).toBe(true); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId, host: 'https://same.example.com' }); + + const handled = dispatch({ type: 'acceptSucceeded', payload, fromColdStart: true }); + + expect(handled).toBe(true); expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith(callId); expect(mediaSessionInstance.applyRestStateSignals).toHaveBeenCalled(); expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); }); - it('returns true and opens deep link when answered on cold start but host differs from workspace', async () => { - const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); + it('opens deep link for iOS cold-start cross-workspace', () => { const callId = 'answered-cross-ws'; mockServerSelector.mockReturnValue('https://workspace-ios.example.com'); - (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( - buildIncomingPayload({ - callId, - host: 'https://foreign.example.com' - }) - ); - (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([ - { name: 'RNCallKeepPerformAnswerCallAction', data: { callUUID: callId } } - ]); - - const result = await getInitialMediaCallEvents(makeTestAdapters()); - - expect(result).toBe(true); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith(callId); - expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ - callId, - host: 'https://foreign.example.com' - }); - expect(mediaSessionInstance.applyRestStateSignals).not.toHaveBeenCalled(); - }); - - it('returns false when CallKit initial events have no RNCallKeepPerformAnswerCallAction', async () => { - const callId = 'unanswered-ios-uuid'; - (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( - buildIncomingPayload({ callId, host: 'https://workspace-ios.example.com' }) - ); - (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([ - { name: 'RNCallKeepDidDisplayIncomingCall', data: { callUUID: callId } } - ]); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId, host: 'https://foreign.example.com' }); - const result = await getInitialMediaCallEvents(makeTestAdapters()); + const handled = dispatch({ type: 'acceptSucceeded', payload, fromColdStart: true }); - expect(result).toBe(false); - expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); - expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); + expect(handled).toBe(true); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith(callId); + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ callId, host: 'https://foreign.example.com' }); }); }); -describe('setupMediaCallEvents — endCall clears accept dedupe (iOS)', () => { +describe('createVoipEventDispatcher — endCall clears dispatcher on iOS', () => { const getState = useCallStore.getState as jest.Mock; - const mockSetNativeAcceptedCallId = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); }); - it('allows a second VoipAcceptSucceeded with the same callId after endCall', () => { - setupMediaCallEvents(makeTestAdapters()); + it('allows a second acceptSucceeded with same callId after endCall (dedupe lives in adapter, not dispatcher)', () => { + // The dispatcher itself has no deduplication — that is handled by VoipNative adapter. + // Two dispatches with same callId are both processed. + const dispatch = createVoipEventDispatcher(makeTestAdapters()); const payload = buildIncomingPayload({ callId: 'reuse-id', host: 'https://foreign.example.com' }); - emitNativeVoipEvent('VoipAcceptSucceeded', payload); - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); - getEndCallHandler()({ callUUID: 'any' }); - emitNativeVoipEvent('VoipAcceptSucceeded', payload); + dispatch({ type: 'acceptSucceeded', payload, fromColdStart: false }); + dispatch({ type: 'acceptSucceeded', payload, fromColdStart: false }); expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(2); }); }); diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index c61c61315dd..8c202fd4efb 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -1,27 +1,41 @@ -import { DeviceEventEmitter } from 'react-native'; -import RNCallKeep from 'react-native-callkeep'; - import type { VoipPayload } from '../../../definitions/Voip'; -import NativeVoipModule from '../../native/NativeVoip'; -import { - getInitialMediaCallEvents, - resetMediaCallEventsStateForTesting, - setupMediaCallEvents, - type MediaCallEventsAdapters -} from './MediaCallEvents'; +import { createVoipEventDispatcher, type MediaCallEventsAdapters } from './MediaCallEvents'; import { useCallStore } from './useCallStore'; -const mockOnOpenDeepLink = jest.fn(); -const mockSetNativeAcceptedCallId = jest.fn(); -const mockAddEventListener = jest.fn(); -const mockRNCallKeepClearInitialEvents = jest.fn(); -const mockSetCurrentCallActive = jest.fn(); +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); +jest.mock('react-native-webrtc', () => ({ registerGlobals: jest.fn() })); +jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn() } +})); +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + registerVoipToken: jest.fn(), + getInitialEvents: jest.fn(() => null), + clearInitialEvents: jest.fn(), + getLastVoipToken: jest.fn(() => ''), + stopNativeDDPClient: jest.fn(), + stopVoipCallService: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn() + } +})); jest.mock('../../methods/helpers', () => ({ ...jest.requireActual('../../methods/helpers'), isIOS: false })); +const mockOnOpenDeepLink = jest.fn(); +const mockSetNativeAcceptedCallId = jest.fn(); const mockServerSelector = jest.fn(() => 'https://workspace-a.example.com'); function makeTestAdapters(): MediaCallEventsAdapters { @@ -37,24 +51,6 @@ jest.mock('./useCallStore', () => ({ } })); -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(), @@ -62,6 +58,13 @@ jest.mock('./MediaSessionInstance', () => ({ } })); +jest.mock('./CallLifecycle', () => ({ + callLifecycle: { + end: jest.fn(() => Promise.resolve()), + emitter: { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + } +})); + jest.mock('../restApi', () => ({ registerPushToken: jest.fn(() => Promise.resolve()) })); @@ -75,6 +78,21 @@ jest.mock('./MediaCallLogger', () => ({ } })); +jest.mock('./VoipNative', () => ({ + ...jest.requireActual('./VoipNative'), + voipNative: { + call: { + markActive: jest.fn(), + end: jest.fn(), + markAvailable: jest.fn(), + setSpeaker: jest.fn(), + startAudio: jest.fn(), + stopAudio: jest.fn() + }, + attach: jest.fn() + } +})); + function buildIncomingPayload(overrides: Partial = {}): VoipPayload { return { callId: 'call-b-uuid', @@ -88,381 +106,318 @@ function buildIncomingPayload(overrides: Partial = {}): VoipPayload }; } -function getToggleHoldHandler(): (payload: { hold: boolean; callUUID: string }) => void { - const call = mockAddEventListener.mock.calls.find(([name]) => name === 'didToggleHoldCallAction'); - if (!call) { - throw new Error('didToggleHoldCallAction listener not registered'); - } - return call[1] as (payload: { hold: boolean; callUUID: string }) => void; -} - -/** Minimal store slice: handler only runs hold logic when call + matching callId/native id exist. */ const activeCallBase = { call: {} as object, callId: 'uuid-1', nativeAcceptedCallId: null as string | null }; -describe('MediaCallEvents cross-server accept (slice 3)', () => { +describe('createVoipEventDispatcher — acceptSucceeded (Android)', () => { const getState = useCallStore.getState as jest.Mock; - describe('VoipAccept via setupMediaCallEvents', () => { - let teardown: (() => void) | undefined; + beforeEach(() => { + jest.clearAllMocks(); + getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); + }); - beforeEach(() => { - jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); - (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue(null); - getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); - teardown = setupMediaCallEvents(makeTestAdapters()); - }); + it('sets nativeAcceptedCallId and opens deep link for cross-workspace incoming_call', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId: 'workspace-b-call', host: 'https://workspace-b.open.rocket.chat' }); - afterEach(() => { - teardown?.(); - teardown = undefined; - }); + const handled = dispatch({ type: 'acceptSucceeded', payload, fromColdStart: false }); - describe('VoipAcceptSucceeded', () => { - it('sets nativeAcceptedCallId and opens deep link with host and callId for incoming_call', () => { - const payload = buildIncomingPayload({ - callId: 'workspace-b-call', - host: 'https://workspace-b.open.rocket.chat' - }); - - DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); - - expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalledTimes(1); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('workspace-b-call'); - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); - expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ - callId: 'workspace-b-call', - host: 'https://workspace-b.open.rocket.chat' - }); - }); - - it('skips deep link open and replays REST state signals when host matches active workspace', () => { - const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); - mockServerSelector.mockReturnValueOnce('https://workspace-a.example.com'); - const payload = buildIncomingPayload({ - callId: 'same-ws-call', - host: 'https://workspace-a.example.com' - }); - - DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); - - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('same-ws-call'); - expect(mediaSessionInstance.applyRestStateSignals).toHaveBeenCalledTimes(1); - expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); - }); - - it('does not open deep link or set native id when type is not incoming_call', () => { - DeviceEventEmitter.emit( - 'VoipAcceptSucceeded', - buildIncomingPayload({ - callId: 'outgoing-payload-id', - type: 'outgoing_call' - }) - ); - - expect(NativeVoipModule.clearInitialEvents).not.toHaveBeenCalled(); - expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); - expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); - }); - - it('dedupes duplicate VoipAcceptSucceeded for the same callId (idempotent native delivery)', () => { - const payload = buildIncomingPayload({ callId: 'dedupe-id' }); - - DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); - DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); - - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); - }); - }); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('workspace-b-call'); + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ callId: 'workspace-b-call', host: 'https://workspace-b.open.rocket.chat' }); + expect(handled).toBe(true); + }); + + it('replays REST signals when host matches active workspace (live)', () => { + const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); + mockServerSelector.mockReturnValueOnce('https://workspace-a.example.com'); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId: 'same-ws-call', host: 'https://workspace-a.example.com' }); + + dispatch({ type: 'acceptSucceeded', payload, fromColdStart: false }); + + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('same-ws-call'); + expect(mediaSessionInstance.applyRestStateSignals).toHaveBeenCalledTimes(1); + expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); + }); + + it('returns false and skips handler when type is not incoming_call', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); - describe('VoipAcceptFailed', () => { - it('opens deep link with voipAcceptFailed after native failure event', () => { - DeviceEventEmitter.emit( - 'VoipAcceptFailed', - buildIncomingPayload({ - callId: 'failed-b', - host: 'https://workspace-b.example.com', - username: 'remote-user' - }) - ); - - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); - expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ - host: 'https://workspace-b.example.com', - callId: 'failed-b', - username: 'remote-user', - voipAcceptFailed: true - }); - expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalled(); - }); - - it('dedupes duplicate VoipAcceptFailed delivery for the same callId', () => { - const raw = buildIncomingPayload({ callId: 'fail-dedupe' }); - - DeviceEventEmitter.emit('VoipAcceptFailed', raw); - DeviceEventEmitter.emit('VoipAcceptFailed', raw); - - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); - }); - - it('allows a second failure delivery for the same callId after resetMediaCallEventsStateForTesting', () => { - const raw = buildIncomingPayload({ callId: 'fail-reset' }); - DeviceEventEmitter.emit('VoipAcceptFailed', raw); - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); - resetMediaCallEventsStateForTesting(); - DeviceEventEmitter.emit('VoipAcceptFailed', raw); - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(2); - }); + const handled = dispatch({ + type: 'acceptSucceeded', + payload: buildIncomingPayload({ callId: 'outgoing-id', type: 'outgoing_call' }), + fromColdStart: false }); + + expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); + expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); + expect(handled).toBe(false); + }); + + it('returns false for Android cold-start same workspace (lets appInit run)', () => { + mockServerSelector.mockReturnValueOnce('https://workspace-a.example.com'); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId: 'android-cold', host: 'https://workspace-a.example.com' }); + + const handled = dispatch({ type: 'acceptSucceeded', payload, fromColdStart: true }); + + expect(handled).toBe(false); }); - describe('getInitialMediaCallEvents', () => { - beforeEach(() => { - jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); - mockRNCallKeepClearInitialEvents.mockClear(); - (NativeVoipModule.getInitialEvents as jest.Mock).mockReset(); - (NativeVoipModule.clearInitialEvents as jest.Mock).mockClear(); - (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([]); - getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); + it('returns true for cold-start cross-workspace', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId: 'cold-b', host: 'https://workspace-b.example.com' }); + + const handled = dispatch({ type: 'acceptSucceeded', payload, fromColdStart: true }); + + expect(handled).toBe(true); + }); + + it('getActiveServerUrl returning undefined falls through to deep-link path', () => { + const adapters: MediaCallEventsAdapters = { getActiveServerUrl: () => undefined, onOpenDeepLink: mockOnOpenDeepLink }; + const dispatch = createVoipEventDispatcher(adapters); + const payload = buildIncomingPayload({ callId: 'any-call', host: 'https://server-b.example.com' }); + + dispatch({ type: 'acceptSucceeded', payload, fromColdStart: false }); + + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ callId: 'any-call', host: 'https://server-b.example.com' }); + }); + + it('different callIds are both processed', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + + dispatch({ + type: 'acceptSucceeded', + payload: buildIncomingPayload({ callId: 'call-A', host: 'https://server-b.example.com' }), + fromColdStart: false + }); + dispatch({ + type: 'acceptSucceeded', + payload: buildIncomingPayload({ callId: 'call-B', host: 'https://server-b.example.com' }), + fromColdStart: false }); - it('returns true and opens failure deep link when stash has voipAcceptFailed + host + callId', async () => { - (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue({ - voipAcceptFailed: true, - callId: 'cold-fail-call', - host: 'https://server-b.cold', - username: 'caller-cold', - caller: 'id', - hostName: 'B', - type: 'incoming_call', - notificationId: 1 - }); - - const result = await getInitialMediaCallEvents(makeTestAdapters()); - - expect(result).toBe(true); - expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ - host: 'https://server-b.cold', - callId: 'cold-fail-call', - username: 'caller-cold', - voipAcceptFailed: true - }); - expect(mockRNCallKeepClearInitialEvents).toHaveBeenCalled(); - expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalled(); - expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('call-A'); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('call-B'); + }); + + it('outgoing_call type does not prevent subsequent incoming_call with same callId', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + + dispatch({ + type: 'acceptSucceeded', + payload: buildIncomingPayload({ callId: 'shared-id', type: 'outgoing_call' }), + fromColdStart: false }); + expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); - it('on Android cold start, opens success deep link when incoming payload is present (answered path)', async () => { - (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( - buildIncomingPayload({ - callId: 'android-cold-accept', - host: 'https://android-b.example.com' - }) - ); - - const result = await getInitialMediaCallEvents(makeTestAdapters()); - - expect(result).toBe(true); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('android-cold-accept'); - expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ - callId: 'android-cold-accept', - host: 'https://android-b.example.com' - }); + dispatch({ + type: 'acceptSucceeded', + payload: buildIncomingPayload({ callId: 'shared-id', type: 'incoming_call', host: 'https://server-b.example.com' }), + fromColdStart: false }); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('shared-id'); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); }); }); -describe('VoipAcceptSucceeded sentinel-correctness (Android)', () => { +describe('createVoipEventDispatcher — acceptFailed', () => { const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); }); - it('B1: outgoing_call with same callId does not poison sentinel for subsequent incoming_call', () => { - setupMediaCallEvents(makeTestAdapters()); + it('opens deep link with voipAcceptFailed after native failure event', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ + callId: 'failed-b', + host: 'https://workspace-b.example.com', + username: 'remote-user' + }); - // First emit: outgoing_call — type guard should bail before setting sentinel - DeviceEventEmitter.emit('VoipAcceptSucceeded', buildIncomingPayload({ callId: 'shared-id', type: 'outgoing_call' })); - expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); + const handled = dispatch({ type: 'acceptFailed', payload, fromColdStart: false }); - // Second emit: incoming_call with same callId — must NOT be suppressed - DeviceEventEmitter.emit( - 'VoipAcceptSucceeded', - buildIncomingPayload({ callId: 'shared-id', type: 'incoming_call', host: 'https://server-b.example.com' }) - ); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('shared-id'); - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ + host: 'https://workspace-b.example.com', + callId: 'failed-b', + username: 'remote-user', + voipAcceptFailed: true + }); + expect(handled).toBe(true); }); - it('B2: different callIds are both processed (not suppressed)', () => { - setupMediaCallEvents(makeTestAdapters()); + it('returns true for cold-start failed event', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId: 'cold-fail', host: 'https://workspace-b.example.com' }); - DeviceEventEmitter.emit( - 'VoipAcceptSucceeded', - buildIncomingPayload({ callId: 'call-A', host: 'https://server-b.example.com' }) - ); - DeviceEventEmitter.emit( - 'VoipAcceptSucceeded', - buildIncomingPayload({ callId: 'call-B', host: 'https://server-b.example.com' }) - ); + const handled = dispatch({ type: 'acceptFailed', payload, fromColdStart: true }); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('call-A'); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('call-B'); + expect(handled).toBe(true); }); - it('B3: getActiveServerUrl returning undefined falls through to deep-link path', () => { - const adapters: MediaCallEventsAdapters = { - getActiveServerUrl: () => undefined, - onOpenDeepLink: mockOnOpenDeepLink - }; - setupMediaCallEvents(adapters); + // Blocker 2 regression: failed-accept must stash the native callId so the + // downstream callLifecycle.end('error') (from deepLinking saga) can resolve + // it via `callId ?? nativeAcceptedCallId`. Otherwise the CallKit/Telecom + // session is never ended. + it('sets nativeAcceptedCallId so subsequent lifecycle.end can resolve the callId', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + const payload = buildIncomingPayload({ callId: 'failed-needs-id', host: 'https://workspace-b.example.com' }); - DeviceEventEmitter.emit( - 'VoipAcceptSucceeded', - buildIncomingPayload({ callId: 'any-call', host: 'https://server-b.example.com' }) - ); + dispatch({ type: 'acceptFailed', payload, fromColdStart: false }); - // isVoipIncomingHostCurrentWorkspace returns false when active URL is falsy - expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); - expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ - callId: 'any-call', - host: 'https://server-b.example.com' - }); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('failed-needs-id'); }); }); -describe('setupMediaCallEvents — didToggleHoldCallAction', () => { +describe('createVoipEventDispatcher — hold', () => { const toggleHold = jest.fn(); const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); toggleHold.mockClear(); - mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold }); }); - it('registers didToggleHoldCallAction via RNCallKeep.addEventListener', () => { - setupMediaCallEvents(makeTestAdapters()); - expect(mockAddEventListener).toHaveBeenCalledWith('didToggleHoldCallAction', expect.any(Function)); - }); - - it('hold: true when isOnHold is false calls toggleHold once and does not setCurrentCallActive', () => { - setupMediaCallEvents(makeTestAdapters()); - getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' }); + it('hold: true when isOnHold is false calls toggleHold once', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); expect(toggleHold).toHaveBeenCalledTimes(1); - expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); }); it('hold: true when isOnHold is true does not call toggleHold', () => { getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); - setupMediaCallEvents(makeTestAdapters()); - getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); expect(toggleHold).not.toHaveBeenCalled(); }); - it('hold: false after OS-initiated hold calls toggleHold once (auto-resume) and setCurrentCallActive', () => { - setupMediaCallEvents(makeTestAdapters()); - const handler = getToggleHoldHandler(); - handler({ hold: true, callUUID: 'uuid-1' }); + it('hold: false after OS-initiated hold calls toggleHold and markActive', () => { + const { voipNative: mockVoipNative } = jest.requireMock('./VoipNative'); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); - handler({ hold: false, callUUID: 'uuid-1' }); + dispatch({ type: 'hold', hold: false, callUuid: 'uuid-1' }); expect(toggleHold).toHaveBeenCalledTimes(2); - expect(mockSetCurrentCallActive).toHaveBeenCalledTimes(1); - expect(mockSetCurrentCallActive).toHaveBeenCalledWith('uuid-1'); + expect(mockVoipNative.call.markActive).toHaveBeenCalledWith('uuid-1'); }); - it('hold: false without prior OS-initiated hold does not call toggleHold or setCurrentCallActive', () => { - setupMediaCallEvents(makeTestAdapters()); - getToggleHoldHandler()({ hold: false, callUUID: 'uuid-1' }); + it('hold: false without prior OS hold does not call toggleHold', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: false, callUuid: 'uuid-1' }); expect(toggleHold).not.toHaveBeenCalled(); - expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); }); - it('consecutive hold: true events call toggleHold only once', () => { - setupMediaCallEvents(makeTestAdapters()); - const handler = getToggleHoldHandler(); - handler({ hold: true, callUUID: 'uuid-1' }); + it('consecutive hold: true calls toggleHold only once', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); - handler({ hold: true, callUUID: 'uuid-1' }); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); expect(toggleHold).toHaveBeenCalledTimes(1); }); - it('clears stale auto-hold when callUUID does not match current call id (e.g. new workspace / call)', () => { - setupMediaCallEvents(makeTestAdapters()); - const handler = getToggleHoldHandler(); - handler({ hold: true, callUUID: 'uuid-1' }); + it('drops event when callUUID does not match active call', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-2' }); + expect(toggleHold).not.toHaveBeenCalled(); + }); + + it('does not toggle when no active call object', () => { + getState.mockReturnValue({ call: null, callId: 'uuid-1', nativeAcceptedCallId: null, isOnHold: false, toggleHold }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); + expect(toggleHold).not.toHaveBeenCalled(); + }); + + it('hold: false does not toggle when user already manually resumed', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); expect(toggleHold).toHaveBeenCalledTimes(1); - getState.mockReturnValue({ - call: {}, - callId: 'uuid-2', - nativeAcceptedCallId: null, - isOnHold: true, - toggleHold - }); - handler({ hold: false, callUUID: 'uuid-1' }); + getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold }); + dispatch({ type: 'hold', hold: false, callUuid: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); + }); + + it('wasAutoHeld is per-dispatcher instance', () => { + const { voipNative: mockVoipNative } = jest.requireMock('./VoipNative'); + const dispatchA = createVoipEventDispatcher(makeTestAdapters()); + const dispatchB = createVoipEventDispatcher(makeTestAdapters()); + dispatchA({ type: 'hold', hold: true, callUuid: 'uuid-1' }); + getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); + dispatchB({ type: 'hold', hold: false, callUuid: 'uuid-1' }); + expect(toggleHold).toHaveBeenCalledTimes(1); // only from dispatchA's hold:true + expect(mockVoipNative.call.markActive).not.toHaveBeenCalled(); + }); + + it('clears stale wasAutoHeld when callUUID does not match', () => { + const { voipNative: mockVoipNative } = jest.requireMock('./VoipNative'); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); expect(toggleHold).toHaveBeenCalledTimes(1); - expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); - handler({ hold: false, callUUID: 'uuid-2' }); + getState.mockReturnValue({ call: {}, callId: 'uuid-2', nativeAcceptedCallId: null, isOnHold: true, toggleHold }); + dispatch({ type: 'hold', hold: false, callUuid: 'uuid-1' }); // uuid mismatch -> clears wasAutoHeld expect(toggleHold).toHaveBeenCalledTimes(1); - expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); + expect(mockVoipNative.call.markActive).not.toHaveBeenCalled(); }); +}); - it('does not toggle when there is no active call object even if ids match', () => { - setupMediaCallEvents(makeTestAdapters()); - const handler = getToggleHoldHandler(); - getState.mockReturnValue({ - call: null, - callId: 'uuid-1', - nativeAcceptedCallId: null, - isOnHold: false, - toggleHold - }); - handler({ hold: true, callUUID: 'uuid-1' }); - expect(toggleHold).not.toHaveBeenCalled(); +describe('createVoipEventDispatcher — endCall', () => { + beforeEach(() => jest.clearAllMocks()); + + it('tags OS-originated end-call as remote by calling callLifecycle.end("remote")', () => { + const { callLifecycle } = jest.requireMock('./CallLifecycle'); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'endCall', callUuid: 'end-uuid' }); + expect(callLifecycle.end).toHaveBeenCalledWith('remote'); }); +}); - it('hold: false does not call toggleHold when user already manually resumed before OS unhold arrives', () => { - setupMediaCallEvents(makeTestAdapters()); - const handler = getToggleHoldHandler(); +describe('createVoipEventDispatcher — mute', () => { + const toggleMute = jest.fn(); + const getState = useCallStore.getState as jest.Mock; - // OS holds the call - handler({ hold: true, callUUID: 'uuid-1' }); - expect(toggleHold).toHaveBeenCalledTimes(1); + beforeEach(() => { + jest.clearAllMocks(); + toggleMute.mockClear(); + getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute }); + }); - // User manually resumes — isOnHold is now false - getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold }); + it('calls toggleMute when muted differs from OS and UUIDs match', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); + expect(toggleMute).toHaveBeenCalledTimes(1); + }); - // OS sends hold: false — should be a no-op since call is already resumed - handler({ hold: false, callUUID: 'uuid-1' }); - expect(toggleHold).toHaveBeenCalledTimes(1); - expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); + it('does not call toggleMute when muted state already matches', () => { + getState.mockReturnValue({ ...activeCallBase, isMuted: true, toggleMute }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); + expect(toggleMute).not.toHaveBeenCalled(); }); - it('cleanup removes didToggleHoldCallAction subscription', () => { - const remove = jest.fn(); - mockAddEventListener.mockImplementation((event: string) => { - if (event === 'didToggleHoldCallAction') { - return { remove }; - } - return { remove: jest.fn() }; - }); - const cleanup = setupMediaCallEvents(makeTestAdapters()); - cleanup(); - expect(remove).toHaveBeenCalled(); + it('drops event when UUID does not match', () => { + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', muted: true, callUuid: 'uuid-2' }); + expect(toggleMute).not.toHaveBeenCalled(); + }); +}); + +describe('createVoipEventDispatcher — pushTokenRegistered', () => { + beforeEach(() => jest.clearAllMocks()); + + it('calls registerPushToken on VoipPushTokenRegistered', async () => { + const { registerPushToken } = jest.requireMock('../restApi'); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'pushTokenRegistered', token: 'voip-token-xyz' }); + await Promise.resolve(); + expect(registerPushToken).toHaveBeenCalledWith(); }); }); diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 71e5a967be4..74af52d3c08 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -1,22 +1,16 @@ -import RNCallKeep from 'react-native-callkeep'; -import { DeviceEventEmitter, NativeEventEmitter } from 'react-native'; - import { isIOS, normalizeDeepLinkingServerHost } from '../../methods/helpers'; -import { useCallStore } from './useCallStore'; -import { mediaSessionInstance } from './MediaSessionInstance'; import type { VoipPayload } from '../../../definitions/Voip'; -import NativeVoipModule from '../../native/NativeVoip'; import { registerPushToken } from '../restApi'; +import { callLifecycle } from './CallLifecycle'; import { MediaCallLogger } from './MediaCallLogger'; +import { mediaSessionInstance } from './MediaSessionInstance'; +import { useCallStore } from './useCallStore'; +import { voipNative, type VoipNativeEvent } from './VoipNative'; -const Emitter = isIOS ? new NativeEventEmitter(NativeVoipModule) : DeviceEventEmitter; const platform = isIOS ? 'iOS' : 'Android'; const TAG = `[MediaCallEvents][${platform}]`; const mediaCallLogger = new MediaCallLogger(); -const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed'; -const EVENT_VOIP_ACCEPT_SUCCEEDED = 'VoipAcceptSucceeded'; - /** Params forwarded into the app deep-linking pipeline for VoIP-driven navigation. */ export type VoipDeepLinkParams = { host?: string; @@ -30,7 +24,7 @@ export type MediaCallEventsAdapters = { onOpenDeepLink: (params: VoipDeepLinkParams) => void; }; -/** True when normalized incoming host matches the active Redux workspace (no server switch needed). */ +/** True when normalized incoming host matches the active Redux workspace. */ function isVoipIncomingHostCurrentWorkspace( incomingHost: string, getActiveServerUrl: MediaCallEventsAdapters['getActiveServerUrl'] @@ -42,250 +36,123 @@ function isVoipIncomingHostCurrentWorkspace( return normalizeDeepLinkingServerHost(incomingHost) === normalizeDeepLinkingServerHost(active); } -/** Dedupe native emit + stash replay for the same failed accept. */ -let lastHandledVoipAcceptFailureCallId: string | null = null; -/** Idempotent warm delivery of native accept success. */ -let lastHandledVoipAcceptSucceededCallId: string | null = null; - -export function clearVoipAcceptDedupeSentinels(): void { - lastHandledVoipAcceptFailureCallId = null; - lastHandledVoipAcceptSucceededCallId = null; -} - -/** Exported for tests only. Clears accept-dedupe sentinels between test cases. Production code calls clearVoipAcceptDedupeSentinels() directly. */ -export function resetMediaCallEventsStateForTesting(): void { - clearVoipAcceptDedupeSentinels(); -} +/** No-op preserved for test backward compatibility. Dedupe sentinels now live in the VoipNative adapter. */ +export function clearVoipAcceptDedupeSentinels(): void {} -function dispatchVoipAcceptFailureFromNative( - raw: VoipPayload & { voipAcceptFailed?: boolean }, - onOpenDeepLink: MediaCallEventsAdapters['onOpenDeepLink'] -) { - if (!raw.voipAcceptFailed) { - return; - } - const { callId } = raw; - if (callId && lastHandledVoipAcceptFailureCallId === callId) { - return; - } - lastHandledVoipAcceptFailureCallId = callId ?? null; - onOpenDeepLink({ - host: raw.host, - callId: raw.callId, - username: raw.username, - voipAcceptFailed: true - }); -} +/** No-op preserved for test backward compatibility. */ +export function resetMediaCallEventsStateForTesting(): void {} -function handleVoipAcceptSucceededFromNative(data: VoipPayload, adapters: MediaCallEventsAdapters) { - const { callId } = data; - if (callId && lastHandledVoipAcceptSucceededCallId === callId) { - return; - } - if (data.type !== 'incoming_call') { +function handleAcceptSucceededEvent(payload: VoipPayload, adapters: MediaCallEventsAdapters, fromColdStart: boolean): boolean { + if (payload.type !== 'incoming_call') { mediaCallLogger.log(`${TAG} VoipAcceptSucceeded: not an incoming call`); - return; - } - if (callId) { - lastHandledVoipAcceptSucceededCallId = callId; + return false; } - mediaCallLogger.debug(`${TAG} VoipAcceptSucceeded:`, data); - NativeVoipModule.clearInitialEvents(); - useCallStore.getState().setNativeAcceptedCallId(data.callId); - if (data.host && isVoipIncomingHostCurrentWorkspace(data.host, adapters.getActiveServerUrl)) { + mediaCallLogger.debug(`${TAG} VoipAcceptSucceeded:`, payload); + useCallStore.getState().setNativeAcceptedCallId(payload.callId); + + if (payload.host && isVoipIncomingHostCurrentWorkspace(payload.host, adapters.getActiveServerUrl)) { + if (fromColdStart && !isIOS) { + // Android cold-start same workspace: let appInit() handle the call handoff + mediaCallLogger.log(`${TAG} Same workspace as VoIP host; continuing appInit for cold-start handoff`); + return false; + } mediaSessionInstance.applyRestStateSignals().catch(error => { mediaCallLogger.error(`${TAG} applyRestStateSignals failed:`, error); }); - return; + return fromColdStart; } + + adapters.onOpenDeepLink({ callId: payload.callId, host: payload.host }); + return true; +} + +function handleAcceptFailedEvent(payload: VoipPayload, adapters: MediaCallEventsAdapters): boolean { + mediaCallLogger.debug(`${TAG} VoipAcceptFailed event:`, payload); + // Pre-bind: stash the native callId in the store so the subsequent + // callLifecycle.end('error') (issued from deepLinking saga) can resolve + // it via `callId ?? nativeAcceptedCallId`. Without this, end() has no + // callUuid and the native CallKit/Telecom session is not torn down. + useCallStore.getState().setNativeAcceptedCallId(payload.callId); adapters.onOpenDeepLink({ - callId: data.callId, - host: data.host + host: payload.host, + callId: payload.callId, + username: payload.username, + voipAcceptFailed: true }); + return true; } /** - * Sets up listeners for media call events. - * @returns Cleanup function to remove listeners + * Creates an event dispatcher that routes `VoipNativeEvent` values to the appropriate handler. + * Returns true when the event indicates a cold-start VoIP path that should suppress the default + * `appInit()` call. */ -export const setupMediaCallEvents = (adapters: MediaCallEventsAdapters): (() => void) => { - const subscriptions: { remove: () => void }[] = []; +export function createVoipEventDispatcher(adapters: MediaCallEventsAdapters): (e: VoipNativeEvent) => boolean { + let wasAutoHeld = false; - // iOS listens for VoIP push token registration and CallKeep events - if (isIOS) { - subscriptions.push( - Emitter.addListener('VoipPushTokenRegistered', ({ token }: { token: string }) => { - mediaCallLogger.debug(`${TAG} Registered VoIP push token:`, token); - registerPushToken().catch(error => { - mediaCallLogger.warn(`${TAG} Failed to register push token after VoIP update:`, error); + return function dispatchVoipNativeEvent(e: VoipNativeEvent): boolean { + switch (e.type) { + case 'endCall': { + mediaCallLogger.log(`${TAG} End call event listener:`, e.callUuid); + callLifecycle.end('remote').catch(error => { + mediaCallLogger.error(`${TAG} callLifecycle.end failed:`, error); }); - }) - ); - - subscriptions.push( - RNCallKeep.addEventListener('endCall', ({ callUUID }) => { - mediaCallLogger.log(`${TAG} End call event listener:`, callUUID); - clearVoipAcceptDedupeSentinels(); - mediaSessionInstance.endCall(callUUID); - }) - ); + return false; + } - subscriptions.push( - RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => { + case 'mute': { const { call, callId, nativeAcceptedCallId, toggleMute, isMuted } = useCallStore.getState(); - const eventUuid = callUUID.toLowerCase(); + const eventUuid = e.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; + return false; } - - // Sync mute state if it doesn't match what the OS is reporting - if (muted !== isMuted) { + if (e.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 REST accept - // (POST /api/v1/media-calls.answer) before JS runs. JS receives VoipAcceptSucceeded after success. - } - - /** Tracks OS-driven hold (competing call) so we only auto-resume that path, not manual hold. */ - let wasAutoHeld = false; - subscriptions.push( - RNCallKeep.addEventListener('didToggleHoldCallAction', ({ hold, callUUID }) => { - const { call, callId, nativeAcceptedCallId, isOnHold, toggleHold } = 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 - // (e.g. workspace/server switch, logout, or call ended while setupMediaCallEvents still lives on Root). - if (!call || !activeUuid || eventUuid !== activeUuid) { - wasAutoHeld = false; - return; + return false; } - if (hold) { - if (!isOnHold) { - toggleHold(); - wasAutoHeld = true; + case 'hold': { + const { call, callId, nativeAcceptedCallId, isOnHold, toggleHold } = useCallStore.getState(); + const eventUuid = e.callUuid.toLowerCase(); + const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase(); + if (!call || !activeUuid || eventUuid !== activeUuid) { + wasAutoHeld = false; + return false; } - return; - } - if (wasAutoHeld) { - if (isOnHold) { - toggleHold(); - RNCallKeep.setCurrentCallActive(callUUID); + if (e.hold) { + if (!isOnHold) { + toggleHold(); + wasAutoHeld = true; + } + return false; } - wasAutoHeld = false; - } - }) - ); - - subscriptions.push( - Emitter.addListener(EVENT_VOIP_ACCEPT_SUCCEEDED, (data: VoipPayload) => { - try { - handleVoipAcceptSucceededFromNative(data, adapters); - } catch (error) { - mediaCallLogger.error(`${TAG} Error handling VoipAcceptSucceeded:`, error); - } - }) - ); - - subscriptions.push( - Emitter.addListener(EVENT_VOIP_ACCEPT_FAILED, (data: VoipPayload & { voipAcceptFailed?: boolean }) => { - mediaCallLogger.debug(`${TAG} VoipAcceptFailed event:`, data); - dispatchVoipAcceptFailureFromNative({ ...data, voipAcceptFailed: true }, adapters.onOpenDeepLink); - NativeVoipModule.clearInitialEvents(); - }) - ); - - return () => { - subscriptions.forEach(sub => sub.remove()); - }; -}; - -/** - * Handles initial media call events (cold start). - * @returns true if startup should skip the default `appInit()` path (answered call, or accept failure handed to deep linking) - */ -export const getInitialMediaCallEvents = async (adapters: MediaCallEventsAdapters): Promise => { - try { - const initialEvents = NativeVoipModule.getInitialEvents() as (VoipPayload & { voipAcceptFailed?: boolean }) | null; - if (!initialEvents) { - mediaCallLogger.log(`${TAG} No initial events from native module`); - RNCallKeep.clearInitialEvents(); - return false; - } - mediaCallLogger.debug(`${TAG} Found initial events:`, initialEvents); - - if (initialEvents.voipAcceptFailed && initialEvents.callId && initialEvents.host) { - dispatchVoipAcceptFailureFromNative(initialEvents, adapters.onOpenDeepLink); - RNCallKeep.clearInitialEvents(); - NativeVoipModule.clearInitialEvents(); - // Avoid racing `appInit()` with the deep-linking saga that handles the failure - return true; - } - - if (!initialEvents.callId || !initialEvents.host || initialEvents.type !== 'incoming_call') { - mediaCallLogger.log(`${TAG} Missing required call data`); - RNCallKeep.clearInitialEvents(); - return false; - } - - let wasAnswered = false; - - // iOS loops through the events and checks if the call was already answered - if (isIOS) { - const callKeepInitialEvents = await RNCallKeep.getInitialEvents(); - RNCallKeep.clearInitialEvents(); - mediaCallLogger.debug(`${TAG} CallKeep initial events:`, JSON.stringify(callKeepInitialEvents, null, 2)); - - for (const event of callKeepInitialEvents) { - const { name, data } = event; - if (name === 'RNCallKeepPerformAnswerCallAction') { - const { callUUID } = data; - if (initialEvents.callId === callUUID) { - wasAnswered = true; - mediaCallLogger.log(`${TAG} Call was already answered via CallKit`); - break; + if (wasAutoHeld) { + if (isOnHold) { + toggleHold(); + voipNative.call.markActive(e.callUuid); } + wasAutoHeld = false; } + return false; } - } else { - // Android only sends answered event, so we can assume the call was answered - wasAnswered = true; - } - if (wasAnswered) { - useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId); - - if (initialEvents.host && isVoipIncomingHostCurrentWorkspace(initialEvents.host, adapters.getActiveServerUrl)) { - if (!isIOS) { - mediaCallLogger.log(`${TAG} Same workspace as VoIP host; continuing appInit for cold-start handoff`); - return false; - } - mediaSessionInstance.applyRestStateSignals().catch(error => { - mediaCallLogger.error(`${TAG} applyRestStateSignals (initial) failed:`, error); + case 'pushTokenRegistered': { + mediaCallLogger.debug(`${TAG} Registered VoIP push token:`, e.token); + registerPushToken().catch(error => { + mediaCallLogger.warn(`${TAG} Failed to register push token after VoIP update:`, error); }); - mediaCallLogger.log(`${TAG} Same workspace as VoIP host; skipped deepLinkingOpen`); - return true; + return false; } - adapters.onOpenDeepLink({ - callId: initialEvents.callId, - host: initialEvents.host - }); - mediaCallLogger.log(`${TAG} Dispatched deepLinkingOpen for VoIP`); - } + case 'acceptSucceeded': { + return handleAcceptSucceededEvent(e.payload, adapters, e.fromColdStart); + } - return wasAnswered; - } catch (error) { - mediaCallLogger.error(`${TAG} Error:`, error); - return false; - } -}; + case 'acceptFailed': { + return handleAcceptFailedEvent(e.payload, adapters); + } + } + }; +} diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index e17dba8862f..304b2c20106 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -1,13 +1,14 @@ import type { IClientMediaCall } from '@rocket.chat/media-signaling'; -import RNCallKeep from 'react-native-callkeep'; import { waitFor } from '@testing-library/react-native'; +import { voipNative, type InMemoryVoipNative } from './VoipNative'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import Navigation from '../../navigation/appNavigation'; import { getDMSubscriptionByUsername } from '../../database/services/Subscription'; import { getUidDirectMessage } from '../../methods/helpers/helpers'; import { mediaSessionStore } from './MediaSessionStore'; import { mediaSessionInstance } from './MediaSessionInstance'; +import { callLifecycle } from './CallLifecycle'; jest.mock('../../database/services/Subscription', () => ({ getDMSubscriptionByUsername: jest.fn() @@ -79,15 +80,6 @@ jest.mock('react-native-webrtc', () => ({ mediaDevices: { getUserMedia: jest.fn() } })); -jest.mock('react-native-callkeep', () => ({ - __esModule: true, - default: { - endCall: jest.fn(), - setCurrentCallActive: jest.fn(), - setAvailable: jest.fn() - } -})); - jest.mock('react-native-device-info', () => ({ default: { getUniqueId: jest.fn(() => 'test-device-id'), @@ -242,6 +234,7 @@ describe('MediaSessionInstance', () => { roomId: null }); mediaSessionInstance.reset(); + (voipNative as InMemoryVoipNative).reset(); }); afterEach(() => { @@ -339,7 +332,7 @@ describe('MediaSessionInstance', () => { const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); getNewCallHandler()({ call: incoming }); expect(incoming.reject).not.toHaveBeenCalled(); - expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('incoming-b'); + expect((voipNative as InMemoryVoipNative).recorded).not.toContainEqual({ cmd: 'end', callUuid: 'incoming-b' }); }); it('allows incoming callee newCall when nativeAcceptedCallId is set but differs from incoming callId', async () => { @@ -358,7 +351,7 @@ describe('MediaSessionInstance', () => { const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); getNewCallHandler()({ call: incoming }); expect(incoming.reject).not.toHaveBeenCalled(); - expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('incoming-b'); + expect((voipNative as InMemoryVoipNative).recorded).not.toContainEqual({ cmd: 'end', callUuid: 'incoming-b' }); }); it('allows incoming callee newCall when nativeAcceptedCallId matches incoming callId', async () => { @@ -377,7 +370,7 @@ describe('MediaSessionInstance', () => { const incoming = buildClientMediaCall({ callId: 'same-id', role: 'callee' }); getNewCallHandler()({ call: incoming }); expect(incoming.reject).not.toHaveBeenCalled(); - expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('same-id'); + expect((voipNative as InMemoryVoipNative).recorded).not.toContainEqual({ cmd: 'end', callUuid: 'same-id' }); }); it('does not reject outgoing (caller) newCall; binds call and navigates', async () => { @@ -790,5 +783,103 @@ describe('MediaSessionInstance', () => { await waitFor(() => expect(mockSetRoomId).toHaveBeenCalledWith('dm-ext')); expect(mockGetDMSubscriptionByUsername).toHaveBeenCalledWith('bob'); }); + + it('answerCall records markActive on voipNative with callId', async () => { + await mediaSessionInstance.init('user-1'); + const session = createdSessions[0]; + const mainCall = { + callId: 'ans-mark', + accept: jest.fn().mockResolvedValue(undefined), + remoteParticipants: [{ contact: {} }] + }; + session.getCallData.mockReturnValue(mainCall); + (voipNative as InMemoryVoipNative).reset(); + + await mediaSessionInstance.answerCall('ans-mark'); + + expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: 'ans-mark' }); + }); + }); + + describe("call.emitter 'ended' guard (post-teardown stale emission)", () => { + it("does not invoke callLifecycle.end again when 'ended' fires after store has been reset", async () => { + const mockSetCall = jest.fn(); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: mockSetCall, + setRoomId: mockSetRoomId, + setDirection: mockSetDirection, + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: null, + roomId: null + }); + await mediaSessionInstance.init('user-1'); + const endSpy = jest.spyOn(callLifecycle, 'end').mockResolvedValue(undefined); + + const outgoing = buildClientMediaCall({ callId: 'stale-c1', role: 'caller' }); + getNewCallHandler()({ call: outgoing }); + + const emitterOnMock = (outgoing.emitter as unknown as { on: jest.Mock }).on; + const endedEntry = emitterOnMock.mock.calls.find(([name]: [string]) => name === 'ended'); + expect(endedEntry).toBeDefined(); + const endedHandler = endedEntry![1] as () => void; + + // State while call is active — store reflects the bound call. + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: mockSetCall, + setRoomId: mockSetRoomId, + setDirection: mockSetDirection, + resetNativeCallId: jest.fn(), + call: { callId: 'stale-c1' } as unknown as IClientMediaCall, + callId: 'stale-c1', + nativeAcceptedCallId: null, + roomId: null + }); + + // First 'ended' emission — store still has the call → teardown invoked once. + endedHandler(); + await Promise.resolve(); + expect(endSpy).toHaveBeenCalledTimes(1); + expect(endSpy).toHaveBeenCalledWith('remote'); + + // Simulate teardown completing — store cleared (call/callId/native id all null). + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: mockSetCall, + setRoomId: mockSetRoomId, + setDirection: mockSetDirection, + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: null, + roomId: null + }); + + // Second (stale, late-arriving) 'ended' on the same captured `call` object. + endedHandler(); + await Promise.resolve(); + + // Guard must have short-circuited — no additional invocations. + expect(endSpy).toHaveBeenCalledTimes(1); + endSpy.mockRestore(); + }); + }); + + describe('endCall', () => { + it('delegates to callLifecycle.end("local") — endCall is a one-line delegate', async () => { + // endCall now delegates entirely to callLifecycle.end('local'). + // Teardown ordering and command recording are tested in CallLifecycle.test.ts. + // Here we verify only that the delegate fires (no direct voipNative commands in MediaSessionInstance). + await mediaSessionInstance.init('user-1'); + const endSpy = jest.spyOn(callLifecycle, 'end').mockResolvedValue(undefined); + + mediaSessionInstance.endCall('end-1'); + + expect(endSpy).toHaveBeenCalledWith('local'); + endSpy.mockRestore(); + }); }); }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 6f4ee98be5f..50a03692d5a 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -8,20 +8,20 @@ import { type ServerMediaSignal, type WebRTCProcessorConfig } from '@rocket.chat/media-signaling'; -import RNCallKeep from 'react-native-callkeep'; import { registerGlobals } from 'react-native-webrtc'; import { getUniqueIdSync } from 'react-native-device-info'; import { dequal } from 'dequal'; import { mediaSessionStore } from './MediaSessionStore'; -import { terminateNativeCall } from './terminateNativeCall'; +import { voipNative } from './VoipNative'; import { useCallStore } from './useCallStore'; +import { callLifecycle } from './CallLifecycle'; import { MediaCallLogger } from './MediaCallLogger'; import { isSelfUserId } from './isSelfUserId'; import { store } from '../../store/auxStore'; import sdk from '../sdk'; import { mediaCallsStateSignals } from '../restApi'; -import Navigation, { waitForNavigationReady } from '../../navigation/appNavigation'; +import Navigation from '../../navigation/appNavigation'; import { parseStringToIceServers } from './parseStringToIceServers'; import type { IceServer } from '../../../definitions/Voip'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; @@ -138,7 +138,18 @@ class MediaSessionInstance { } call.emitter.on('ended', () => { - terminateNativeCall(call.callId); + // Guard against stale 'ended' emissions firing after teardown has cleared the + // active call from the store. Without this, a delayed/late server signal on the + // captured `call` would trigger a second teardown sequence and emit a duplicate + // `callEnded` event with the wrong reason. + const { call: activeCall, callId: activeCallId } = useCallStore.getState(); + if (activeCall?.callId !== call.callId && activeCallId !== call.callId) { + return; + } + // Route through CallLifecycle for idempotent, ordered teardown. + callLifecycle.end('remote').catch(error => { + mediaCallLogger.error('[VoIP] callLifecycle.end failed:', error); + }); }); } }); @@ -154,16 +165,16 @@ class MediaSessionInstance { if (mainCall && mainCall.callId === callId) { await mainCall.accept(); - RNCallKeep.setCurrentCallActive(callId); + voipNative.call.markActive(callId); useCallStore.getState().setCall(mainCall); useCallStore.getState().setDirection('incoming'); - await waitForNavigationReady(); + // waitForNavigationReady removed — CallNavRouter handles post-call navigation. Navigation.navigate('CallView'); this.resolveRoomIdFromContact(mainCall.remoteParticipants[0]?.contact).catch(error => { console.error('[VoIP] Error resolving room id from contact (answerCall):', error); }); } else { - terminateNativeCall(callId); + voipNative.call.end(callId); const st = useCallStore.getState(); if (st.nativeAcceptedCallId === callId) { st.resetNativeCallId(); @@ -207,21 +218,11 @@ class MediaSessionInstance { await this.instance.startCall(actor, userId); }; - public endCall = (callId: string) => { - const mainCall = this.instance?.getCallData(callId); - - if (mainCall && mainCall.callId === callId) { - if (mainCall.state === 'ringing') { - mainCall.reject(); - } else { - mainCall.hangup(); - } - } - terminateNativeCall(callId); - RNCallKeep.setCurrentCallActive(''); - RNCallKeep.setAvailable(true); - useCallStore.getState().resetNativeCallId(); - useCallStore.getState().reset(); + public endCall = (_callId: string) => { + // Delegate to CallLifecycle for idempotent, ordered teardown. + callLifecycle.end('local').catch(error => { + mediaCallLogger.error('[VoIP] callLifecycle.end failed:', error); + }); }; private async resolveRoomIdFromContact(contact: CallContact | undefined): Promise { diff --git a/app/lib/services/voip/VoipNative.test.ts b/app/lib/services/voip/VoipNative.test.ts new file mode 100644 index 00000000000..f450ab2d45c --- /dev/null +++ b/app/lib/services/voip/VoipNative.test.ts @@ -0,0 +1,197 @@ +import type { VoipPayload } from '../../../definitions/Voip'; +import { InMemoryVoipNative, type VoipNativeEvent } from './VoipNative'; + +jest.mock('react-native-webrtc', () => ({ registerGlobals: jest.fn() })); +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); +jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn() } +})); +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + registerVoipToken: jest.fn(), + getInitialEvents: jest.fn(() => null), + clearInitialEvents: jest.fn(), + getLastVoipToken: jest.fn(() => ''), + stopNativeDDPClient: jest.fn(), + stopVoipCallService: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn() + } +})); + +function buildPayload(callId = 'call-1'): VoipPayload { + return { + callId, + caller: 'id', + username: 'user', + host: 'https://x.example.com', + hostName: 'X', + type: 'incoming_call', + notificationId: 1 + }; +} + +describe('InMemoryVoipNative', () => { + let adapter: InMemoryVoipNative; + + beforeEach(() => { + adapter = new InMemoryVoipNative(); + }); + + it('starts with empty recorded list', () => { + expect(adapter.recorded).toEqual([]); + }); + + it('call.end records end command with callUuid', () => { + adapter.call.end('abc-123'); + expect(adapter.recorded).toEqual([{ cmd: 'end', callUuid: 'abc-123' }]); + }); + + it('two call.end calls produce two ordered records', () => { + adapter.call.end('first'); + adapter.call.end('second'); + expect(adapter.recorded).toEqual([ + { cmd: 'end', callUuid: 'first' }, + { cmd: 'end', callUuid: 'second' } + ]); + }); + + it('call.markActive records markActive command', () => { + adapter.call.markActive('uuid-1'); + expect(adapter.recorded).toEqual([{ cmd: 'markActive', callUuid: 'uuid-1' }]); + }); + + it('call.markAvailable records markAvailable command', () => { + adapter.call.markAvailable('uuid-1'); + expect(adapter.recorded).toEqual([{ cmd: 'markAvailable', callUuid: 'uuid-1' }]); + }); + + it('call.setSpeaker(true) records setSpeaker on:true', async () => { + await adapter.call.setSpeaker(true); + expect(adapter.recorded).toEqual([{ cmd: 'setSpeaker', on: true }]); + }); + + it('call.setSpeaker(false) records setSpeaker on:false', async () => { + await adapter.call.setSpeaker(false); + expect(adapter.recorded).toEqual([{ cmd: 'setSpeaker', on: false }]); + }); + + it('call.startAudio records startAudio', () => { + adapter.call.startAudio(); + expect(adapter.recorded).toEqual([{ cmd: 'startAudio' }]); + }); + + it('call.stopAudio records stopAudio', () => { + adapter.call.stopAudio(); + expect(adapter.recorded).toEqual([{ cmd: 'stopAudio' }]); + }); + + it('mixed commands preserve insertion order', async () => { + adapter.call.startAudio(); + adapter.call.markActive('u1'); + await adapter.call.setSpeaker(true); + adapter.call.end('u1'); + adapter.call.stopAudio(); + expect(adapter.recorded).toEqual([ + { cmd: 'startAudio' }, + { cmd: 'markActive', callUuid: 'u1' }, + { cmd: 'setSpeaker', on: true }, + { cmd: 'end', callUuid: 'u1' }, + { cmd: 'stopAudio' } + ]); + }); + + it('attach resolves', async () => { + await expect(adapter.attach({ onEvent: () => undefined })).resolves.toBeDefined(); + }); + + it('attach returns { detach fn, pushToken string }', async () => { + const result = await adapter.attach({ onEvent: () => undefined }); + expect(typeof result.detach).toBe('function'); + expect(typeof result.pushToken).toBe('string'); + }); +}); + +describe('InMemoryVoipNative — __emit', () => { + let adapter: InMemoryVoipNative; + let received: VoipNativeEvent[]; + + beforeEach(() => { + adapter = new InMemoryVoipNative(); + received = []; + }); + + it('__emit before attach is a no-op', () => { + expect(() => adapter.__emit({ type: 'endCall', callUuid: 'cold' })).not.toThrow(); + }); + + it('__emit after attach fires onEvent', async () => { + await adapter.attach({ onEvent: e => received.push(e) }); + const event: VoipNativeEvent = { type: 'endCall', callUuid: 'uuid-1' }; + adapter.__emit(event); + expect(received).toEqual([event]); + }); + + it('detach stops __emit from calling onEvent', async () => { + const { detach } = await adapter.attach({ onEvent: e => received.push(e) }); + detach(); + adapter.__emit({ type: 'endCall', callUuid: 'uuid-2' }); + expect(received).toHaveLength(0); + }); + + it('detach is idempotent', async () => { + const { detach } = await adapter.attach({ onEvent: () => undefined }); + expect(() => { + detach(); + detach(); + }).not.toThrow(); + }); +}); + +describe('InMemoryVoipNative — __seedColdStart', () => { + let adapter: InMemoryVoipNative; + let received: VoipNativeEvent[]; + + beforeEach(() => { + adapter = new InMemoryVoipNative(); + received = []; + }); + + it('seeds fire through onEvent during attach', async () => { + const events: VoipNativeEvent[] = [{ type: 'acceptSucceeded', payload: buildPayload(), fromColdStart: true }]; + adapter.__seedColdStart(events); + await adapter.attach({ onEvent: e => received.push(e) }); + expect(received).toEqual(events); + }); + + it('seeds cleared after first attach — not replayed on second attach', async () => { + adapter.__seedColdStart([{ type: 'endCall', callUuid: 'cold-uuid' }]); + await adapter.attach({ onEvent: e => received.push(e) }); + expect(received).toHaveLength(1); + const received2: VoipNativeEvent[] = []; + await adapter.attach({ onEvent: e => received2.push(e) }); + expect(received2).toHaveLength(0); + }); + + it('seeds fire before live __emit events', async () => { + const order: string[] = []; + adapter.__seedColdStart([{ type: 'acceptSucceeded', payload: buildPayload('seed-call'), fromColdStart: true }]); + await adapter.attach({ + onEvent: e => { + if (e.type === 'acceptSucceeded') order.push(`seed:${e.payload.callId}`); + else if (e.type === 'endCall') order.push(`live:${e.callUuid}`); + } + }); + adapter.__emit({ type: 'endCall', callUuid: 'live-1' }); + expect(order).toEqual(['seed:seed-call', 'live:live-1']); + }); +}); diff --git a/app/lib/services/voip/VoipNative.ts b/app/lib/services/voip/VoipNative.ts new file mode 100644 index 00000000000..79534524a2d --- /dev/null +++ b/app/lib/services/voip/VoipNative.ts @@ -0,0 +1,287 @@ +import { DeviceEventEmitter, NativeEventEmitter, Platform } from 'react-native'; +import RNCallKeep from 'react-native-callkeep'; +import InCallManager from 'react-native-incall-manager'; + +import type { VoipPayload } from '../../../definitions/Voip'; +import NativeVoipModule from '../../native/NativeVoip'; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type VoipNativeEvent = + | { type: 'endCall'; callUuid: string } + | { type: 'mute'; muted: boolean; callUuid: string } + | { type: 'hold'; hold: boolean; callUuid: string } + | { type: 'pushTokenRegistered'; token: string } + | { type: 'acceptSucceeded'; payload: VoipPayload; fromColdStart: boolean } + | { type: 'acceptFailed'; payload: VoipPayload; fromColdStart: boolean }; + +export type Command = + | { cmd: 'end'; callUuid: string } + | { cmd: 'markActive'; callUuid: string } + | { cmd: 'markAvailable'; callUuid: string } + | { cmd: 'setSpeaker'; on: boolean } + | { cmd: 'startAudio' } + | { cmd: 'stopAudio' }; + +export type VoipNativeCallCommands = { + end(callUuid: string): void; + markActive(callUuid: string): void; + markAvailable(callUuid: string): void; + setSpeaker(on: boolean): Promise; + startAudio(): void; + stopAudio(): void; +}; + +export type VoipNativePort = { + attach(opts: { onEvent(e: VoipNativeEvent): void }): Promise<{ detach(): void; pushToken: string }>; + readonly call: VoipNativeCallCommands; +}; + +// ── In-memory adapter (tests) ──────────────────────────────────────────────── + +export class InMemoryVoipNative implements VoipNativePort { + readonly recorded: Command[] = []; + private _onEvent: ((e: VoipNativeEvent) => void) | null = null; + private _coldStartQueue: VoipNativeEvent[] = []; + + readonly call: VoipNativeCallCommands = { + end: (callUuid: string) => { + this.recorded.push({ cmd: 'end', callUuid }); + }, + markActive: (callUuid: string) => { + this.recorded.push({ cmd: 'markActive', callUuid }); + }, + markAvailable: (callUuid: string) => { + this.recorded.push({ cmd: 'markAvailable', callUuid }); + }, + setSpeaker: (on: boolean) => { + this.recorded.push({ cmd: 'setSpeaker', on }); + return Promise.resolve(); + }, + startAudio: () => { + this.recorded.push({ cmd: 'startAudio' }); + }, + stopAudio: () => { + this.recorded.push({ cmd: 'stopAudio' }); + } + }; + + reset(): void { + this.recorded.splice(0); + } + + attach(opts: { onEvent(e: VoipNativeEvent): void }): Promise<{ detach(): void; pushToken: string }> { + this._onEvent = opts.onEvent; + const seeds = this._coldStartQueue.splice(0); + for (const event of seeds) { + this._onEvent(event); + } + return Promise.resolve({ + detach: () => { + this._onEvent = null; + }, + pushToken: '' + }); + } + + __emit(event: VoipNativeEvent): void { + this._onEvent?.(event); + } + + __seedColdStart(events: VoipNativeEvent[]): void { + this._coldStartQueue.push(...events); + } +} + +// ── Production adapter ─────────────────────────────────────────────────────── + +const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed'; +const EVENT_VOIP_ACCEPT_SUCCEEDED = 'VoipAcceptSucceeded'; + +class ProductionVoipNative implements VoipNativePort { + private _lastHandledAcceptSucceededCallId: string | null = null; + private _lastHandledAcceptFailedCallId: string | null = null; + + readonly call: VoipNativeCallCommands = { + end: (callUuid: string) => { + try { + RNCallKeep.endCall(callUuid); + } catch { + // CallKeep unavailable; still attempt Android service stop below + } + if (Platform.OS === 'android') { + try { + NativeVoipModule.stopVoipCallService(); + } catch { + // bridge unavailable pre-boot + } + } + }, + markActive: (callUuid: string) => { + RNCallKeep.setCurrentCallActive(callUuid); + }, + markAvailable: (_callUuid: string) => { + RNCallKeep.setCurrentCallActive(''); + RNCallKeep.setAvailable(true); + }, + setSpeaker: async (on: boolean) => { + await InCallManager.setForceSpeakerphoneOn(on); + }, + startAudio: () => { + InCallManager.start({ media: 'audio' }); + }, + stopAudio: () => { + InCallManager.stop(); + } + }; + + async attach(opts: { onEvent(e: VoipNativeEvent): void }): Promise<{ detach(): void; pushToken: string }> { + const { onEvent } = opts; + + // 1. Register WebRTC globals (lazy require avoids cascading import errors in unrelated tests) + // eslint-disable-next-line @typescript-eslint/no-require-imports + (require('react-native-webrtc') as { registerGlobals(): void }).registerGlobals(); + + // 2. Register PushKit token (iOS only) + if (Platform.OS === 'ios') { + NativeVoipModule.registerVoipToken(); + } + + // 3. Wire all listeners + const Emitter = Platform.OS === 'ios' ? new NativeEventEmitter(NativeVoipModule) : DeviceEventEmitter; + const subs: { remove(): void }[] = []; + + if (Platform.OS === 'ios') { + subs.push( + Emitter.addListener('VoipPushTokenRegistered', ({ token }: { token: string }) => { + onEvent({ type: 'pushTokenRegistered', token }); + }) + ); + + subs.push( + RNCallKeep.addEventListener('endCall', ({ callUUID }: { callUUID: string }) => { + this._lastHandledAcceptSucceededCallId = null; + this._lastHandledAcceptFailedCallId = null; + onEvent({ type: 'endCall', callUuid: callUUID }); + }) + ); + + subs.push( + RNCallKeep.addEventListener( + 'didPerformSetMutedCallAction', + ({ muted, callUUID }: { muted: boolean; callUUID: string }) => { + onEvent({ type: 'mute', muted, callUuid: callUUID }); + } + ) + ); + } + + subs.push( + RNCallKeep.addEventListener('didToggleHoldCallAction', ({ hold, callUUID }: { hold: boolean; callUUID: string }) => { + onEvent({ type: 'hold', hold, callUuid: callUUID }); + }) + ); + + subs.push( + Emitter.addListener(EVENT_VOIP_ACCEPT_SUCCEEDED, (data: VoipPayload) => { + const { callId } = data; + if (callId && this._lastHandledAcceptSucceededCallId === callId) { + return; + } + if (data.type !== 'incoming_call') { + return; + } + if (callId) { + this._lastHandledAcceptSucceededCallId = callId; + } + onEvent({ type: 'acceptSucceeded', payload: data, fromColdStart: false }); + }) + ); + + subs.push( + Emitter.addListener(EVENT_VOIP_ACCEPT_FAILED, (data: VoipPayload) => { + const { callId } = data; + if (callId && this._lastHandledAcceptFailedCallId === callId) { + return; + } + if (callId) { + this._lastHandledAcceptFailedCallId = callId; + } + NativeVoipModule.clearInitialEvents(); + onEvent({ type: 'acceptFailed', payload: data, fromColdStart: false }); + }) + ); + + // 4. Drain cold-start events + await this._drainColdStart(onEvent); + + // 5. Resolve + const pushToken = NativeVoipModule.getLastVoipToken(); + + return { + detach: () => { + subs.forEach(s => s.remove()); + if (Platform.OS === 'ios') { + NativeVoipModule.stopNativeDDPClient(); + } + }, + pushToken + }; + } + + private async _drainColdStart(onEvent: (e: VoipNativeEvent) => void): Promise { + const initialEvents = NativeVoipModule.getInitialEvents() as (VoipPayload & { voipAcceptFailed?: boolean }) | null; + + if (!initialEvents) { + RNCallKeep.clearInitialEvents(); + return; + } + + if (initialEvents.voipAcceptFailed && initialEvents.callId && initialEvents.host) { + const { callId } = initialEvents; + if (!callId || this._lastHandledAcceptFailedCallId !== callId) { + if (callId) this._lastHandledAcceptFailedCallId = callId; + onEvent({ type: 'acceptFailed', payload: initialEvents, fromColdStart: true }); + } + RNCallKeep.clearInitialEvents(); + NativeVoipModule.clearInitialEvents(); + return; + } + + if (!initialEvents.callId || !initialEvents.host || initialEvents.type !== 'incoming_call') { + RNCallKeep.clearInitialEvents(); + return; + } + + let wasAnswered = false; + + if (Platform.OS === 'ios') { + const callKeepInitialEvents = await RNCallKeep.getInitialEvents(); + RNCallKeep.clearInitialEvents(); + + for (const event of callKeepInitialEvents) { + const { name, data } = event as { name: string; data: { callUUID?: string } }; + if (name === 'RNCallKeepPerformAnswerCallAction' && data?.callUUID === initialEvents.callId) { + wasAnswered = true; + break; + } + } + } else { + wasAnswered = true; + } + + if (wasAnswered) { + const { callId } = initialEvents; + if (!callId || this._lastHandledAcceptSucceededCallId !== callId) { + if (callId) this._lastHandledAcceptSucceededCallId = callId; + onEvent({ type: 'acceptSucceeded', payload: initialEvents, fromColdStart: true }); + } + } + + NativeVoipModule.clearInitialEvents(); + } +} + +// ── Singleton ──────────────────────────────────────────────────────────────── + +export const voipNative: VoipNativePort = process.env.NODE_ENV === 'test' ? new InMemoryVoipNative() : new ProductionVoipNative(); diff --git a/app/lib/services/voip/resetVoipState.test.ts b/app/lib/services/voip/resetVoipState.test.ts index 105ec87deba..aaae1cb0e71 100644 --- a/app/lib/services/voip/resetVoipState.test.ts +++ b/app/lib/services/voip/resetVoipState.test.ts @@ -1,35 +1,41 @@ -import { DeviceEventEmitter } from 'react-native'; - -import { resetMediaCallEventsStateForTesting, setupMediaCallEvents, type MediaCallEventsAdapters } from './MediaCallEvents'; import { resetVoipState } from './resetVoipState'; import { useCallStore } from './useCallStore'; -jest.mock('../../methods/helpers', () => ({ - ...jest.requireActual('../../methods/helpers'), - isIOS: false -})); - -jest.mock('./useCallStore', () => ({ - useCallStore: { - getState: jest.fn() +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => Promise.resolve([])) } })); - +jest.mock('react-native-webrtc', () => ({ registerGlobals: jest.fn() })); +jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn() } +})); jest.mock('../../native/NativeVoip', () => ({ __esModule: true, default: { + registerVoipToken: jest.fn(), + getInitialEvents: jest.fn(() => null), clearInitialEvents: jest.fn(), - getInitialEvents: jest.fn(() => null) + getLastVoipToken: jest.fn(() => ''), + stopNativeDDPClient: jest.fn(), + stopVoipCallService: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn() } })); -jest.mock('react-native-callkeep', () => ({ - __esModule: true, - default: { - addEventListener: jest.fn(() => ({ remove: jest.fn() })), - clearInitialEvents: jest.fn(), - setCurrentCallActive: jest.fn(), - getInitialEvents: jest.fn(() => Promise.resolve([])) +jest.mock('../../methods/helpers', () => ({ + ...jest.requireActual('../../methods/helpers'), + isIOS: false +})); + +jest.mock('./useCallStore', () => ({ + useCallStore: { + getState: jest.fn() } })); @@ -53,15 +59,20 @@ jest.mock('./MediaCallLogger', () => ({ } })); -const mockOnOpenDeepLink = jest.fn(); -const mockSetNativeAcceptedCallId = jest.fn(); - -function makeTestAdapters(): MediaCallEventsAdapters { - return { - getActiveServerUrl: () => undefined, - onOpenDeepLink: mockOnOpenDeepLink - }; -} +jest.mock('./VoipNative', () => ({ + ...jest.requireActual('./VoipNative'), + voipNative: { + call: { + markActive: jest.fn(), + end: jest.fn(), + markAvailable: jest.fn(), + setSpeaker: jest.fn(), + startAudio: jest.fn(), + stopAudio: jest.fn() + }, + attach: jest.fn() + } +})); describe('resetVoipState', () => { it('calls resetNativeCallId before reset (native id must clear before store reset)', () => { @@ -103,47 +114,4 @@ describe('resetVoipState', () => { clearSpy.mockRestore(); }); - - it('C1: after resetVoipState, a previously-handled callId is processed again (sentinel cleared)', () => { - (useCallStore.getState as jest.Mock).mockReturnValue({ - setNativeAcceptedCallId: mockSetNativeAcceptedCallId, - resetNativeCallId: jest.fn(), - reset: jest.fn() - }); - jest.clearAllMocks(); - resetMediaCallEventsStateForTesting(); - - // Wire up event listeners so DeviceEventEmitter delivers VoipAcceptSucceeded - setupMediaCallEvents(makeTestAdapters()); - - const payload = { - callId: 'reused-call-id', - caller: 'caller-id', - username: 'caller', - host: 'https://server.example.com', - hostName: 'Server', - type: 'incoming_call' as const, - notificationId: 1 - }; - - // First delivery — handled, sentinel set - DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); - - // Second delivery without reset — suppressed by dedupe - DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); - - // Reset clears sentinel - (useCallStore.getState as jest.Mock).mockReturnValue({ - setNativeAcceptedCallId: mockSetNativeAcceptedCallId, - resetNativeCallId: jest.fn(), - reset: jest.fn() - }); - resetVoipState(); - - // Third delivery — must be processed again - DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); - expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2); - }); }); diff --git a/app/lib/services/voip/terminateNativeCall.ts b/app/lib/services/voip/terminateNativeCall.ts deleted file mode 100644 index c843f9fee40..00000000000 --- a/app/lib/services/voip/terminateNativeCall.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Platform } from 'react-native'; -import RNCallKeep from 'react-native-callkeep'; - -import NativeVoipModule from '../../native/NativeVoip'; - -export function terminateNativeCall(callId: string): void { - try { - RNCallKeep.endCall(callId); - } catch { - // CallKeep may be unavailable; still attempt to stop the Android service below - } - if (Platform.OS === 'android') { - try { - NativeVoipModule.stopVoipCallService(); - } catch { - // bridge unavailable pre-boot - } - } -} diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index ebd2917f21b..1cbd5bc9ee6 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -1,6 +1,34 @@ import type { IClientMediaCall } from '@rocket.chat/media-signaling'; import { useCallStore } from './useCallStore'; +import { voipNative, type InMemoryVoipNative } from './VoipNative'; + +jest.mock('react-native-webrtc', () => ({ registerGlobals: jest.fn() })); +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); +jest.mock('react-native-incall-manager', () => ({ + __esModule: true, + default: { start: jest.fn(), stop: jest.fn(), setForceSpeakerphoneOn: jest.fn() } +})); +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + registerVoipToken: jest.fn(), + getInitialEvents: jest.fn(() => null), + clearInitialEvents: jest.fn(), + getLastVoipToken: jest.fn(() => ''), + stopNativeDDPClient: jest.fn(), + stopVoipCallService: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn() + } +})); jest.mock('../../navigation/appNavigation', () => ({ __esModule: true, @@ -11,16 +39,6 @@ jest.mock('../../../containers/ActionSheet', () => ({ hideActionSheetRef: jest.fn() })); -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, options?: { initialState?: string }) { const initialState = options?.initialState ?? 'active'; const listeners: Record void>> = {}; @@ -293,3 +311,66 @@ describe('useCallStore native accepted + stale timer', () => { expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); }); }); + +describe('useCallStore audio commands via VoipNative seam', () => { + const adapter = voipNative as InMemoryVoipNative; + + beforeEach(() => { + adapter.reset(); + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + }); + + it('setCall records startAudio on voipNative', () => { + const { call } = createMockCall('audio-1'); + useCallStore.getState().setCall(call); + expect(adapter.recorded).toContainEqual({ cmd: 'startAudio' }); + }); + + it('reset does NOT record stopAudio on voipNative (stopAudio ownership moved to CallLifecycle.end step 6)', () => { + // stopAudio is now called by CallLifecycle._runTeardown as step 6 (after reset()), + // so subscribers see consistent JS state when callEnded emits. + // Direct reset() calls (e.g. session teardown) do not stop audio — by design. + adapter.reset(); + useCallStore.getState().reset(); + expect(adapter.recorded).not.toContainEqual({ cmd: 'stopAudio' }); + }); + + it('toggleSpeaker records setSpeaker(true) when speaker was off', async () => { + const { call } = createMockCall('spk-1'); + useCallStore.getState().setCall(call); + adapter.reset(); + + await useCallStore.getState().toggleSpeaker(); + + expect(adapter.recorded).toContainEqual({ cmd: 'setSpeaker', on: true }); + expect(useCallStore.getState().isSpeakerOn).toBe(true); + }); + + it('toggleSpeaker records setSpeaker(false) when speaker was on', async () => { + const { call } = createMockCall('spk-2'); + useCallStore.getState().setCall(call); + await useCallStore.getState().toggleSpeaker(); + adapter.reset(); + + await useCallStore.getState().toggleSpeaker(); + + expect(adapter.recorded).toContainEqual({ cmd: 'setSpeaker', on: false }); + expect(useCallStore.getState().isSpeakerOn).toBe(false); + }); + + it('toggleSpeaker is a no-op without an active call', async () => { + await useCallStore.getState().toggleSpeaker(); + expect(adapter.recorded).not.toContainEqual(expect.objectContaining({ cmd: 'setSpeaker' })); + }); + + it('stateChange to active records markActive on voipNative with callId', () => { + const { call, emit } = createMockCall('mark-1'); + useCallStore.getState().setCall(call); + adapter.reset(); + + emit('stateChange'); + + expect(adapter.recorded).toContainEqual({ cmd: 'markActive', callUuid: 'mark-1' }); + }); +}); diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 0028257bff4..846141f0555 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -1,12 +1,15 @@ import { create } from 'zustand'; import type { CallState, CallContact, IClientMediaCall } from '@rocket.chat/media-signaling'; -import RNCallKeep from 'react-native-callkeep'; -import InCallManager from 'react-native-incall-manager'; -import { terminateNativeCall } from './terminateNativeCall'; +import { voipNative } from './VoipNative'; import Navigation from '../../navigation/appNavigation'; import { hideActionSheetRef } from '../../../containers/ActionSheet'; import { useIsScreenReaderEnabled } from '../../hooks/useIsScreenReaderEnabled'; +import { callLifecycle } from './CallLifecycle'; +import { MediaCallLogger } from './MediaCallLogger'; + +const mediaCallLogger = new MediaCallLogger(); +const TAG = '[useCallStore]'; const STALE_NATIVE_MS = 60_000; @@ -164,11 +167,7 @@ export const useCallStore = create((set, get) => ({ callStartTime: call.state === 'active' ? Date.now() : null }); - try { - InCallManager.start({ media: 'audio' }); - } catch (error) { - console.error('[VoIP] InCallManager.start failed:', error); - } + voipNative.call.startAudio(); // Subscribe to call events const handleStateChange = () => { @@ -186,7 +185,7 @@ export const useCallStore = create((set, get) => ({ // 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 ?? ''); + voipNative.call.markActive(callId ?? nativeAcceptedCallId ?? ''); } }; @@ -205,9 +204,10 @@ export const useCallStore = create((set, get) => ({ }; const handleEnded = () => { - get().resetNativeCallId(); - get().reset(); - Navigation.back(); + // Navigation.back() removed — CallNavRouter handles navigation after callEnded emits. + callLifecycle.end('remote').catch(error => { + mediaCallLogger.error(`${TAG} callLifecycle.end failed:`, error); + }); }; call.emitter.on('stateChange', handleStateChange); @@ -246,13 +246,8 @@ export const useCallStore = create((set, get) => ({ if (!call) return; const newSpeakerOn = !isSpeakerOn; - - try { - await InCallManager.setForceSpeakerphoneOn(newSpeakerOn); - set({ isSpeakerOn: newSpeakerOn }); - } catch (error) { - console.error('[VoIP] Failed to toggle speaker:', error); - } + await voipNative.call.setSpeaker(newSpeakerOn); + set({ isSpeakerOn: newSpeakerOn }); }, toggleFocus: () => { @@ -283,31 +278,21 @@ export const useCallStore = create((set, get) => ({ }, endCall: () => { - const { call, callId, nativeAcceptedCallId } = get(); - // UUID for the native call UI layer (react-native-callkeep on iOS and Android). - const callUuid = callId ?? nativeAcceptedCallId; - - if (call) { - call.hangup(); - } - - if (callUuid) { - terminateNativeCall(callUuid); - } - - get().resetNativeCallId(); - get().reset(); + // Delegate to CallLifecycle for idempotent, ordered teardown. + callLifecycle.end('local').catch(error => { + mediaCallLogger.error(`${TAG} callLifecycle.end failed:`, error); + }); }, reset: () => { const { nativeAcceptedCallId } = get(); cleanupCallListeners(); cancelStaleNativeTimer(); - try { - InCallManager.stop(); - } catch (error) { - console.error('[VoIP] InCallManager.stop failed:', error); - } + // NOTE: stopAudio is intentionally NOT called here. + // CallLifecycle.end() calls voipNative.call.stopAudio() as step 6 (after reset), + // ensuring subscribers see consistent JS state when callEnded emits. + // If reset() is called outside of CallLifecycle (e.g., on session teardown), + // stopAudio is a safe no-op if audio was not started. set({ ...initialState, nativeAcceptedCallId }); hideActionSheetRef(); // Old timer was cleared above; start a new one if nativeAcceptedCallId is still set. diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index bf2d265a145..4c64a52562a 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -1,8 +1,9 @@ import { InteractionManager } from 'react-native'; -import RNCallKeep from 'react-native-callkeep'; import I18n from 'i18n-js'; import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects'; +import { voipNative } from '../lib/services/voip/VoipNative'; + import { shareSetParams } from '../actions/share'; import * as types from '../actions/actionsTypes'; import { appInit, appStart, appReady } from '../actions/app'; @@ -27,6 +28,7 @@ import { loginOAuthOrSso } from '../lib/services/connect'; import { notifyUser } from '../lib/services/restApi'; import sdk from '../lib/services/sdk'; import Navigation, { waitForNavigationReady } from '../lib/navigation/appNavigation'; +import { callLifecycle } from '../lib/services/voip/CallLifecycle'; import { resetVoipState } from '../lib/services/voip/resetVoipState'; const roomTypes = { @@ -84,11 +86,13 @@ const navigate = function* navigate({ params }) { */ const handleVoipAcceptFailed = function* handleVoipAcceptFailed(params) { try { - const { callId, username } = params; + const { username } = params; + // Delegate to CallLifecycle for idempotent, ordered teardown. + // 'error' reason: native accept failed pre-bind. + // Yield via redux-saga `call` to await teardown before resetVoipState/navigation, + // preventing a race where navigation lands while teardown is still in flight. + yield call([callLifecycle, callLifecycle.end], 'error'); resetVoipState(); - if (callId) { - RNCallKeep.endCall(callId); - } yield call(waitForNavigationReady);