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 a7ad08a385e..cc8596324d4 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'; @@ -449,12 +448,16 @@ 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. + // 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. act(() => { (call!.emitter as unknown as ReturnType).emit('ended'); }); 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 () => { @@ -565,7 +568,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'); }); @@ -641,30 +644,39 @@ describe('VoIP call lifecycle (integration)', () => { }); expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' }); - expect(InCallManager.stop as jest.Mock).toHaveBeenCalled(); + // 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]; + // 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); + }); act(() => { mediaSessionInstance.endCall('active-1'); }); + // 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(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith(''); - expect(RNCallKeep.setAvailable as jest.Mock).toHaveBeenCalledWith(true); + 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); + // 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); + }); act(() => { mediaSessionInstance.endCall('ringing-1'); @@ -749,13 +761,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); }); }); @@ -846,7 +858,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/lib/services/voip/CallLifecycle.test.ts b/app/lib/services/voip/CallLifecycle.test.ts new file mode 100644 index 00000000000..6c4b1723a72 --- /dev/null +++ b/app/lib/services/voip/CallLifecycle.test.ts @@ -0,0 +1,919 @@ +/** + * 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 + */ + +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() +})); + +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'; + +// ── 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 native commands in the documented order: end → markActive → markAvailable → stopAudio', 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'); + + const recorded = native.recorded; + 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('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('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('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('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('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('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('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 MediaCall hangup/reject 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)); + 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(); + }); + }); + + describe('teardown clears private auto-hold flag', () => { + it('_wasAutoHeld resets after end() so a JS-held next call does not auto-resume', async () => { + // Why: callLifecycle is a module singleton and _wasAutoHeld persists between + // distinct calls. If teardown leaves it set, a subsequent OS hold:false on + // the next call would spuriously issue markActive (auto-resume path) even + // though the next call was held by JS, never auto-held by the OS. + const participantA = { + local: true, + role: 'caller' as const, + muted: false, + held: false, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }; + const callA = makeCall({ callId: 'auto-held-a' }); + (callA as any).localParticipant = participantA; + useCallStore.getState().setCall(callA); + useCallStore.setState({ callId: 'auto-held-a' }); + + // OS auto-holds the first call (sets _wasAutoHeld=true internally). + callLifecycle.toggle('hold', 'native', 'auto-held-a', true); + native.reset(); + + // End the call — should reset _wasAutoHeld. + await callLifecycle.end('remote'); + + // Start a new call that is JS-held (e.g. user pressed hold from in-app UI). + const participantB = { + local: true, + role: 'caller' as const, + muted: false, + held: true, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }; + const callB = makeCall({ callId: 'auto-held-b' }); + (callB as any).localParticipant = participantB; + useCallStore.getState().setCall(callB); + useCallStore.setState({ callId: 'auto-held-b', isOnHold: true }); + native.reset(); + + // OS-driven hold:false on the new call — since the previous call's + // _wasAutoHeld must not bleed into this one, no markActive should fire. + callLifecycle.toggle('hold', 'native', 'auto-held-b', false); + + expect(useCallStore.getState().isOnHold).toBe(false); + expect(native.recorded).not.toContainEqual(expect.objectContaining({ cmd: 'markActive' })); + }); + }); +}); + +// ── toggle() ───────────────────────────────────────────────────────────────── + +/** + * Helpers shared across toggle describe blocks. + */ +function makeParticipant() { + return { + local: true, + role: 'caller' as const, + muted: false, + held: false, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }; +} + +function makeToggleCall(options: { callId: string; muted?: boolean; held?: boolean }) { + const participant = makeParticipant(); + if (options.muted) participant.muted = true; + if (options.held) participant.held = true; + const call = makeCall({ callId: options.callId }); + // Override localParticipant with one that has setMuted/setHeld + (call as any).localParticipant = participant; + return { call, participant }; +} + +describe('CallLifecycle.toggle(kind, source)', () => { + let native: InMemoryVoipNative; + + beforeEach(() => { + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + native = makeNative(); + native.reset(); + }); + + // ── mute / 'js' ────────────────────────────────────────────────────────── + + describe("toggle('mute', 'js')", () => { + it('updates isMuted in store', () => { + const { call } = makeToggleCall({ callId: 'mute-js-1' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-js-1' }); + native.reset(); + + callLifecycle.toggle('mute', 'js'); + + expect(useCallStore.getState().isMuted).toBe(true); + }); + + it('calls setMuted on localParticipant', () => { + const { call, participant } = makeToggleCall({ callId: 'mute-js-2' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-js-2' }); + native.reset(); + + callLifecycle.toggle('mute', 'js'); + + expect(participant.setMuted).toHaveBeenCalledWith(true); + }); + + it('records ZERO voipNative commands (no RNCallKeep setMuted command — forward-compat scaffolding)', () => { + const { call } = makeToggleCall({ callId: 'mute-js-3' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-js-3' }); + native.reset(); + + callLifecycle.toggle('mute', 'js'); + + expect(native.recorded).toHaveLength(0); + }); + + it('toggles from muted to unmuted', () => { + const { call, participant } = makeToggleCall({ callId: 'mute-js-4', muted: true }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-js-4', isMuted: true }); + native.reset(); + + callLifecycle.toggle('mute', 'js'); + + expect(useCallStore.getState().isMuted).toBe(false); + expect(participant.setMuted).toHaveBeenCalledWith(false); + }); + + it("defaults source to 'js' when not specified", () => { + const { call } = makeToggleCall({ callId: 'mute-default-1' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-default-1' }); + native.reset(); + + callLifecycle.toggle('mute'); + + expect(useCallStore.getState().isMuted).toBe(true); + expect(native.recorded).toHaveLength(0); + }); + }); + + // ── mute / 'native' — echo prevention ──────────────────────────────────── + + describe("toggle('mute', 'native') — echo prevention", () => { + it('updates isMuted in store', () => { + const { call } = makeToggleCall({ callId: 'mute-native-1' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-native-1' }); + native.reset(); + + callLifecycle.toggle('mute', 'native'); + + expect(useCallStore.getState().isMuted).toBe(true); + }); + + it('calls setMuted on localParticipant', () => { + const { call, participant } = makeToggleCall({ callId: 'mute-native-2' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-native-2' }); + native.reset(); + + callLifecycle.toggle('mute', 'native'); + + expect(participant.setMuted).toHaveBeenCalledWith(true); + }); + + it('records ZERO voipNative commands (echo prevention)', () => { + const { call } = makeToggleCall({ callId: 'mute-native-3' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'mute-native-3' }); + native.reset(); + + callLifecycle.toggle('mute', 'native'); + + // Echo prevention: native source must never issue a voipNative command back. + // (Contrast with speaker where 'js' records setSpeaker but 'native' must not.) + expect(native.recorded).toHaveLength(0); + }); + }); + + // ── hold / 'js' ────────────────────────────────────────────────────────── + + describe("toggle('hold', 'js')", () => { + it('updates isOnHold in store', () => { + const { call } = makeToggleCall({ callId: 'hold-js-1' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-js-1' }); + native.reset(); + + callLifecycle.toggle('hold', 'js'); + + expect(useCallStore.getState().isOnHold).toBe(true); + }); + + it('calls setHeld on localParticipant', () => { + const { call, participant } = makeToggleCall({ callId: 'hold-js-2' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-js-2' }); + native.reset(); + + callLifecycle.toggle('hold', 'js'); + + expect(participant.setHeld).toHaveBeenCalledWith(true); + }); + + it('records ZERO voipNative commands (no RNCallKeep setHeld command)', () => { + const { call } = makeToggleCall({ callId: 'hold-js-3' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-js-3' }); + native.reset(); + + callLifecycle.toggle('hold', 'js'); + + expect(native.recorded).toHaveLength(0); + }); + + it('toggles from held to unheld', () => { + const { call, participant } = makeToggleCall({ callId: 'hold-js-4', held: true }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-js-4', isOnHold: true }); + native.reset(); + + callLifecycle.toggle('hold', 'js'); + + expect(useCallStore.getState().isOnHold).toBe(false); + expect(participant.setHeld).toHaveBeenCalledWith(false); + }); + }); + + // ── hold / 'native' — echo prevention + wasAutoHeld ────────────────────── + + describe("toggle('hold', 'native') — echo prevention + auto-resume", () => { + it('records ZERO voipNative commands when going on hold (echo prevention)', () => { + const { call } = makeToggleCall({ callId: 'hold-native-1' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-native-1' }); + native.reset(); + + callLifecycle.toggle('hold', 'native'); + + // No echo: the OS told us to hold, we must not echo back a hold command. + expect(native.recorded).toHaveLength(0); + }); + + it('updates isOnHold in store when toggling to held', () => { + const { call } = makeToggleCall({ callId: 'hold-native-2' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-native-2' }); + native.reset(); + + callLifecycle.toggle('hold', 'native'); + + expect(useCallStore.getState().isOnHold).toBe(true); + }); + + it('calls setHeld on localParticipant', () => { + const { call, participant } = makeToggleCall({ callId: 'hold-native-3' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-native-3' }); + native.reset(); + + callLifecycle.toggle('hold', 'native'); + + expect(participant.setHeld).toHaveBeenCalledWith(true); + }); + + it('auto-resume: hold→false after auto-hold issues markActive (the documented per-kind exception)', () => { + const { call } = makeToggleCall({ callId: 'hold-native-4' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-native-4' }); + native.reset(); + + // OS places call on hold + callLifecycle.toggle('hold', 'native'); // wasAutoHeld = true, isOnHold = true + native.reset(); + + // OS releases hold + useCallStore.setState({ isOnHold: true }); // reflect current state + callLifecycle.toggle('hold', 'native'); // should call markActive + + expect(native.recorded).toContainEqual({ cmd: 'markActive', callUuid: 'hold-native-4' }); + }); + + it('no markActive when hold→false without prior auto-hold (manual-resume path)', () => { + const { call } = makeToggleCall({ callId: 'hold-native-5' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-native-5', isOnHold: true }); + native.reset(); + + // No prior auto-hold — goes false directly + callLifecycle.toggle('hold', 'native'); + + expect(native.recorded).not.toContainEqual(expect.objectContaining({ cmd: 'markActive' })); + }); + + it('wasAutoHeld is private to CallLifecycle — not in useCallStore', () => { + // wasAutoHeld must live as private state on CallLifecycle, not in the store. + // Verify the store has no wasAutoHeld property. + const storeState = useCallStore.getState(); + expect(storeState).not.toHaveProperty('wasAutoHeld'); + }); + + // ── Idempotency: targetValue matching current state is a no-op ──────── + + it('hold: redundant hold:true while already held is a no-op', () => { + // Why: OS may send a second hold:true while the call is already held. Without + // the targetValue idempotency check the toggle would flip to UNHELD and could + // fire a spurious markActive. + const { call, participant } = makeToggleCall({ callId: 'hold-native-redundant' }); + useCallStore.getState().setCall(call); + // Simulate call already held (e.g. by a prior OS event or JS toggle). + useCallStore.setState({ callId: 'hold-native-redundant', isOnHold: true }); + native.reset(); + + // OS sends redundant hold:true — must be a complete no-op. + callLifecycle.toggle('hold', 'native', 'hold-native-redundant', true); + + // Store unchanged. + expect(useCallStore.getState().isOnHold).toBe(true); + // No native commands (no markActive, no setSpeaker). + expect(native.recorded).toHaveLength(0); + // Participant setHeld was not called. + expect(participant.setHeld).not.toHaveBeenCalled(); + }); + + it('hold: hold:false after manual user-resume is a no-op', () => { + // Why: OS may deliver a delayed hold:false AFTER the user already resumed + // manually. Without idempotency the toggle would flip back to HELD and set + // _wasAutoHeld=true, which would then trigger a spurious markActive next time. + const { call, participant } = makeToggleCall({ callId: 'hold-native-late-resume' }); + useCallStore.getState().setCall(call); + // Simulate call not on hold and _wasAutoHeld=false (user already resumed manually). + useCallStore.setState({ callId: 'hold-native-late-resume', isOnHold: false }); + // Ensure _wasAutoHeld is false (no prior auto-hold that was not cleared). + // We verify indirectly: a subsequent markActive should NOT fire. + native.reset(); + + // OS sends delayed hold:false — must be a complete no-op. + callLifecycle.toggle('hold', 'native', 'hold-native-late-resume', false); + + // Store unchanged. + expect(useCallStore.getState().isOnHold).toBe(false); + // No native commands (no spurious markActive). + expect(native.recorded).toHaveLength(0); + // Participant setHeld was not called. + expect(participant.setHeld).not.toHaveBeenCalled(); + }); + + it('stale-UUID hold event clears _wasAutoHeld', () => { + // A stale hold event (wrong UUID) must defensively clear _wasAutoHeld so that + // a dead-call's auto-held flag cannot affect the next call's auto-resume path. + const { call } = makeToggleCall({ callId: 'hold-native-stale' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-native-stale' }); + native.reset(); + + // First: auto-hold the active call (sets _wasAutoHeld=true). + callLifecycle.toggle('hold', 'native', 'hold-native-stale', true); + expect(useCallStore.getState().isOnHold).toBe(true); + native.reset(); + + // Now: stale hold event from a different call UUID — must clear _wasAutoHeld. + callLifecycle.toggle('hold', 'native', 'WRONG-UUID', true); + + // Verify _wasAutoHeld was cleared by asserting indirect behaviour: + // a subsequent hold:false on the active call must NOT issue markActive + // (because _wasAutoHeld was cleared by the stale-UUID drop above). + callLifecycle.toggle('hold', 'native', 'hold-native-stale', false); + + // No markActive should have been recorded. + expect(native.recorded).not.toContainEqual(expect.objectContaining({ cmd: 'markActive' })); + }); + }); + + // ── speaker / 'js' ─────────────────────────────────────────────────────── + + describe("toggle('speaker', 'js')", () => { + it('records setSpeaker(true) when speaker was off', async () => { + const { call } = makeToggleCall({ callId: 'spk-js-1' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'spk-js-1', isSpeakerOn: false }); + native.reset(); + + await callLifecycle.toggle('speaker', 'js'); + + expect(native.recorded).toContainEqual({ cmd: 'setSpeaker', on: true }); + }); + + it('records setSpeaker(false) when speaker was on', async () => { + const { call } = makeToggleCall({ callId: 'spk-js-2' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'spk-js-2', isSpeakerOn: true }); + native.reset(); + + await callLifecycle.toggle('speaker', 'js'); + + expect(native.recorded).toContainEqual({ cmd: 'setSpeaker', on: false }); + }); + + it('updates isSpeakerOn in store', async () => { + const { call } = makeToggleCall({ callId: 'spk-js-3' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'spk-js-3', isSpeakerOn: false }); + native.reset(); + + await callLifecycle.toggle('speaker', 'js'); + + expect(useCallStore.getState().isSpeakerOn).toBe(true); + }); + }); + + describe("toggle('speaker', 'native') — reserved, records no commands", () => { + it('records ZERO voipNative commands (out-of-scope for slice 07)', async () => { + // Speaker 'native' source is reserved for future audio-route-sync work. + // For now it must be a no-op so audio-route-sync still works via setState directly. + const { call } = makeToggleCall({ callId: 'spk-native-1' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'spk-native-1', isSpeakerOn: false }); + native.reset(); + + await callLifecycle.toggle('speaker', 'native'); + + expect(native.recorded).toHaveLength(0); + }); + + it('directional assertion: js records setSpeaker, native does not', async () => { + // This is the key directional test that makes the echo-prevention contract falsifiable. + // For speaker, 'js' issues a command and 'native' must not — clear directionality. + const { call: call1 } = makeToggleCall({ callId: 'spk-dir-1' }); + useCallStore.getState().setCall(call1); + useCallStore.setState({ callId: 'spk-dir-1', isSpeakerOn: false }); + native.reset(); + await callLifecycle.toggle('speaker', 'js'); + const jsCommands = [...native.recorded]; + + const { call: call2 } = makeToggleCall({ callId: 'spk-dir-2' }); + useCallStore.getState().setCall(call2); + useCallStore.setState({ callId: 'spk-dir-2', isSpeakerOn: false }); + native.reset(); + await callLifecycle.toggle('speaker', 'native'); + const nativeCommands = [...native.recorded]; + + expect(jsCommands).toContainEqual(expect.objectContaining({ cmd: 'setSpeaker' })); + expect(nativeCommands).toHaveLength(0); + }); + }); + + // ── stale-UUID drop ─────────────────────────────────────────────────────── + + describe('stale-UUID drop', () => { + it('mute toggle is a no-op when callUuid does not match active callId or nativeAcceptedCallId', () => { + const { call } = makeToggleCall({ callId: 'active-call-uuid' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'active-call-uuid', nativeAcceptedCallId: null }); + native.reset(); + + // Provide a stale/mismatched UUID + callLifecycle.toggle('mute', 'native', 'stale-uuid-xyz'); + + // No store update, no participant call, no native command + expect(useCallStore.getState().isMuted).toBe(false); + expect(native.recorded).toHaveLength(0); + }); + + it('hold toggle is a no-op with mismatched UUID', () => { + const { call } = makeToggleCall({ callId: 'hold-active-uuid' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'hold-active-uuid', nativeAcceptedCallId: null }); + native.reset(); + + callLifecycle.toggle('hold', 'native', 'wrong-uuid'); + + expect(useCallStore.getState().isOnHold).toBe(false); + expect(native.recorded).toHaveLength(0); + }); + + it('matches callId case-insensitively', () => { + const { call } = makeToggleCall({ callId: 'MIXED-CASE-UUID' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'MIXED-CASE-UUID', nativeAcceptedCallId: null }); + native.reset(); + + // Lower-cased UUID should still match + callLifecycle.toggle('mute', 'native', 'mixed-case-uuid'); + + expect(useCallStore.getState().isMuted).toBe(true); + }); + + it('falls back to nativeAcceptedCallId when callId is null (Pre-bind)', () => { + const { call } = makeToggleCall({ callId: 'prebind-uuid' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: null, nativeAcceptedCallId: 'prebind-uuid' }); + native.reset(); + + callLifecycle.toggle('mute', 'native', 'prebind-uuid'); + + expect(useCallStore.getState().isMuted).toBe(true); + }); + + it('is a no-op when no UUID is provided and no callId exists', () => { + // No active call at all + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + native.reset(); + + // No UUID provided, no active call + callLifecycle.toggle('mute', 'native', 'some-uuid'); + + expect(useCallStore.getState().isMuted).toBe(false); + }); + + it('stale-UUID drop applies uniformly to all kinds — no isIOS branch', () => { + // Mute, hold, speaker must all respect the UUID guard regardless of platform. + const { call } = makeToggleCall({ callId: 'uniform-uuid' }); + useCallStore.getState().setCall(call); + useCallStore.setState({ callId: 'uniform-uuid' }); + native.reset(); + + callLifecycle.toggle('mute', 'native', 'wrong'); + callLifecycle.toggle('hold', 'native', 'wrong'); + + expect(useCallStore.getState().isMuted).toBe(false); + expect(useCallStore.getState().isOnHold).toBe(false); + }); + }); + + // ── no-op when no active call ───────────────────────────────────────────── + + describe('no-op without active call', () => { + it('mute toggle without call is a no-op', () => { + native.reset(); + callLifecycle.toggle('mute', 'js'); + expect(useCallStore.getState().isMuted).toBe(false); + expect(native.recorded).toHaveLength(0); + }); + + it('hold toggle without call is a no-op', () => { + native.reset(); + callLifecycle.toggle('hold', 'js'); + expect(useCallStore.getState().isOnHold).toBe(false); + expect(native.recorded).toHaveLength(0); + }); + + it('speaker toggle without call is a no-op', async () => { + native.reset(); + await callLifecycle.toggle('speaker', 'js'); + expect(useCallStore.getState().isSpeakerOn).toBe(false); + expect(native.recorded).toHaveLength(0); + }); + }); +}); diff --git a/app/lib/services/voip/CallLifecycle.ts b/app/lib/services/voip/CallLifecycle.ts new file mode 100644 index 00000000000..ced9017ef5a --- /dev/null +++ b/app/lib/services/voip/CallLifecycle.ts @@ -0,0 +1,309 @@ +/** + * CallLifecycle — orchestrates call-state transitions. + * + * ## End-of-call teardown (CallLifecycle.end) + * + * 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 + * 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). + * + * ## Toggle transitions (CallLifecycle.toggle) + * + * `toggle(kind, source?, callUuid?, targetValue?)` handles mute, hold, and speaker toggles. + * + * `source` encodes where the intent originated: + * - `'js'`: user-initiated from the JS UI. Updates store, mutates localParticipant, + * and where applicable issues a voipNative command (e.g. setSpeaker). + * - `'native'`: OS-initiated (CallKit / Telecom). Updates store and mutates + * localParticipant ONLY — does NOT issue a voipNative command back, + * preventing the OS→JS→OS echo loop. + * Exception: `toggle('hold', 'native')` auto-resume issues `markActive` + * when the OS releases a hold it previously placed (wasAutoHeld). + * This is intentional — markActive is a different kind of command, + * not an echo of the hold event itself. + * + * `targetValue` — when provided, the toggle uses this as the desired new value rather + * than flipping the current value. This makes the toggle idempotent: if `targetValue` + * matches the current store value, the call is a no-op (no store change, no native + * command, no `_wasAutoHeld` mutation). Used by `'native'` callers to honour OS payload + * assertions (e.g. `e.hold`, `e.muted`). `'js'` callers omit `targetValue` and keep + * flip semantics unchanged. + * + * Stale-UUID drop: when `callUuid` is provided it must match the active callId or + * nativeAcceptedCallId (case-insensitive). Mismatched UUIDs are no-ops for mute and + * speaker; for `kind='hold'`, a stale-UUID drop also clears `_wasAutoHeld`. This applies + * uniformly to all kinds and both platforms — no isIOS branch. + * + * `wasAutoHeld` is private state owned by CallLifecycle (not useCallStore, not MediaCallEvents). + */ + +import { voipNative, type VoipNativePort } from './VoipNative'; +import { useCallStore } from './useCallStore'; + +// ── 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 +}; + +export type ToggleKind = 'mute' | 'hold' | 'speaker'; +export type ToggleSource = 'js' | 'native'; + +// ── 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; + for (const listener of set) { + listener(payload); + } + } +} + +// ── 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; + + /** + * Tracks whether the most recent hold was OS-initiated (auto-held by CallKit/Telecom). + * When the OS releases the hold, we re-issue `markActive` to restore the native UI. + * This is the only documented carve-out from the "native source issues no commands" rule. + * Owned here, not in useCallStore or MediaCallEvents. + */ + private _wasAutoHeld = false; + + /** + * 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; + } + this._endPromise = this._runTeardown(reason).finally(() => { + this._endPromise = null; + }); + return this._endPromise; + } + + /** + * Toggle mute, hold, or speaker state. + * + * @param kind — which toggle to perform + * @param source — `'js'` (user intent) or `'native'` (OS intent). Defaults to `'js'`. + * @param callUuid — optional UUID for stale-event validation. When provided, the toggle + * is a no-op if the UUID does not match the active call or nativeAcceptedCallId. + * For `kind='hold'`, a stale-UUID drop also clears `_wasAutoHeld`. + * @param targetValue — optional target value. When provided, the toggle uses this as the + * desired new value (idempotent: no-op when it matches current state). + * When omitted, flip semantics apply (current behaviour for 'js' callers). + * + * Returns `Promise` (only speaker is async; mute/hold resolve immediately). + */ + toggle(kind: ToggleKind, source: ToggleSource = 'js', callUuid?: string, targetValue?: boolean): Promise { + const native = this._voipNativeOverride ?? voipNative; + const { call, callId, nativeAcceptedCallId, isMuted, isOnHold, isSpeakerOn } = useCallStore.getState(); + + // ── Stale-UUID drop ────────────────────────────────────────────────────── + // When a callUuid is provided, validate it against the active call. + // Applies uniformly to all kinds and both platforms — no isIOS branch. + // For kind='hold', a stale drop also defensively clears _wasAutoHeld. + if (callUuid !== undefined) { + const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase(); + if (!activeUuid || callUuid.toLowerCase() !== activeUuid) { + if (kind === 'hold') { + // Defensive clear: a stale hold event for a dead call must not leave + // _wasAutoHeld=true, which could cause a spurious markActive on the + // next call's auto-resume path. + this._wasAutoHeld = false; + } + // Stale event — drop silently. + return Promise.resolve(); + } + } + + // ── Guard: require an active call for all toggle kinds ────────────────── + if (!call) { + return Promise.resolve(); + } + + switch (kind) { + case 'mute': { + // Derive effective new value: targetValue wins over flip semantics. + const newMuted = targetValue ?? !isMuted; + // Idempotent: if the target value already matches current state, do nothing. + if (newMuted === isMuted) return Promise.resolve(); + call!.localParticipant.setMuted(newMuted); + useCallStore.setState({ isMuted: newMuted }); + // Echo prevention: 'native' source does NOT issue a voipNative command. + // 'js' source also records no command today — no RNCallKeep setMuted exists. + // This guard is forward-compatibility scaffolding for when a native mute + // command lands (e.g. Android-only). The directionality is tested via speaker. + return Promise.resolve(); + } + + case 'hold': { + // Derive effective new value: targetValue wins over flip semantics. + const newHeld = targetValue ?? !isOnHold; + // Idempotent: if the target value already matches current state, do nothing. + // Honours OS payload assertions — a redundant hold:true while already held, + // or a late hold:false after the user already resumed, must be a no-op so we + // don't flip state, mutate _wasAutoHeld, or fire a spurious markActive. + if (newHeld === isOnHold) return Promise.resolve(); + call!.localParticipant.setHeld(newHeld); + useCallStore.setState({ isOnHold: newHeld }); + + if (source === 'native') { + if (newHeld) { + // OS placed the call on hold — record for auto-resume. + this._wasAutoHeld = true; + } else if (this._wasAutoHeld) { + // OS released the hold it previously placed — re-issue markActive. + // This is the documented per-kind exception to the no-echo rule: + // markActive is not an echo of the hold event; it restores native UI. + const effectiveCallId = callId ?? nativeAcceptedCallId ?? callUuid ?? ''; + if (effectiveCallId) { + native.call.markActive(effectiveCallId); + } + this._wasAutoHeld = false; + } + // No other voipNative commands for 'native' source. + } else { + // 'js' source: no RNCallKeep setHeld command exists today. + // Guard is forward-compatibility scaffolding (same as mute). + this._wasAutoHeld = false; + } + return Promise.resolve(); + } + + case 'speaker': { + if (source === 'native') { + // Reserved for future audio-route-sync work (slice follow-up). + // Out of scope for slice 07 — no-op here so audio-route-sync can + // continue writing isSpeakerOn directly via setState. + return Promise.resolve(); + } + // 'js' source: issue native command and update store. + // Speaker keeps flip semantics — targetValue is ignored (always flip for 'js'). + const newSpeakerOn = !isSpeakerOn; + return native.call.setSpeaker(newSpeakerOn).then(() => { + useCallStore.setState({ isSpeakerOn: newSpeakerOn }); + }); + } + } + } + + // 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; + + // Read the active call from useCallStore — MediaSessionInstance owns it. + const mediaCall = useCallStore.getState().call; + if (mediaCall) { + if ((mediaCall as any).state === 'ringing') { + mediaCall.reject(); + } else { + mediaCall.hangup(); + } + } + + if (effectiveCallId) { + native.call.end(effectiveCallId); + } + + native.call.markActive(''); + native.call.markAvailable(effectiveCallId ?? ''); + + // Reset JS state BEFORE stopAudio so that all callEnded subscribers see a + // consistent cleared store when audio actually stops. + useCallStore.getState().reset(); + + // callLifecycle is a module singleton — clear instance flags so the next + // call starts fresh and a stale auto-held bit cannot trigger a spurious + // markActive on the next OS hold:false event. + this._wasAutoHeld = false; + + native.call.stopAudio(); + + 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..59458824cc8 --- /dev/null +++ b/app/lib/services/voip/CallNavRouter.test.ts @@ -0,0 +1,191 @@ +/** + * 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 + */ + +// Mock navigation BEFORE importing the module under test. +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) +})); + +// Import after mocks are set up. +import { callLifecycle } from './CallLifecycle'; +import { CallNavRouter } from './CallNavRouter'; +import { emitter } from '../../methods/helpers'; +import Navigation from '../../navigation/appNavigation'; + +// ── 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); + }); + }); +}); diff --git a/app/lib/services/voip/CallNavRouter.ts b/app/lib/services/voip/CallNavRouter.ts new file mode 100644 index 00000000000..c95a5216c65 --- /dev/null +++ b/app/lib/services/voip/CallNavRouter.ts @@ -0,0 +1,64 @@ +/** + * 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 _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 = () => { + emitter.off('navigationReady', onceNavigationReady); + onNavigationReady(); + }; + emitter.on('navigationReady', onceNavigationReady); + } +} + +/** + * Unmount the router. Cleans up event listeners. + * Useful for testing or if the router needs to be reset. + */ +function unmount(): void { + _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 d782f2e43a9..60bfb46e5af 100644 --- a/app/lib/services/voip/MediaCallEvents.ios.test.ts +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -94,6 +94,14 @@ jest.mock('./MediaCallLogger', () => { }; }); +jest.mock('./CallLifecycle', () => ({ + callLifecycle: { + end: jest.fn(() => Promise.resolve()), + toggle: jest.fn(() => Promise.resolve()), + emitter: { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + } +})); + jest.mock('./VoipNative', () => ({ ...jest.requireActual('./VoipNative'), voipNative: { @@ -140,39 +148,36 @@ const activeCallBase = { }; describe('createVoipEventDispatcher — mute (iOS)', () => { - const toggleMute = jest.fn(); const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - toggleMute.mockClear(); - getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute }); + getState.mockReturnValue({ ...activeCallBase, isMuted: false }); }); - it('calls toggleMute when muted state differs from OS and UUIDs match', () => { + it('delegates to callLifecycle.toggle("mute", "native", uuid, true) with targetValue', () => { + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); - expect(toggleMute).toHaveBeenCalledTimes(1); + expect(mockLifecycle.toggle).toHaveBeenCalledWith('mute', 'native', 'uuid-1', true); }); - it('does not call toggleMute when muted state already matches OS even if UUIDs match', () => { - getState.mockReturnValue({ ...activeCallBase, isMuted: true, toggleMute }); + it('always calls toggle even when muted state matches OS (idempotency check moved to CallLifecycle)', () => { + // The dispatcher no longer guards — it always passes the OS targetValue to toggle. + // CallLifecycle.toggle handles the idempotency (no-op when targetValue === current state). + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); + getState.mockReturnValue({ ...activeCallBase, isMuted: true }); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); - expect(toggleMute).not.toHaveBeenCalled(); + expect(mockLifecycle.toggle).toHaveBeenCalledWith('mute', 'native', 'uuid-1', true); }); - it('drops event when callUUID does not match active call id', () => { + it('passes UUID and targetValue to toggle (stale-UUID validation happens in CallLifecycle)', () => { + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); 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 }); - const dispatch = createVoipEventDispatcher(makeTestAdapters()); - dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); - expect(toggleMute).not.toHaveBeenCalled(); + // Dispatcher passes uuid + targetValue; lifecycle drops stale UUIDs internally. + expect(mockLifecycle.toggle).toHaveBeenCalledWith('mute', 'native', 'uuid-2', true); }); }); diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index baa0a164a4d..71ce64e0547 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -58,6 +58,14 @@ jest.mock('./MediaSessionInstance', () => ({ } })); +jest.mock('./CallLifecycle', () => ({ + callLifecycle: { + end: jest.fn(() => Promise.resolve()), + toggle: jest.fn(() => Promise.resolve()), + emitter: { on: jest.fn(), off: jest.fn(), emit: jest.fn() } + } +})); + jest.mock('../restApi', () => ({ registerPushToken: jest.fn(() => Promise.resolve()) })); @@ -257,135 +265,100 @@ describe('createVoipEventDispatcher — acceptFailed', () => { }); describe('createVoipEventDispatcher — hold', () => { - const toggleHold = jest.fn(); const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - toggleHold.mockClear(); - getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold }); - }); - - 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); - }); - - it('hold: true when isOnHold is true does not call toggleHold', () => { - getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); - const dispatch = createVoipEventDispatcher(makeTestAdapters()); - dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); - expect(toggleHold).not.toHaveBeenCalled(); + getState.mockReturnValue({ ...activeCallBase, isOnHold: false }); }); - it('hold: false after OS-initiated hold calls toggleHold and markActive', () => { - const { voipNative: mockVoipNative } = jest.requireMock('./VoipNative'); + it('hold: true delegates to callLifecycle.toggle("hold", "native", uuid, true)', () => { + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); - getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); - dispatch({ type: 'hold', hold: false, callUuid: 'uuid-1' }); - expect(toggleHold).toHaveBeenCalledTimes(2); - expect(mockVoipNative.call.markActive).toHaveBeenCalledWith('uuid-1'); + expect(mockLifecycle.toggle).toHaveBeenCalledWith('hold', 'native', 'uuid-1', true); }); - it('hold: false without prior OS hold does not call toggleHold', () => { + it('hold: false delegates to callLifecycle.toggle("hold", "native", uuid, false)', () => { + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'hold', hold: false, callUuid: 'uuid-1' }); - expect(toggleHold).not.toHaveBeenCalled(); + expect(mockLifecycle.toggle).toHaveBeenCalledWith('hold', 'native', 'uuid-1', false); }); - it('consecutive hold: true calls toggleHold only once', () => { + it('hold: true — idempotency and isOnHold check are in CallLifecycle (targetValue passed)', () => { + // Dispatcher always calls toggle with the OS payload's targetValue. + // CallLifecycle.toggle handles idempotency (no-op when targetValue === current state). + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); + getState.mockReturnValue({ ...activeCallBase, isOnHold: true }); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); - getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); - dispatch({ type: 'hold', hold: true, callUuid: 'uuid-1' }); - expect(toggleHold).toHaveBeenCalledTimes(1); + // toggle is always called with the OS-asserted targetValue; lifecycle handles idempotency. + expect(mockLifecycle.toggle).toHaveBeenCalledWith('hold', 'native', 'uuid-1', true); }); - it('drops event when callUUID does not match active call', () => { + it('drops event when callUUID does not match active call (stale-UUID drop is in CallLifecycle)', () => { + // The dispatcher no longer does UUID checking itself — it passes uuid + targetValue to toggle. + // CallLifecycle.toggle does the stale-UUID drop. The dispatcher always calls toggle. + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); 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({ ...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); - 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(mockVoipNative.call.markActive).not.toHaveBeenCalled(); + // Dispatcher passes uuid + targetValue to lifecycle; lifecycle drops the stale event. + expect(mockLifecycle.toggle).toHaveBeenCalledWith('hold', 'native', 'uuid-2', true); }); }); 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'); }); }); describe('createVoipEventDispatcher — mute', () => { - const toggleMute = jest.fn(); const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - toggleMute.mockClear(); - getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute }); + getState.mockReturnValue({ ...activeCallBase, isMuted: false }); }); - it('calls toggleMute when muted differs from OS and UUIDs match', () => { + it('muted: true delegates to callLifecycle.toggle("mute", "native", uuid, true)', () => { + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); - expect(toggleMute).toHaveBeenCalledTimes(1); + expect(mockLifecycle.toggle).toHaveBeenCalledWith('mute', 'native', 'uuid-1', true); }); - it('does not call toggleMute when muted state already matches', () => { - getState.mockReturnValue({ ...activeCallBase, isMuted: true, toggleMute }); + it('always calls toggle (idempotency check is now in CallLifecycle, not the dispatcher)', () => { + // The dispatcher no longer guards against redundant calls — it always passes the OS + // payload's targetValue to toggle. CallLifecycle.toggle handles the idempotency check. + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); + getState.mockReturnValue({ ...activeCallBase, isMuted: true }); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'mute', muted: true, callUuid: 'uuid-1' }); - expect(toggleMute).not.toHaveBeenCalled(); + // toggle is always called; lifecycle no-ops when targetValue matches current state. + expect(mockLifecycle.toggle).toHaveBeenCalledWith('mute', 'native', 'uuid-1', true); + }); + + it('muted: false delegates to callLifecycle.toggle("mute", "native", uuid, false)', () => { + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); + getState.mockReturnValue({ ...activeCallBase, isMuted: true }); + const dispatch = createVoipEventDispatcher(makeTestAdapters()); + dispatch({ type: 'mute', muted: false, callUuid: 'uuid-1' }); + expect(mockLifecycle.toggle).toHaveBeenCalledWith('mute', 'native', 'uuid-1', false); }); - it('drops event when UUID does not match', () => { + it('passes UUID and targetValue to toggle for stale-UUID validation in CallLifecycle', () => { + const { callLifecycle: mockLifecycle } = jest.requireMock('./CallLifecycle'); const dispatch = createVoipEventDispatcher(makeTestAdapters()); dispatch({ type: 'mute', muted: true, callUuid: 'uuid-2' }); - expect(toggleMute).not.toHaveBeenCalled(); + // Dispatcher passes uuid + targetValue; CallLifecycle.toggle does the stale-UUID drop. + expect(mockLifecycle.toggle).toHaveBeenCalledWith('mute', 'native', 'uuid-2', true); }); }); diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 33e0bc1d2fd..eea72520b44 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -1,10 +1,11 @@ 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'; -import { voipNative, type VoipNativeEvent } from './VoipNative'; +import type { VoipNativeEvent } from './VoipNative'; const platform = isIOS ? 'iOS' : 'Android'; const TAG = `[MediaCallEvents][${platform}]`; @@ -80,53 +81,32 @@ function handleAcceptFailedEvent(payload: VoipPayload, adapters: MediaCallEvents * 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. + * + * Mute and hold events delegate to `callLifecycle.toggle(kind, 'native', callUuid, targetValue)`. + * UUID validation, idempotency, echo prevention, and wasAutoHeld state all live in CallLifecycle. */ export function createVoipEventDispatcher(adapters: MediaCallEventsAdapters): (e: VoipNativeEvent) => boolean { - let wasAutoHeld = false; - return function dispatchVoipNativeEvent(e: VoipNativeEvent): boolean { switch (e.type) { case 'endCall': { mediaCallLogger.log(`${TAG} End call event listener:`, e.callUuid); - mediaSessionInstance.endCall(e.callUuid); + callLifecycle.end('remote'); return false; } case 'mute': { - const { call, callId, nativeAcceptedCallId, toggleMute, isMuted } = useCallStore.getState(); - const eventUuid = e.callUuid.toLowerCase(); - const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase(); - if (!call || !activeUuid || eventUuid !== activeUuid) { - return false; - } - if (e.muted !== isMuted) { - toggleMute(); - } + // Pass e.muted as targetValue so CallLifecycle can honour the OS assertion + // and skip the toggle when the store already reflects the OS state (idempotent). + // The dispatcher guard (isMuted check) is no longer needed — lifecycle handles it. + callLifecycle.toggle('mute', 'native', e.callUuid, e.muted); return false; } 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; - } - if (e.hold) { - if (!isOnHold) { - toggleHold(); - wasAutoHeld = true; - } - return false; - } - if (wasAutoHeld) { - if (isOnHold) { - toggleHold(); - voipNative.call.markActive(e.callUuid); - } - wasAutoHeld = false; - } + // Pass e.hold as targetValue so CallLifecycle can honour the OS assertion and + // skip the toggle when the store already reflects the OS state (idempotent). + // Without this, redundant or late OS hold events would flip the state spuriously. + callLifecycle.toggle('hold', 'native', e.callUuid, e.hold); return false; } diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index aa58dc7df4e..c986b42be54 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() @@ -801,21 +802,17 @@ describe('MediaSessionInstance', () => { }); 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..0b7ab55a3ae 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,8 @@ class MediaSessionInstance { } call.emitter.on('ended', () => { - voipNative.call.end(call.callId); + // Route through CallLifecycle for idempotent, ordered teardown. + callLifecycle.end('remote'); }); } }); @@ -156,7 +158,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 +208,9 @@ 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'); }; private async resolveRoomIdFromContact(contact: CallContact | undefined): Promise { diff --git a/app/lib/services/voip/VoipNative.ts b/app/lib/services/voip/VoipNative.ts index 1b13a43cf52..79534524a2d 100644 --- a/app/lib/services/voip/VoipNative.ts +++ b/app/lib/services/voip/VoipNative.ts @@ -54,8 +54,9 @@ export class InMemoryVoipNative implements VoipNativePort { markAvailable: (callUuid: string) => { this.recorded.push({ cmd: 'markAvailable', callUuid }); }, - setSpeaker: async (on: boolean) => { + setSpeaker: (on: boolean) => { this.recorded.push({ cmd: 'setSpeaker', on }); + return Promise.resolve(); }, startAudio: () => { this.recorded.push({ cmd: 'startAudio' }); @@ -69,18 +70,18 @@ export class InMemoryVoipNative implements VoipNativePort { this.recorded.splice(0); } - async attach(opts: { onEvent(e: VoipNativeEvent): void }): Promise<{ detach(): void; pushToken: string }> { + 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 { + return Promise.resolve({ detach: () => { this._onEvent = null; }, pushToken: '' - }; + }); } __emit(event: VoipNativeEvent): void { @@ -119,16 +120,12 @@ class ProductionVoipNative implements VoipNativePort { markActive: (callUuid: string) => { RNCallKeep.setCurrentCallActive(callUuid); }, - markAvailable: (callUuid: string) => { + markAvailable: (_callUuid: string) => { RNCallKeep.setCurrentCallActive(''); RNCallKeep.setAvailable(true); }, setSpeaker: async (on: boolean) => { - if (Platform.OS === 'ios') { - await InCallManager.setForceSpeakerphoneOn(on); - } else { - await NativeVoipModule.setSpeakerOn(on); - } + await InCallManager.setForceSpeakerphoneOn(on); }, startAudio: () => { InCallManager.start({ media: 'audio' }); diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index b1af569d6b4..af6b1bee157 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 () => { @@ -370,3 +374,69 @@ describe('useCallStore audio commands via VoipNative seam', () => { expect(adapter.recorded).toContainEqual({ cmd: 'markActive', callUuid: 'mark-1' }); }); }); + +describe('useCallStore toggle delegates — one-line delegate pattern', () => { + // These tests verify that toggleMute, toggleHold, and toggleSpeaker are + // one-line delegates to callLifecycle.toggle with no direct native imports. + // Observable behavior (store updates, native commands) is already covered by + // CallLifecycle.test.ts. Here we verify the delegation itself. + const adapter = voipNative as InMemoryVoipNative; + + beforeEach(() => { + adapter.reset(); + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + }); + + it('toggleMute delegates to callLifecycle — updates isMuted in store', () => { + const { call } = createMockCall('delegate-mute-1'); + useCallStore.getState().setCall(call); + adapter.reset(); + + useCallStore.getState().toggleMute(); + + expect(useCallStore.getState().isMuted).toBe(true); + }); + + it('toggleMute records zero voipNative commands (delegate, no direct native imports)', () => { + const { call } = createMockCall('delegate-mute-2'); + useCallStore.getState().setCall(call); + adapter.reset(); + + useCallStore.getState().toggleMute(); + + expect(adapter.recorded).toHaveLength(0); + }); + + it('toggleHold delegates to callLifecycle — updates isOnHold in store', () => { + const { call } = createMockCall('delegate-hold-1'); + useCallStore.getState().setCall(call); + adapter.reset(); + + useCallStore.getState().toggleHold(); + + expect(useCallStore.getState().isOnHold).toBe(true); + }); + + it('toggleHold records zero voipNative commands (delegate, no direct native imports)', () => { + const { call } = createMockCall('delegate-hold-2'); + useCallStore.getState().setCall(call); + adapter.reset(); + + useCallStore.getState().toggleHold(); + + expect(adapter.recorded).toHaveLength(0); + }); + + it('toggleSpeaker delegates to callLifecycle — records setSpeaker via lifecycle', async () => { + const { call } = createMockCall('delegate-spk-1'); + useCallStore.getState().setCall(call); + adapter.reset(); + + await useCallStore.getState().toggleSpeaker(); + + // Still records setSpeaker — proves delegation routes through lifecycle correctly. + expect(adapter.recorded).toContainEqual({ cmd: 'setSpeaker', on: true }); + expect(useCallStore.getState().isSpeakerOn).toBe(true); + }); +}); diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 09ccc4b73d9..0c5f6ad9e47 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -5,6 +5,7 @@ import { voipNative } from './VoipNative'; import Navigation from '../../navigation/appNavigation'; import { hideActionSheetRef } from '../../../containers/ActionSheet'; import { useIsScreenReaderEnabled } from '../../hooks/useIsScreenReaderEnabled'; +import { callLifecycle } from './CallLifecycle'; const STALE_NATIVE_MS = 60_000; @@ -199,9 +200,8 @@ 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'); }; call.emitter.on('stateChange', handleStateChange); @@ -219,30 +219,11 @@ export const useCallStore = create((set, get) => ({ set({ controlsVisible: !get().controlsVisible }); }, - toggleMute: () => { - const { call, isMuted } = get(); - if (!call) return; - - call.localParticipant.setMuted(!isMuted); - set({ isMuted: !isMuted }); - }, - - toggleHold: () => { - const { call, isOnHold } = get(); - if (!call) return; - - call.localParticipant.setHeld(!isOnHold); - set({ isOnHold: !isOnHold }); - }, + toggleMute: () => callLifecycle.toggle('mute'), - toggleSpeaker: async () => { - const { call, isSpeakerOn } = get(); - if (!call) return; + toggleHold: () => callLifecycle.toggle('hold'), - const newSpeakerOn = !isSpeakerOn; - await voipNative.call.setSpeaker(newSpeakerOn); - set({ isSpeakerOn: newSpeakerOn }); - }, + toggleSpeaker: () => callLifecycle.toggle('speaker'), toggleFocus: () => { const isFocused = get().focused; @@ -272,27 +253,19 @@ 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'); }, 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 4f68d5527b3..04e75cf0556 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -1,5 +1,4 @@ import { InteractionManager } from 'react-native'; -import { voipNative } from '../lib/services/voip/VoipNative'; import I18n from 'i18n-js'; import { all, call, delay, put, select, take, takeLatest } from 'redux-saga/effects'; @@ -27,6 +26,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 +84,11 @@ 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. + callLifecycle.end('error'); resetVoipState(); - if (callId) { - voipNative.call.end(callId); - } yield call(waitForNavigationReady);