Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -36,6 +37,11 @@ const Stack = createStackNavigator<StackParamList>();
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();
Expand Down
84 changes: 54 additions & 30 deletions app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +23,7 @@ import Navigation from '../../lib/navigation/appNavigation';
import { usePeerAutocompleteStore } from '../../lib/services/voip/usePeerAutocompleteStore';
import { useCallStore } from '../../lib/services/voip/useCallStore';
import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance';
import { voipNative, type InMemoryVoipNative } from '../../lib/services/voip/VoipNative';
import { mockedStore } from '../../reducers/mockedStore';
import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions';
import type { InsideStackParamList } from '../../stacks/types';
Expand Down Expand Up @@ -387,6 +387,7 @@ describe('VoIP call lifecycle (integration)', () => {
usePeerAutocompleteStore.getState().reset();
useCallStore.getState().reset();
mediaSessionInstance.reset();
(voipNative as InMemoryVoipNative).reset();

consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
const message = formatConsoleArgs(args);
Expand Down Expand Up @@ -447,12 +448,18 @@ describe('VoIP call lifecycle (integration)', () => {
const { call } = useCallStore.getState();
expect(call?.callId).toBe('call-user-1');

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

it('SIP peer: press Call → startCall(sip, number) → navigates to CallView', async () => {
Expand Down Expand Up @@ -563,7 +570,7 @@ describe('VoIP call lifecycle (integration)', () => {
await flushMicrotasks();
});

expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('incoming-1');
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: 'incoming-1' });
expect(Navigation.navigate).toHaveBeenCalledWith('CallView');
expect(useCallStore.getState().call?.callId).toBe('incoming-1');
});
Expand All @@ -586,7 +593,7 @@ describe('VoIP call lifecycle (integration)', () => {
await flushMicrotasks();
});

expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('missing-1');
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'missing-1' });
expect(useCallStore.getState().nativeAcceptedCallId).toBeNull();
expect(Navigation.navigate).not.toHaveBeenCalled();
expect(useCallStore.getState().call).toBeNull();
Expand Down Expand Up @@ -622,7 +629,7 @@ describe('VoIP call lifecycle (integration)', () => {
// app/views/CallView/components/CallButtons.tsx), NOT MediaSessionInstance.endCall.
// The latter is invoked from native CallKit "end" events. Both need coverage.
describe('UI store contract: Hang up', () => {
it('B1: useCallStore.endCall clears store and triggers RNCallKeep.endCall', async () => {
it('B1: useCallStore.endCall clears store and triggers voipNative.call.end', async () => {
setSelectedPeer({ type: 'user', value: 'user-1', label: 'Alice', username: 'alice' });
const { getByTestId } = render(
<Wrapper>
Expand All @@ -634,43 +641,58 @@ describe('VoIP call lifecycle (integration)', () => {
await act(() => Promise.resolve());
expect(useCallStore.getState().call?.callId).toBe('call-user-1');

act(() => {
// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
useCallStore.getState().endCall();
await Promise.resolve();
});

expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('call-user-1');
expect(InCallManager.stop as jest.Mock).toHaveBeenCalled();
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'call-user-1' });
// stopAudio is now issued by CallLifecycle.end (step 6) via voipNative.call.stopAudio(),
// which in the test environment records to InMemoryVoipNative.recorded rather than calling InCallManager.stop.
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'stopAudio' });
expect(useCallStore.getState().call).toBeNull();
expect(useCallStore.getState().callId).toBeNull();
});

it('B2: MediaSessionInstance.endCall during active state → RNCallKeep cleanup, store reset', () => {
const session = createdSessions[createdSessions.length - 1];
it('B2: MediaSessionInstance.endCall during active state → voipNative cleanup, store reset', async () => {
// endCall now delegates to callLifecycle.end('local'). CallLifecycle reads the
// active call from useCallStore, so the call must be set there first.
const activeCall = makeCall({ callId: 'active-1', state: 'active' });
session.getCallData.mockReturnValue(activeCall);

act(() => {
useCallStore.getState().setCall(activeCall);
});

// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
mediaSessionInstance.endCall('active-1');
await Promise.resolve();
});

expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('active-1');
expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('');
expect(RNCallKeep.setAvailable as jest.Mock).toHaveBeenCalledWith(true);
// CallLifecycle.end() steps 2-4 run via InMemoryVoipNative (records commands instead of calling RNCallKeep).
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'active-1' });
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: '' });
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markAvailable', callUuid: 'active-1' });
expect(useCallStore.getState().call).toBeNull();
});

it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + RNCallKeep cleanup', () => {
const session = createdSessions[createdSessions.length - 1];
const ringingCall = makeCall({ callId: 'ringing-1' });
session.getCallData.mockReturnValue(ringingCall);

it('B3: MediaSessionInstance.endCall during ringing → reject (not hangup) + voipNative cleanup', async () => {
// CallLifecycle reads the active call from useCallStore to decide reject vs hangup.
// The ringing call must be in the store for reject() to be called.
const ringingCall = makeCall({ callId: 'ringing-1', state: 'ringing' });
act(() => {
useCallStore.getState().setCall(ringingCall);
});

// CallLifecycle.end() defers its body to a microtask (re-entry guard); flush it.
await act(async () => {
mediaSessionInstance.endCall('ringing-1');
await Promise.resolve();
});

expect(ringingCall.reject).toHaveBeenCalled();
expect(ringingCall.hangup).not.toHaveBeenCalled();
expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('ringing-1');
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'ringing-1' });
expect(useCallStore.getState().call).toBeNull();
});
});
Expand Down Expand Up @@ -747,13 +769,13 @@ describe('VoIP call lifecycle (integration)', () => {
await act(async () => {
await useCallStore.getState().toggleSpeaker();
});
expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(true);
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'setSpeaker', on: true });
expect(useCallStore.getState().isSpeakerOn).toBe(true);

await act(async () => {
await useCallStore.getState().toggleSpeaker();
});
expect(InCallManager.setForceSpeakerphoneOn as jest.Mock).toHaveBeenCalledWith(false);
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'setSpeaker', on: false });
expect(useCallStore.getState().isSpeakerOn).toBe(false);
});
});
Expand Down Expand Up @@ -800,7 +822,7 @@ describe('VoIP call lifecycle (integration)', () => {
expect(useCallStore.getState().isOnHold).toBe(true);
});

it('D3: press end button → call.hangup, RNCallKeep.endCall, store cleared', () => {
it('D3: press end button → call.hangup, voipNative.call.end, store cleared', async () => {
const call = makeCall({ callId: 'btn-end', role: 'caller', state: 'active' });
act(() => {
useCallStore.getState().setCall(call);
Expand All @@ -812,12 +834,14 @@ describe('VoIP call lifecycle (integration)', () => {
</Wrapper>
);

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

expect(call.hangup).toHaveBeenCalled();
expect(RNCallKeep.endCall as jest.Mock).toHaveBeenCalledWith('btn-end');
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'end', callUuid: 'btn-end' });
expect(useCallStore.getState().call).toBeNull();
});
});
Expand All @@ -844,7 +868,7 @@ describe('VoIP call lifecycle (integration)', () => {

expect(useCallStore.getState().callState).toBe('active');
expect(useCallStore.getState().callStartTime).not.toBeNull();
expect(RNCallKeep.setCurrentCallActive as jest.Mock).toHaveBeenCalledWith('state-1');
expect((voipNative as InMemoryVoipNative).recorded).toContainEqual({ cmd: 'markActive', callUuid: 'state-1' });
});
});

Expand Down
21 changes: 13 additions & 8 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,8 @@ import {
} from './lib/methods/helpers/theme';
import { initializePushNotifications, onNotification } from './lib/notifications';
import { getInitialNotification, setupVideoConfActionListener } from './lib/notifications/videoConf/getInitialNotification';
import {
getInitialMediaCallEvents,
setupMediaCallEvents,
type MediaCallEventsAdapters
} from './lib/services/voip/MediaCallEvents';
import { createVoipEventDispatcher, type MediaCallEventsAdapters } from './lib/services/voip/MediaCallEvents';
import { voipNative } from './lib/services/voip/VoipNative';
import store from './lib/store';
import { initStore } from './lib/store/auxStore';
import { type TSupportedThemes, ThemeContext } from './theme';
Expand Down Expand Up @@ -133,8 +130,6 @@ export default class Root extends React.Component<{}, IState> {

// Set up video conf action listener for background accept/decline
this.videoConfActionCleanup = setupVideoConfActionListener();
// Set up media call event listeners for incoming calls
this.mediaCallEventCleanup = setupMediaCallEvents(this.getMediaCallEventsAdapters());
}

componentWillUnmount() {
Expand Down Expand Up @@ -164,7 +159,17 @@ export default class Root extends React.Component<{}, IState> {
return;
}

const voipInitialHandled = await getInitialMediaCallEvents(this.getMediaCallEventsAdapters());
// Single VoIP attach: sets up live listeners and drains cold-start events before resolving.
let voipInitialHandled = false;
const dispatchVoipEvent = createVoipEventDispatcher(this.getMediaCallEventsAdapters());
const { detach } = await voipNative.attach({
onEvent: e => {
if (dispatchVoipEvent(e)) {
voipInitialHandled = true;
}
}
});
this.mediaCallEventCleanup = detach;
if (voipInitialHandled) {
// VoIP path already dispatched navigation (or will via deep linking); do not call appInit() in parallel
return;
Expand Down
Loading