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 08a966f8b57..d2b4ae81959 100644 --- a/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx +++ b/app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx @@ -448,12 +448,18 @@ describe('VoIP call lifecycle (integration)', () => { const { call } = useCallStore.getState(); expect(call?.callId).toBe('call-user-1'); - // Firing 'ended' triggers voipNative 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((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' }); - expect(Navigation.back).toHaveBeenCalled(); + // 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 () => { @@ -635,37 +641,53 @@ 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((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 → voipNative 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(); }); + // 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) + voipNative 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(); @@ -800,7 +822,7 @@ describe('VoIP call lifecycle (integration)', () => { expect(useCallStore.getState().isOnHold).toBe(true); }); - it('D3: press end button → call.hangup, voipNative.call.end, 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,8 +834,10 @@ 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(); 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.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index baa0a164a4d..8c202fd4efb 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -58,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()) })); @@ -254,6 +261,20 @@ describe('createVoipEventDispatcher — acceptFailed', () => { expect(handled).toBe(true); }); + + // 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' }); + + dispatch({ type: 'acceptFailed', payload, fromColdStart: false }); + + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('failed-needs-id'); + }); }); describe('createVoipEventDispatcher — hold', () => { @@ -351,11 +372,11 @@ describe('createVoipEventDispatcher — hold', () => { describe('createVoipEventDispatcher — endCall', () => { beforeEach(() => jest.clearAllMocks()); - it('calls mediaSessionInstance.endCall with callUuid', () => { - const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); + 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(mediaSessionInstance.endCall).toHaveBeenCalledWith('end-uuid'); + expect(callLifecycle.end).toHaveBeenCalledWith('remote'); }); }); diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 33e0bc1d2fd..74af52d3c08 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -1,6 +1,7 @@ import { isIOS, normalizeDeepLinkingServerHost } from '../../methods/helpers'; import type { VoipPayload } from '../../../definitions/Voip'; import { registerPushToken } from '../restApi'; +import { callLifecycle } from './CallLifecycle'; import { MediaCallLogger } from './MediaCallLogger'; import { mediaSessionInstance } from './MediaSessionInstance'; import { useCallStore } from './useCallStore'; @@ -67,6 +68,11 @@ function handleAcceptSucceededEvent(payload: VoipPayload, adapters: MediaCallEve 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({ host: payload.host, callId: payload.callId, @@ -88,7 +94,9 @@ export function createVoipEventDispatcher(adapters: MediaCallEventsAdapters): (e switch (e.type) { case 'endCall': { mediaCallLogger.log(`${TAG} End call event listener:`, e.callUuid); - mediaSessionInstance.endCall(e.callUuid); + callLifecycle.end('remote').catch(error => { + mediaCallLogger.error(`${TAG} callLifecycle.end failed:`, error); + }); return false; } diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index aa58dc7df4e..304b2c20106 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -8,6 +8,7 @@ import { getDMSubscriptionByUsername } from '../../database/services/Subscriptio 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() @@ -800,22 +801,85 @@ describe('MediaSessionInstance', () => { }); }); + 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('records markAvailable on voipNative when call is found and hung up', async () => { + 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 session = createdSessions[0]; - const mainCall = { - callId: 'end-1', - state: 'active', - hangup: jest.fn(), - reject: jest.fn() - }; - session.getCallData.mockReturnValue(mainCall); - (voipNative as InMemoryVoipNative).reset(); + const endSpy = jest.spyOn(callLifecycle, 'end').mockResolvedValue(undefined); mediaSessionInstance.endCall('end-1'); - expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markAvailable', callUuid: '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 1fdf5a47294..50a03692d5a 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -15,12 +15,13 @@ import { dequal } from 'dequal'; import { mediaSessionStore } from './MediaSessionStore'; 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'; @@ -137,7 +138,18 @@ class MediaSessionInstance { } call.emitter.on('ended', () => { - voipNative.call.end(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); + }); }); } }); @@ -156,7 +168,7 @@ class MediaSessionInstance { 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); @@ -206,20 +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(); - } - } - voipNative.call.end(callId); - voipNative.call.markAvailable(callId); - 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/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index f4921510d0d..1cbd5bc9ee6 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -327,9 +327,13 @@ describe('useCallStore audio commands via VoipNative seam', () => { expect(adapter.recorded).toContainEqual({ cmd: 'startAudio' }); }); - it('reset records stopAudio on voipNative', () => { + 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).toContainEqual({ cmd: 'stopAudio' }); + expect(adapter.recorded).not.toContainEqual({ cmd: 'stopAudio' }); }); it('toggleSpeaker records setSpeaker(true) when speaker was off', async () => { diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 09ccc4b73d9..846141f0555 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -5,6 +5,11 @@ 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; @@ -199,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); @@ -272,27 +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) { - voipNative.call.end(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(); - voipNative.call.stopAudio(); + // 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 e934a3de144..4c64a52562a 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -28,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 = { @@ -85,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) { - voipNative.call.end(callId); - } yield call(waitForNavigationReady);