Skip to content

Commit 6465723

Browse files
authored
feat(voip): introduce CallLifecycle.end and CallNavRouter (#7274)
1 parent 77d518a commit 6465723

13 files changed

Lines changed: 1327 additions & 71 deletions

app/AppContainer.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { setCurrentScreen } from './lib/methods/helpers/log';
2020
import { themes } from './lib/constants/colors';
2121
import { emitter } from './lib/methods/helpers';
2222
import MediaCallHeader from './containers/MediaCallHeader/MediaCallHeader';
23+
import { CallNavRouter } from './lib/services/voip/CallNavRouter';
2324

2425
const createStackNavigator = createNativeStackNavigator;
2526

@@ -36,6 +37,11 @@ const Stack = createStackNavigator<StackParamList>();
3637
const App = memo(({ root, isMasterDetail }: { root: string; isMasterDetail: boolean }) => {
3738
const { theme } = useContext(ThemeContext);
3839

40+
useEffect(() => {
41+
// Mount CallNavRouter once — it subscribes to CallLifecycle after NavigationContainer is ready.
42+
CallNavRouter.mount();
43+
}, []);
44+
3945
useEffect(() => {
4046
if (root) {
4147
const state = Navigation.navigationRef.current?.getRootState();

app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -448,12 +448,18 @@ describe('VoIP call lifecycle (integration)', () => {
448448
const { call } = useCallStore.getState();
449449
expect(call?.callId).toBe('call-user-1');
450450

451-
// Firing 'ended' triggers voipNative cleanup and navigation back via real handlers.
452-
act(() => {
451+
// Firing 'ended' triggers CallLifecycle teardown via the handleEnded listener.
452+
// Navigation.back() is now handled by CallNavRouter (not wired in this integration test).
453+
// We verify the teardown sequence runs: store cleared, native end issued.
454+
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
455+
await act(async () => {
453456
(call!.emitter as unknown as ReturnType<typeof mockCallEmitter>).emit('ended');
457+
await Promise.resolve();
454458
});
455459
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' });
456-
expect(Navigation.back).toHaveBeenCalled();
460+
// Navigation.back() is now owned by CallNavRouter after callEnded emits.
461+
// In this test environment, CallNavRouter is not mounted, so we assert the store cleared instead.
462+
expect(useCallStore.getState().call).toBeNull();
457463
});
458464

459465
it('SIP peer: press Call → startCall(sip, number) → navigates to CallView', async () => {
@@ -635,37 +641,53 @@ describe('VoIP call lifecycle (integration)', () => {
635641
await act(() => Promise.resolve());
636642
expect(useCallStore.getState().call?.callId).toBe('call-user-1');
637643

638-
act(() => {
644+
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
645+
await act(async () => {
639646
useCallStore.getState().endCall();
647+
await Promise.resolve();
640648
});
641649

642650
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' });
651+
// stopAudio is now issued by CallLifecycle.end (step 6) via voipNative.call.stopAudio(),
652+
// which in the test environment records to InMemoryVoipNative.recorded rather than calling InCallManager.stop.
643653
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'stopAudio' });
644654
expect(useCallStore.getState().call).toBeNull();
645655
expect(useCallStore.getState().callId).toBeNull();
646656
});
647657

648-
it('B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset', () => {
649-
const session = createdSessions[createdSessions.length - 1];
658+
it('B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset', async () => {
659+
// endCall now delegates to callLifecycle.end('local'). CallLifecycle reads the
660+
// active call from useCallStore, so the call must be set there first.
650661
const activeCall = makeCall({ callId: 'active-1', state: 'active' });
651-
session.getCallData.mockReturnValue(activeCall);
652-
653662
act(() => {
663+
useCallStore.getState().setCall(activeCall);
664+
});
665+
666+
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
667+
await act(async () => {
654668
mediaSessionInstance.endCall('active-1');
669+
await Promise.resolve();
655670
});
656671

672+
// CallLifecycle.end() steps 2-4 run via InMemoryVoipNative (records commands instead of calling RNCallKeep).
657673
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'active-1' });
674+
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: '' });
658675
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'active-1' });
659676
expect(useCallStore.getState().call).toBeNull();
660677
});
661678

662-
it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup', () => {
663-
const session = createdSessions[createdSessions.length - 1];
664-
const ringingCall = makeCall({ callId: 'ringing-1' });
665-
session.getCallData.mockReturnValue(ringingCall);
666-
679+
it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup', async () => {
680+
// CallLifecycle reads the active call from useCallStore to decide reject vs hangup.
681+
// The ringing call must be in the store for reject() to be called.
682+
const ringingCall = makeCall({ callId: 'ringing-1', state: 'ringing' });
667683
act(() => {
684+
useCallStore.getState().setCall(ringingCall);
685+
});
686+
687+
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
688+
await act(async () => {
668689
mediaSessionInstance.endCall('ringing-1');
690+
await Promise.resolve();
669691
});
670692

671693
expect(ringingCall.reject).toHaveBeenCalled();
@@ -800,7 +822,7 @@ describe('VoIP call lifecycle (integration)', () => {
800822
expect(useCallStore.getState().isOnHold).toBe(true);
801823
});
802824

803-
it('D3: press end button → call.hangup, voipNative.call.end, store cleared', () => {
825+
it('D3: press end button → call.hangup, voipNative.call.end, store cleared', async () => {
804826
const call = makeCall({ callId: 'btn-end', role: 'caller', state: 'active' });
805827
act(() => {
806828
useCallStore.getState().setCall(call);
@@ -812,8 +834,10 @@ describe('VoIP call lifecycle (integration)', () => {
812834
</Wrapper>
813835
);
814836

815-
act(() => {
837+
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
838+
await act(async () => {
816839
fireEvent.press(getByTestId('call-view-end'));
840+
await Promise.resolve();
817841
});
818842

819843
expect(call.hangup).toHaveBeenCalled();

0 commit comments

Comments
 (0)