diff --git a/.gitignore b/.gitignore index f24d4a8f50d..aaf539e48ee 100644 --- a/.gitignore +++ b/.gitignore @@ -83,4 +83,8 @@ e2e/e2e_account.ts **/e2e_account.js **/e2e_account.ts -*.p8 \ No newline at end of file +*.p8 + +# Local tooling +.claude/ +.superset/ \ No newline at end of file diff --git a/app/containers/NewMediaCall/CreateCall.test.tsx b/app/containers/NewMediaCall/CreateCall.test.tsx index e6514083daf..8a7c9426d4e 100644 --- a/app/containers/NewMediaCall/CreateCall.test.tsx +++ b/app/containers/NewMediaCall/CreateCall.test.tsx @@ -8,25 +8,17 @@ import { mockedStore } from '../../reducers/mockedStore'; import type { TPeerItem } from '../../lib/services/voip/getPeerAutocompleteOptions'; import * as stories from './CreateCall.stories'; import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots'; +import { mediaSessionInstance } from '../../lib/services/voip/MediaSessionInstance'; -const mockStartCall = jest.fn(); const mockHideActionSheet = jest.fn(); -jest.mock('../../lib/services/voip/MediaSessionInstance', () => { - const instance = {}; - Object.defineProperty(instance, 'startCall', { - value: (...args: unknown[]) => mockStartCall(...args), - writable: false, - configurable: false - }); - return { mediaSessionInstance: instance }; -}); - jest.mock('../ActionSheet', () => ({ ...jest.requireActual('../ActionSheet'), hideActionSheetRef: () => mockHideActionSheet() })); +jest.spyOn(mediaSessionInstance, 'startCall').mockReturnValue({ success: true, callId: '' }); + const setStoreState = (selectedPeer: TPeerItem | null) => { usePeerAutocompleteStore.setState({ selectedPeer }); }; @@ -50,6 +42,7 @@ describe('CreateCall', () => { beforeEach(() => { jest.clearAllMocks(); usePeerAutocompleteStore.setState({ selectedPeer: null }); + jest.spyOn(mediaSessionInstance, 'startCall').mockReturnValue({ success: true, callId: '' }); }); it('should render the call button', () => { @@ -83,7 +76,7 @@ describe('CreateCall', () => { ); fireEvent.press(getByTestId('new-media-call-button')); - expect(mockStartCall).not.toHaveBeenCalled(); + expect(mediaSessionInstance.startCall).not.toHaveBeenCalled(); expect(mockHideActionSheet).not.toHaveBeenCalled(); }); @@ -108,8 +101,8 @@ describe('CreateCall', () => { ); fireEvent.press(getByTestId('new-media-call-button')); - expect(mockStartCall).toHaveBeenCalledTimes(1); - expect(mockStartCall).toHaveBeenCalledWith('user-1', 'user'); + expect(mediaSessionInstance.startCall).toHaveBeenCalledTimes(1); + expect(mediaSessionInstance.startCall).toHaveBeenCalledWith('user-1', 'user'); expect(mockHideActionSheet).toHaveBeenCalledTimes(1); }); @@ -122,8 +115,8 @@ describe('CreateCall', () => { ); fireEvent.press(getByTestId('new-media-call-button')); - expect(mockStartCall).toHaveBeenCalledTimes(1); - expect(mockStartCall).toHaveBeenCalledWith('+5511999999999', 'sip'); + expect(mediaSessionInstance.startCall).toHaveBeenCalledTimes(1); + expect(mediaSessionInstance.startCall).toHaveBeenCalledWith('+5511999999999', 'sip'); expect(mockHideActionSheet).toHaveBeenCalledTimes(1); }); }); diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index 32d2367caa7..2a12d3e5c0c 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -51,6 +51,7 @@ jest.mock('react-native-callkeep', () => ({ jest.mock('./MediaSessionInstance', () => ({ mediaSessionInstance: { + startCall: (...args: unknown[]) => ({ success: true, callId: '' }), endCall: jest.fn() } })); diff --git a/app/lib/services/voip/MediaSessionController.test.ts b/app/lib/services/voip/MediaSessionController.test.ts new file mode 100644 index 00000000000..6003cacf356 --- /dev/null +++ b/app/lib/services/voip/MediaSessionController.test.ts @@ -0,0 +1,127 @@ +import type { IClientMediaCall } from '@rocket.chat/media-signaling'; + +import { mediaSessionStore } from './MediaSessionStore'; +import { MediaSessionController } from './MediaSessionController'; + +jest.mock('./MediaSessionStore', () => ({ + mediaSessionStore: { + setWebRTCProcessorFactory: jest.fn(), + getInstance: jest.fn(), + dispose: jest.fn(), + onChange: jest.fn(() => jest.fn()) + } +})); + +jest.mock('../sdk', () => ({ + default: { + onStreamData: jest.fn(() => ({ stop: jest.fn() })) + } +})); + +jest.mock('react-native-device-info', () => ({ + getUniqueIdSync: () => 'device-123' +})); + +jest.mock('../../store/auxStore', () => ({ + store: { + getState: () => ({ + settings: { + VoIP_TeamCollab_Ice_Servers: 'stun:stun.l.google.com:19302', + VoIP_TeamCollab_Ice_Gathering_Timeout: 5000 + } + }), + subscribe: jest.fn(() => jest.fn()) + } +})); + +type MockMediaSignalingSession = { + userId: string; + on: jest.Mock; + setIceGatheringTimeout: jest.Mock; +}; + +jest.mock('@rocket.chat/media-signaling', () => ({ + MediaCallWebRTCProcessor: jest.fn().mockImplementation(function (this: unknown) { + return this; + }), + MediaSignalingSession: jest + .fn() + .mockImplementation(function MockMediaSignalingSession(this: MockMediaSignalingSession, config: { userId: string }) { + this.userId = config.userId; + this.on = jest.fn(); + this.setIceGatheringTimeout = jest.fn(); + }) +})); + +jest.mock('react-native-webrtc', () => ({ + registerGlobals: jest.fn() +})); + +describe('MediaSessionController', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with empty session', () => { + const controller = new MediaSessionController('user-123'); + expect(controller.getSession()).toBeNull(); + }); + }); + + describe('configure', () => { + it('should set WebRTC processor factory with ICE servers', () => { + const controller = new MediaSessionController('user-123'); + controller.configure(); + + expect(mediaSessionStore.setWebRTCProcessorFactory).toHaveBeenCalledTimes(1); + const factory = (mediaSessionStore.setWebRTCProcessorFactory as jest.Mock).mock.calls[0][0]; + const processor = factory({ + rtc: { iceServers: [] }, + iceGatheringTimeout: 5000 + }); + expect(processor).toBeDefined(); + }); + + it('should create session via mediaSessionStore', () => { + const mockInstance = { startCall: jest.fn() }; + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(mockInstance); + + const controller = new MediaSessionController('user-123'); + controller.configure(); + + expect(mediaSessionStore.getInstance).toHaveBeenCalledWith('user-123'); + }); + }); + + describe('getSession', () => { + it('should return null before configure', () => { + const controller = new MediaSessionController('user-123'); + expect(controller.getSession()).toBeNull(); + }); + + it('should return session after configure', () => { + const mockInstance = { startCall: jest.fn() }; + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(mockInstance); + + const controller = new MediaSessionController('user-123'); + controller.configure(); + + expect(controller.getSession()).toBe(mockInstance); + }); + }); + + describe('reset', () => { + it('should dispose mediaSessionStore and set session to null', () => { + const mockInstance = { startCall: jest.fn() }; + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(mockInstance); + + const controller = new MediaSessionController('user-123'); + controller.configure(); + controller.reset(); + + expect(mediaSessionStore.dispose).toHaveBeenCalled(); + expect(controller.getSession()).toBeNull(); + }); + }); +}); diff --git a/app/lib/services/voip/MediaSessionController.ts b/app/lib/services/voip/MediaSessionController.ts new file mode 100644 index 00000000000..ecd6cdc42f5 --- /dev/null +++ b/app/lib/services/voip/MediaSessionController.ts @@ -0,0 +1,87 @@ +import { + MediaCallWebRTCProcessor, + type WebRTCProcessorConfig, + type MediaSignalingSession +} from '@rocket.chat/media-signaling'; +import { registerGlobals } from 'react-native-webrtc'; + +import { mediaSessionStore } from './MediaSessionStore'; +import { parseStringToIceServers } from './parseStringToIceServers'; +import { store } from '../../store/auxStore'; +import type { IceServer } from '../../../definitions/Voip'; + +export class MediaSessionController { + private userId: string; + private session: MediaSignalingSession | null = null; + private iceServers: IceServer[] = []; + private iceGatheringTimeout: number = 5000; + private storeTimeoutUnsubscribe: (() => void) | null = null; + private storeIceServersUnsubscribe: (() => void) | null = null; + + constructor(userId: string) { + this.userId = userId; + } + + public configure(): void { + registerGlobals(); + this.configureIceServers(); + + mediaSessionStore.setWebRTCProcessorFactory( + (config: WebRTCProcessorConfig) => + new MediaCallWebRTCProcessor({ + ...config, + rtc: { ...config.rtc, iceServers: this.iceServers }, + iceGatheringTimeout: this.iceGatheringTimeout + }) + ); + + this.session = mediaSessionStore.getInstance(this.userId); + } + + public getSession(): MediaSignalingSession | null { + return this.session; + } + + public refreshSession(): MediaSignalingSession | null { + this.session = mediaSessionStore.getInstance(this.userId); + return this.session; + } + + public reset(): void { + if (this.storeTimeoutUnsubscribe) { + this.storeTimeoutUnsubscribe(); + this.storeTimeoutUnsubscribe = null; + } + if (this.storeIceServersUnsubscribe) { + this.storeIceServersUnsubscribe(); + this.storeIceServersUnsubscribe = null; + } + mediaSessionStore.dispose(); + this.session = null; + } + + private getIceServers(): IceServer[] { + const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as string; + return parseStringToIceServers(iceServers); + } + + private configureIceServers(): void { + this.iceServers = this.getIceServers(); + this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; + + this.storeTimeoutUnsubscribe = store.subscribe(() => { + const currentTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; + if (currentTimeout !== this.iceGatheringTimeout) { + this.iceGatheringTimeout = currentTimeout; + this.session?.setIceGatheringTimeout(this.iceGatheringTimeout); + } + }); + + this.storeIceServersUnsubscribe = store.subscribe(() => { + const currentIceServers = this.getIceServers(); + if (currentIceServers !== this.iceServers) { + this.iceServers = currentIceServers; + } + }); + } +} diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 288a2d2eded..8a0f5d4718e 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -1,13 +1,19 @@ -import type { IClientMediaCall } from '@rocket.chat/media-signaling'; -import RNCallKeep from 'react-native-callkeep'; -import { waitFor } from '@testing-library/react-native'; - -import type { IDDPMessage } from '../../../definitions/IDDPMessage'; -import Navigation from '../../navigation/appNavigation'; -import { getDMSubscriptionByUsername } from '../../database/services/Subscription'; -import { getUidDirectMessage } from '../../methods/helpers/helpers'; -import { mediaSessionStore } from './MediaSessionStore'; -import { mediaSessionInstance } from './MediaSessionInstance'; +import { mediaSessionInstance, CallOrchestrator } from './MediaSessionInstance'; + +jest.mock('./MediaSessionStore', () => ({ + mediaSessionStore: { + setWebRTCProcessorFactory: jest.fn(), + setSendSignalFn: jest.fn(), + getInstance: jest.fn(() => ({ + on: jest.fn(), + processSignal: jest.fn(), + startCall: jest.fn(), + getMainCall: jest.fn() + })), + dispose: jest.fn(), + onChange: jest.fn(() => jest.fn()) + } +})); jest.mock('../../database/services/Subscription', () => ({ getDMSubscriptionByUsername: jest.fn() @@ -17,8 +23,8 @@ jest.mock('../../methods/helpers/helpers', () => ({ getUidDirectMessage: jest.fn(() => 'other-user-id') })); -const mockGetDMSubscriptionByUsername = jest.mocked(getDMSubscriptionByUsername); -const mockGetUidDirectMessage = jest.mocked(getUidDirectMessage); +const mockGetDMSubscriptionByUsername = jest.fn(); +const mockGetUidDirectMessage = jest.fn(() => 'other-user-id'); const mockCallStoreReset = jest.fn(); const mockSetRoomId = jest.fn(); @@ -97,87 +103,18 @@ jest.mock('../../methods/voipPhoneStatePermission', () => ({ requestPhoneStatePermission: () => mockRequestPhoneStatePermission() })); -type MockMediaSignalingSession = { - userId: string; - sessionId: string; - endSession: jest.Mock; - on: jest.Mock; - processSignal: jest.Mock; - setIceGatheringTimeout: jest.Mock; - startCall: jest.Mock; - getMainCall: jest.Mock; -}; - -const createdSessions: MockMediaSignalingSession[] = []; - jest.mock('@rocket.chat/media-signaling', () => ({ MediaCallWebRTCProcessor: jest.fn().mockImplementation(function MediaCallWebRTCProcessor(this: unknown) { return this; }), - MediaSignalingSession: jest - .fn() - .mockImplementation(function MockMediaSignalingSession(this: MockMediaSignalingSession, config: { userId: string }) { - const endSession = jest.fn(); - this.userId = config.userId; - this.endSession = endSession; - this.on = jest.fn(); - this.processSignal = jest.fn().mockResolvedValue(undefined); - this.setIceGatheringTimeout = jest.fn(); - this.startCall = jest.fn().mockResolvedValue(undefined); - this.getMainCall = jest.fn(); - Object.defineProperty(this, 'sessionId', { value: `session-${config.userId}`, writable: false }); - createdSessions.push(this); - }) + MediaSignalingSession: jest.fn() })); -const STREAM_NOTIFY_USER = 'stream-notify-user'; - -function getStreamNotifyHandler(): (ddpMessage: IDDPMessage) => void { - const calls = mockOnStreamData.mock.calls as unknown as [string, (m: IDDPMessage) => void][]; - for (let i = calls.length - 1; i >= 0; i--) { - const [eventName, handler] = calls[i]; - if (eventName === STREAM_NOTIFY_USER && typeof handler === 'function') { - return handler; - } - } - throw new Error('stream-notify-user handler not registered'); -} - -function getNewCallHandler(): (payload: { call: IClientMediaCall }) => void { - const session = createdSessions[0]; - if (!session) { - throw new Error('no session created'); - } - const entry = session.on.mock.calls.find(([name]) => name === 'newCall'); - if (!entry) { - throw new Error('newCall handler not registered'); - } - return entry[1] as (payload: { call: IClientMediaCall }) => void; -} - -function buildClientMediaCall(options: { - callId: string; - role: 'caller' | 'callee'; - hidden?: boolean; - reject?: jest.Mock; -}): IClientMediaCall { - const reject = options.reject ?? jest.fn(); - const emitter = { on: jest.fn(), off: jest.fn(), emit: jest.fn() }; - return { - callId: options.callId, - role: options.role, - hidden: options.hidden ?? false, - reject, - emitter: emitter as unknown as IClientMediaCall['emitter'] - } as unknown as IClientMediaCall; -} - -describe('MediaSessionInstance', () => { +describe('CallOrchestrator', () => { beforeEach(() => { jest.clearAllMocks(); - createdSessions.length = 0; mockGetUidDirectMessage.mockReturnValue('other-user-id'); - mockGetDMSubscriptionByUsername.mockResolvedValue(null); + (mockGetDMSubscriptionByUsername as jest.Mock).mockResolvedValue(null); mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), @@ -201,13 +138,98 @@ describe('MediaSessionInstance', () => { expect(mockOnStreamData).toHaveBeenCalledWith('stream-notify-user', expect.any(Function)); }); - it('should create session with userId', () => { - mediaSessionInstance.init('user-abc'); - expect(createdSessions).toHaveLength(1); - expect(createdSessions[0].userId).toBe('user-abc'); + it('should re-attach newCall listener when mediaSessionStore session changes', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + let changeCallback: (() => void) | null = null; + (mediaSessionStore.onChange as jest.Mock).mockImplementation((cb: () => void) => { + changeCallback = cb; + return jest.fn(); + }); + const session1 = { + on: jest.fn(), + processSignal: jest.fn(), + startCall: jest.fn(), + getMainCall: jest.fn() + }; + const session2 = { + on: jest.fn(), + processSignal: jest.fn(), + startCall: jest.fn(), + getMainCall: jest.fn() + }; + (mediaSessionStore.getInstance as jest.Mock).mockReturnValueOnce(session1).mockReturnValueOnce(session2); + + mediaSessionInstance.init('user-1'); + expect(session1.on).toHaveBeenCalledWith('newCall', expect.any(Function)); + + changeCallback?.(); + expect(session2.on).toHaveBeenCalledWith('newCall', expect.any(Function)); + }); + + it('should set sendSignal fn after mediaSessionStore.dispose so getInstance sees it', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + mediaSessionInstance.init('user-1'); + const disposeCalls = (mediaSessionStore.dispose as jest.Mock).mock.invocationCallOrder; + const setSendCalls = (mediaSessionStore.setSendSignalFn as jest.Mock).mock.invocationCallOrder; + const getInstanceCalls = (mediaSessionStore.getInstance as jest.Mock).mock.invocationCallOrder; + expect(disposeCalls.length).toBeGreaterThan(0); + expect(setSendCalls.length).toBeGreaterThan(0); + expect(getInstanceCalls.length).toBeGreaterThan(0); + // setSendSignalFn must happen AFTER the last dispose and BEFORE getInstance, + // otherwise makeInstance throws "send signal function must be set". + const lastDispose = Math.max(...disposeCalls); + const firstSetSend = Math.min(...setSendCalls); + const firstGetInstance = Math.min(...getInstanceCalls); + expect(firstSetSend).toBeGreaterThan(lastDispose); + expect(firstSetSend).toBeLessThan(firstGetInstance); + }); + + it('should stop previous stream-notify-user listener on re-init', () => { + const firstStop = jest.fn(); + const secondStop = jest.fn(); + mockOnStreamData.mockReturnValueOnce({ stop: firstStop }).mockReturnValueOnce({ stop: secondStop }); + mediaSessionInstance.init('user-1'); + mediaSessionInstance.init('user-2'); + expect(firstStop).toHaveBeenCalledTimes(1); + expect(secondStop).not.toHaveBeenCalled(); + }); + + it('should not re-attach newCall listener when onChange fires with the same session', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + let changeCallback: (() => void) | null = null; + (mediaSessionStore.onChange as jest.Mock).mockImplementation((cb: () => void) => { + changeCallback = cb; + return jest.fn(); + }); + const sameSession = { + on: jest.fn(), + processSignal: jest.fn(), + startCall: jest.fn(), + getMainCall: jest.fn() + }; + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(sameSession); + + mediaSessionInstance.init('user-1'); + changeCallback?.(); + changeCallback?.(); + + const newCallAttaches = sameSession.on.mock.calls.filter(([name]) => name === 'newCall').length; + expect(newCallAttaches).toBe(1); + }); + + it('should unsubscribe previous mediaSessionStore.onChange on re-init', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + const firstUnsub = jest.fn(); + const secondUnsub = jest.fn(); + (mediaSessionStore.onChange as jest.Mock).mockReturnValueOnce(firstUnsub).mockReturnValueOnce(secondUnsub); + mediaSessionInstance.init('user-1'); + mediaSessionInstance.init('user-2'); + expect(firstUnsub).toHaveBeenCalledTimes(1); + expect(secondUnsub).not.toHaveBeenCalled(); }); it('should route sendSignal through sdk.methodCall with user media-calls channel', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); const spy = jest.spyOn(mediaSessionStore, 'setSendSignalFn'); mediaSessionInstance.init('user-xyz'); expect(spy).toHaveBeenCalled(); @@ -222,384 +244,202 @@ describe('MediaSessionInstance', () => { }); }); - describe('teardown and user switch', () => { - it('should call endSession on previous session when init with different userId', () => { + describe('navigation callbacks', () => { + it('should use default navigation callback', () => { mediaSessionInstance.init('user-1'); - const first = createdSessions[0]; - mediaSessionInstance.init('user-2'); - expect(first.endSession).toHaveBeenCalled(); - expect(createdSessions[createdSessions.length - 1].userId).toBe('user-2'); + expect(jest.requireMock('../../navigation/appNavigation').default.navigate).not.toHaveBeenCalled(); }); - it('should only have one active onChange handler after re-init (getInstance once per change emit)', () => { - mediaSessionInstance.init('user-1'); - mediaSessionInstance.init('user-2'); - const spy = jest.spyOn(mediaSessionStore, 'getInstance'); - mediaSessionStore.emit('change'); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy).toHaveBeenCalledWith('user-2'); - spy.mockRestore(); + it('should allow custom onCallStarted callback', () => { + const customOnCallStarted = jest.fn(); + const orchestrator = new CallOrchestrator({ onCallStarted: customOnCallStarted }); + (orchestrator as unknown as { onCallStarted: () => void }).onCallStarted(); + expect(customOnCallStarted).toHaveBeenCalled(); }); - it('should throw existing makeInstance error when getInstance after reset without init', () => { - mediaSessionInstance.init('user-1'); - mediaSessionInstance.reset(); - expect(() => mediaSessionStore.getInstance('any')).toThrow('WebRTC processor factory and send signal function must be set'); + it('should allow custom onCallEnded callback', () => { + const customOnCallEnded = jest.fn(); + const orchestrator = new CallOrchestrator({ onCallEnded: customOnCallEnded }); + (orchestrator as unknown as { onCallEnded: () => void }).onCallEnded(); + expect(customOnCallEnded).toHaveBeenCalled(); }); + }); - it('should allow init after reset', () => { + describe('startCall returns CallResult', () => { + it('should return success result when session exists', () => { mediaSessionInstance.init('user-1'); - mediaSessionInstance.reset(); - mediaSessionInstance.init('user-2'); - expect(createdSessions[createdSessions.length - 1].userId).toBe('user-2'); + const result = mediaSessionInstance.startCall('peer-1', 'user'); + expect(result).toHaveProperty('success'); + expect(result.success).toBe(true); }); - it('should not throw when reset is called twice', () => { - mediaSessionInstance.init('user-1'); - expect(() => { - mediaSessionInstance.reset(); - mediaSessionInstance.reset(); - }).not.toThrow(); + it('should return error result when session not initialized', () => { + mediaSessionInstance.reset(); + const result = mediaSessionInstance.startCall('peer-1', 'user'); + expect(result.success).toBe(false); + expect(result.error).toBe('Session not initialized'); }); }); - describe('newCall (no JS busy-reject; native decides)', () => { - it('allows incoming callee newCall when store already has an active call', () => { - const mockSetCall = jest.fn(); - mockUseCallStoreGetState.mockReturnValue({ - reset: mockCallStoreReset, - setCall: mockSetCall, - setRoomId: mockSetRoomId, - resetNativeCallId: jest.fn(), - call: { callId: 'active-a' } as IClientMediaCall, - callId: 'active-a', - nativeAcceptedCallId: null, - roomId: null - }); - mediaSessionInstance.init('user-1'); - const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); - getNewCallHandler()({ call: incoming }); - expect(incoming.reject).not.toHaveBeenCalled(); - expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('incoming-b'); - }); - - it('allows incoming callee newCall when nativeAcceptedCallId is set but differs from incoming callId', () => { + describe('answerCall returns CallResult', () => { + it('should return success when call already in store', async () => { mockUseCallStoreGetState.mockReturnValue({ reset: mockCallStoreReset, setCall: jest.fn(), setRoomId: mockSetRoomId, resetNativeCallId: jest.fn(), - call: { callId: 'active-a' } as IClientMediaCall, - callId: 'active-a', - nativeAcceptedCallId: 'native-other', + call: { callId: 'existing-call' } as unknown, + callId: 'existing-call', + nativeAcceptedCallId: null, roomId: null }); - mediaSessionInstance.init('user-1'); - const incoming = buildClientMediaCall({ callId: 'incoming-b', role: 'callee' }); - getNewCallHandler()({ call: incoming }); - expect(incoming.reject).not.toHaveBeenCalled(); - expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('incoming-b'); - }); - it('allows incoming callee newCall when nativeAcceptedCallId matches incoming callId', () => { - mockUseCallStoreGetState.mockReturnValue({ - reset: mockCallStoreReset, - setCall: jest.fn(), - setRoomId: mockSetRoomId, - resetNativeCallId: jest.fn(), - call: null, - callId: null, - nativeAcceptedCallId: 'same-id', - roomId: null - }); mediaSessionInstance.init('user-1'); - const incoming = buildClientMediaCall({ callId: 'same-id', role: 'callee' }); - getNewCallHandler()({ call: incoming }); - expect(incoming.reject).not.toHaveBeenCalled(); - expect(RNCallKeep.endCall).not.toHaveBeenCalledWith('same-id'); + const result = await mediaSessionInstance.answerCall('existing-call'); + expect(result.success).toBe(true); + expect(result.callId).toBe('existing-call'); }); - it('does not reject outgoing (caller) newCall; binds call and navigates', () => { - const mockSetCall = jest.fn(); - mockUseCallStoreGetState.mockReturnValue({ - reset: mockCallStoreReset, - setCall: mockSetCall, - setRoomId: mockSetRoomId, - resetNativeCallId: jest.fn(), - call: null, - callId: null, - nativeAcceptedCallId: null, - roomId: null - }); + it('should return error when call not found', async () => { mediaSessionInstance.init('user-1'); - const outgoing = buildClientMediaCall({ callId: 'out-c', role: 'caller' }); - getNewCallHandler()({ call: outgoing }); - expect(outgoing.reject).not.toHaveBeenCalled(); - expect(mockSetCall).toHaveBeenCalledWith(outgoing); - expect(Navigation.navigate).toHaveBeenCalledWith('CallView'); + const result = await mediaSessionInstance.answerCall('unknown-call'); + expect(result.success).toBe(false); + expect(result.error).toBe('Call not found'); }); }); - describe('stream-notify-user (notification/accepted gated)', () => { - it('does not call answerCall when nativeAcceptedCallId is null', async () => { - const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); - mediaSessionInstance.init('user-1'); - const streamHandler = getStreamNotifyHandler(); - streamHandler({ - msg: 'changed', - fields: { - eventName: 'uid/media-signal', - args: [ - { - type: 'notification', - notification: 'accepted', - signedContractId: 'test-device-id', - callId: 'from-signal' - } - ] - } - }); - await Promise.resolve(); - expect(answerSpy).not.toHaveBeenCalled(); - answerSpy.mockRestore(); - }); + describe('endCall', () => { + const RNCallKeep = jest.requireMock('react-native-callkeep').default; - it('calls answerCall when nativeAcceptedCallId matches signal and contract matches device', async () => { - const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); - mockUseCallStoreGetState.mockReturnValue({ - reset: mockCallStoreReset, - setCall: jest.fn(), - setRoomId: mockSetRoomId, - resetNativeCallId: jest.fn(), - call: null, - callId: null, - nativeAcceptedCallId: 'from-signal', - roomId: null - }); - mediaSessionInstance.init('user-1'); - const streamHandler = getStreamNotifyHandler(); - streamHandler({ - msg: 'changed', - fields: { - eventName: 'uid/media-signal', - args: [ - { - type: 'notification', - notification: 'accepted', - signedContractId: 'test-device-id', - callId: 'from-signal' - } - ] - } - }); - await Promise.resolve(); - expect(answerSpy).toHaveBeenCalledWith('from-signal'); - answerSpy.mockRestore(); - }); + function makeSession(mainCall: unknown) { + return { + on: jest.fn(), + processSignal: jest.fn(), + startCall: jest.fn(), + getMainCall: jest.fn(() => mainCall) + }; + } - it('calls answerCall when only nativeAcceptedCallId matches (transient callId null)', async () => { - const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); - mockUseCallStoreGetState.mockReturnValue({ - reset: mockCallStoreReset, - setCall: jest.fn(), - setRoomId: mockSetRoomId, - resetNativeCallId: jest.fn(), - call: null, - callId: null, - nativeAcceptedCallId: 'sticky-only', - roomId: null - }); - mediaSessionInstance.init('user-1'); - const streamHandler = getStreamNotifyHandler(); - streamHandler({ - msg: 'changed', - fields: { - eventName: 'uid/media-signal', - args: [ - { - type: 'notification', - notification: 'accepted', - signedContractId: 'test-device-id', - callId: 'sticky-only' - } - ] - } - }); - await Promise.resolve(); - expect(answerSpy).toHaveBeenCalledWith('sticky-only'); - answerSpy.mockRestore(); - }); + it('should not invoke onCallEnded when hangup() transitions state to hangup (library fires it via ended event)', () => { + const onCallEnded = jest.fn(); + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + const mainCall = { callId: 'call-1', role: 'caller', state: 'active', hangup: jest.fn(), reject: jest.fn() }; + mainCall.hangup.mockImplementation(() => { mainCall.state = 'hangup'; }); + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(mainCall)); - it('does not call answerCall when store call object is already set', async () => { - const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); - mockUseCallStoreGetState.mockReturnValue({ - reset: mockCallStoreReset, - setCall: jest.fn(), - setRoomId: mockSetRoomId, - resetNativeCallId: jest.fn(), - call: { callId: 'from-signal' } as any, - callId: 'from-signal', - nativeAcceptedCallId: 'from-signal', - roomId: null - }); - mediaSessionInstance.init('user-1'); - const streamHandler = getStreamNotifyHandler(); - streamHandler({ - msg: 'changed', - fields: { - eventName: 'uid/media-signal', - args: [ - { - type: 'notification', - notification: 'accepted', - signedContractId: 'test-device-id', - callId: 'from-signal' - } - ] - } - }); - await Promise.resolve(); - expect(answerSpy).not.toHaveBeenCalled(); - answerSpy.mockRestore(); + const orchestrator = new CallOrchestrator({ onCallEnded }); + orchestrator.init('user-1'); + orchestrator.endCall('call-1'); + + expect(mainCall.hangup).toHaveBeenCalled(); + expect(onCallEnded).not.toHaveBeenCalled(); }); - }); - describe('startCall', () => { - it('requests phone state permission fire-and-forget when starting a call', () => { - mediaSessionInstance.init('user-1'); - mockRequestPhoneStatePermission.mockClear(); - const session = createdSessions[0]; - mediaSessionInstance.startCall('peer-1', 'user'); - expect(mockRequestPhoneStatePermission).toHaveBeenCalledTimes(1); - expect(session.startCall).toHaveBeenCalledWith('user', 'peer-1'); + it('should use reject() for callee in ringing state', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + const mainCall = { callId: 'call-1', role: 'callee', state: 'ringing', hangup: jest.fn(), reject: jest.fn() }; + mainCall.reject.mockImplementation(() => { mainCall.state = 'hangup'; }); + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(mainCall)); + + const orchestrator = new CallOrchestrator(); + orchestrator.init('user-1'); + orchestrator.endCall('call-1'); + + expect(mainCall.reject).toHaveBeenCalled(); + expect(mainCall.hangup).not.toHaveBeenCalled(); }); - }); - describe('roomId population', () => { - it('startCallByRoom sets roomId before startCall', () => { - mediaSessionInstance.init('user-1'); - const session = createdSessions[0]; - const order: string[] = []; - mockSetRoomId.mockImplementationOnce(() => { - order.push('setRoomId'); - }); - session.startCall.mockImplementationOnce(() => { - order.push('startCall'); - }); + it('should use hangup() instead of reject() for caller in ringing state', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + const mainCall = { callId: 'call-1', role: 'caller', state: 'ringing', hangup: jest.fn(), reject: jest.fn() }; + mainCall.hangup.mockImplementation(() => { mainCall.state = 'hangup'; }); + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(mainCall)); - mediaSessionInstance.startCallByRoom({ rid: 'rid-dm', t: 'd', uids: ['a', 'b'] } as any); + const orchestrator = new CallOrchestrator(); + orchestrator.init('user-1'); + orchestrator.endCall('call-1'); - expect(mockSetRoomId).toHaveBeenCalledWith('rid-dm'); - expect(session.startCall).toHaveBeenCalledWith('user', 'other-user-id'); - expect(order).toEqual(['setRoomId', 'startCall']); + expect(mainCall.hangup).toHaveBeenCalled(); + expect(mainCall.reject).not.toHaveBeenCalled(); }); - it('newCall caller triggers DM lookup when roomId is still null', async () => { - mockGetDMSubscriptionByUsername.mockResolvedValue({ rid: 'from-db' } as any); - mediaSessionInstance.init('user-1'); - const session = createdSessions[0]; - const newCallHandler = session.on.mock.calls.find((c: string[]) => c[0] === 'newCall')?.[1] as (p: { - call: IClientMediaCall; - }) => void; - - newCallHandler({ - call: { - hidden: false, - role: 'caller', - callId: 'c1', - contact: { username: 'alice', sipExtension: '' }, - emitter: { on: jest.fn(), off: jest.fn() } - } as unknown as IClientMediaCall - }); + it('should invoke onCallEnded when no matching mainCall exists', () => { + const onCallEnded = jest.fn(); + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(null)); - await waitFor(() => expect(mockGetDMSubscriptionByUsername).toHaveBeenCalledWith('alice')); - expect(mockSetRoomId).toHaveBeenCalledWith('from-db'); + const orchestrator = new CallOrchestrator({ onCallEnded }); + orchestrator.init('user-1'); + orchestrator.endCall('call-1'); + + expect(onCallEnded).toHaveBeenCalled(); }); - it('newCall caller skips DM lookup when roomId already set', async () => { - mediaSessionInstance.init('user-1'); - mockUseCallStoreGetState.mockReturnValue({ - reset: mockCallStoreReset, - setCall: jest.fn(), - setRoomId: mockSetRoomId, - resetNativeCallId: jest.fn(), - call: null, - callId: null, - nativeAcceptedCallId: null, - roomId: 'preset-rid' - }); - const session = createdSessions[0]; - const newCallHandler = session.on.mock.calls.find((c: string[]) => c[0] === 'newCall')?.[1] as (p: { - call: IClientMediaCall; - }) => void; - - newCallHandler({ - call: { - hidden: false, - role: 'caller', - callId: 'c1', - contact: { username: 'alice', sipExtension: '' }, - emitter: { on: jest.fn(), off: jest.fn() } - } as unknown as IClientMediaCall - }); + it('should invoke onCallEnded when callId does not match mainCall', () => { + const onCallEnded = jest.fn(); + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + const mainCall = { callId: 'other-call', role: 'caller', state: 'active', hangup: jest.fn(), reject: jest.fn() }; + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(mainCall)); + + const orchestrator = new CallOrchestrator({ onCallEnded }); + orchestrator.init('user-1'); + orchestrator.endCall('call-1'); - await Promise.resolve(); - expect(mockGetDMSubscriptionByUsername).not.toHaveBeenCalled(); + expect(mainCall.hangup).not.toHaveBeenCalled(); + expect(onCallEnded).toHaveBeenCalled(); }); - it('newCall caller skips DM lookup for SIP contact', async () => { - mediaSessionInstance.init('user-1'); - const session = createdSessions[0]; - const newCallHandler = session.on.mock.calls.find((c: string[]) => c[0] === 'newCall')?.[1] as (p: { - call: IClientMediaCall; - }) => void; - - newCallHandler({ - call: { - hidden: false, - role: 'caller', - callId: 'c1', - contact: { username: 'alice', sipExtension: '100' }, - emitter: { on: jest.fn(), off: jest.fn() } - } as unknown as IClientMediaCall - }); + it('should invoke onCallEnded and not throw when hangup() throws', () => { + const onCallEnded = jest.fn(); + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + const mainCall = { callId: 'call-1', role: 'caller', state: 'active', hangup: jest.fn(), reject: jest.fn() }; + mainCall.hangup.mockImplementation(() => { throw new Error('service-error'); }); + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(mainCall)); - await Promise.resolve(); - expect(mockGetDMSubscriptionByUsername).not.toHaveBeenCalled(); + const orchestrator = new CallOrchestrator({ onCallEnded }); + orchestrator.init('user-1'); + + expect(() => orchestrator.endCall('call-1')).not.toThrow(); + expect(onCallEnded).toHaveBeenCalled(); }); - it('answerCall resolves roomId from DM for non-SIP callee', async () => { - mockGetDMSubscriptionByUsername.mockResolvedValue({ rid: 'dm-rid' } as any); - mediaSessionInstance.init('user-1'); - const session = createdSessions[0]; - const mainCall = { - callId: 'call-ans', - accept: jest.fn().mockResolvedValue(undefined), - contact: { username: 'bob', sipExtension: '' } - }; - session.getMainCall.mockReturnValue(mainCall); + it('should invoke onCallEnded when hangup() early-returns without transitioning state', () => { + const onCallEnded = jest.fn(); + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + // hangup() returns without setting state to 'hangup' (e.g. contractState === 'ignored') + const mainCall = { callId: 'call-1', role: 'caller', state: 'active', hangup: jest.fn(), reject: jest.fn() }; + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(mainCall)); - await mediaSessionInstance.answerCall('call-ans'); + const orchestrator = new CallOrchestrator({ onCallEnded }); + orchestrator.init('user-1'); + orchestrator.endCall('call-1'); - await waitFor(() => expect(mockSetRoomId).toHaveBeenCalledWith('dm-rid')); - expect(mockGetDMSubscriptionByUsername).toHaveBeenCalledWith('bob'); + expect(onCallEnded).toHaveBeenCalled(); }); - it('answerCall skips DM lookup for SIP contact', async () => { - mediaSessionInstance.init('user-1'); - const session = createdSessions[0]; - const mainCall = { - callId: 'call-sip', - accept: jest.fn().mockResolvedValue(undefined), - contact: { username: 'bob', sipExtension: 'ext' } - }; - session.getMainCall.mockReturnValue(mainCall); + it('should always run CallKeep teardown even when hangup() throws', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + const mainCall = { callId: 'call-1', role: 'caller', state: 'active', hangup: jest.fn(), reject: jest.fn() }; + mainCall.hangup.mockImplementation(() => { throw new Error('boom'); }); + (mediaSessionStore.getInstance as jest.Mock).mockReturnValue(makeSession(mainCall)); + + const orchestrator = new CallOrchestrator(); + orchestrator.init('user-1'); + orchestrator.endCall('call-1'); - await mediaSessionInstance.answerCall('call-sip'); + expect(RNCallKeep.endCall).toHaveBeenCalledWith('call-1'); + expect(RNCallKeep.setCurrentCallActive).toHaveBeenCalledWith(''); + expect(RNCallKeep.setAvailable).toHaveBeenCalledWith(true); + expect(mockCallStoreReset).toHaveBeenCalled(); + }); + }); - await Promise.resolve(); - expect(mockGetDMSubscriptionByUsername).not.toHaveBeenCalled(); - expect(mockSetRoomId).not.toHaveBeenCalled(); + describe('teardown', () => { + it('should reset controller on reset()', () => { + const { mediaSessionStore } = jest.requireMock('./MediaSessionStore'); + mediaSessionInstance.init('user-1'); + mediaSessionInstance.reset(); + expect(mediaSessionStore.dispose).toHaveBeenCalled(); }); }); }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 7175bf4da1b..2366ec7d891 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -1,61 +1,109 @@ import { - MediaCallWebRTCProcessor, type ClientMediaSignal, type IClientMediaCall, type CallActorType, - type MediaSignalingSession, - type WebRTCProcessorConfig + type MediaSignalingSession } from '@rocket.chat/media-signaling'; import RNCallKeep from 'react-native-callkeep'; -import { registerGlobals } from 'react-native-webrtc'; import { getUniqueIdSync } from 'react-native-device-info'; import { mediaSessionStore } from './MediaSessionStore'; import { useCallStore } from './useCallStore'; -import { store } from '../../store/auxStore'; import sdk from '../sdk'; import Navigation from '../../navigation/appNavigation'; -import { parseStringToIceServers } from './parseStringToIceServers'; -import type { IceServer } from '../../../definitions/Voip'; import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import type { ISubscription, TSubscriptionModel } from '../../../definitions'; import { getDMSubscriptionByUsername } from '../../database/services/Subscription'; import { getUidDirectMessage } from '../../methods/helpers/helpers'; import { requestPhoneStatePermission } from '../../methods/voipPhoneStatePermission'; +import { MediaSessionController } from './MediaSessionController'; -class MediaSessionInstance { - private iceServers: IceServer[] = []; - private iceGatheringTimeout: number = 5000; +export type CallOrchestratorConfig = { + onCallStarted?: () => void; + onCallEnded?: () => void; +}; + +export type CallResult = + | { success: true; callId?: string } + | { success: false; error: string }; + +class CallOrchestrator { + private controller: MediaSessionController; private mediaSignalListener: { stop: () => void } | null = null; - private instance: MediaSignalingSession | null = null; - private mediaSessionStoreChangeUnsubscribe: (() => void) | null = null; - private storeTimeoutUnsubscribe: (() => void) | null = null; - private storeIceServersUnsubscribe: (() => void) | null = null; + private storeChangeUnsubscribe: (() => void) | null = null; + private attachedSession: MediaSignalingSession | null = null; + private onCallStarted: () => void; + private onCallEnded: () => void; + + constructor(config?: CallOrchestratorConfig) { + this.controller = new MediaSessionController(''); + this.onCallStarted = config?.onCallStarted ?? (() => Navigation.navigate('CallView')); + this.onCallEnded = config?.onCallEnded ?? (() => {}); + } public init(userId: string): void { - this.reset(); - - registerGlobals(); - this.configureIceServers(); - - mediaSessionStore.setWebRTCProcessorFactory( - (config: WebRTCProcessorConfig) => - new MediaCallWebRTCProcessor({ - ...config, - rtc: { ...config.rtc, iceServers: this.iceServers }, - iceGatheringTimeout: this.iceGatheringTimeout - }) - ); + if (this.storeChangeUnsubscribe) { + this.storeChangeUnsubscribe(); + this.storeChangeUnsubscribe = null; + } + if (this.mediaSignalListener?.stop) { + this.mediaSignalListener.stop(); + } + this.mediaSignalListener = null; + this.attachedSession = null; + this.controller.reset(); + this.controller = new MediaSessionController(userId); + mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => { sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal)); }); - this.instance = mediaSessionStore.getInstance(userId); - this.mediaSessionStoreChangeUnsubscribe = mediaSessionStore.onChange(() => { - this.instance = mediaSessionStore.getInstance(userId); + this.controller.configure(); + + this.setupMediaSignalListener(); + } + + private attachNewCallListener(): void { + const session = this.controller.getSession(); + if (!session || session === this.attachedSession) { + return; + } + this.attachedSession = session; + session.on('newCall', ({ call }: { call: IClientMediaCall }) => { + if (call && !call.hidden) { + call.emitter.on('stateChange', oldState => { + console.log(`📊 ${oldState} → ${call.state}`); + console.log('🤙 [VoIP] New call data:', call); + }); + + if (call.role === 'caller') { + useCallStore.getState().setCall(call); + this.onCallStarted(); + if (useCallStore.getState().roomId == null) { + this.resolveRoomIdFromContact(call.contact).catch(error => { + console.error('[VoIP] Error resolving room id from contact (newCall):', error); + }); + } + } + + call.emitter.on('ended', () => { + RNCallKeep.endCall(call.callId); + this.onCallEnded(); + }); + } + }); + } + + private setupMediaSignalListener(): void { + const { controller } = this; + this.attachNewCallListener(); + this.storeChangeUnsubscribe = mediaSessionStore.onChange(() => { + controller.refreshSession(); + this.attachNewCallListener(); }); this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: IDDPMessage) => { - if (!this.instance) { + const instance = controller.getSession(); + if (!instance) { return; } const [, ev] = ddpMessage.fields.eventName.split('/'); @@ -63,11 +111,10 @@ class MediaSessionInstance { return; } const signal = ddpMessage.fields.args[0]; - this.instance.processSignal(signal); + instance.processSignal(signal); console.log('🤙 [VoIP] Processed signal:', signal); - // Answer when native already accepted and stream matches device contract + callId. const storeSlice = useCallStore.getState(); const { call, nativeAcceptedCallId } = storeSlice; @@ -83,40 +130,17 @@ class MediaSessionInstance { }); } }); - - this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { - if (call && !call.hidden) { - call.emitter.on('stateChange', oldState => { - console.log(`📊 ${oldState} → ${call.state}`); - console.log('🤙 [VoIP] New call data:', call); - }); - - if (call.role === 'caller') { - useCallStore.getState().setCall(call); - Navigation.navigate('CallView'); - if (useCallStore.getState().roomId == null) { - this.resolveRoomIdFromContact(call.contact).catch(error => { - console.error('[VoIP] Error resolving room id from contact (newCall):', error); - }); - } - } - - call.emitter.on('ended', () => { - RNCallKeep.endCall(call.callId); - }); - } - }); } - public answerCall = async (callId: string) => { + public answerCall = async (callId: string): Promise => { const { call: existingCall } = useCallStore.getState(); if (existingCall != null && existingCall.callId === callId) { console.log('[VoIP] answerCall skipped — call already bound in store:', callId); - return; + return { success: true, callId }; } console.log('[VoIP] Answering call:', callId); - const mainCall = this.instance?.getMainCall(); + const mainCall = this.controller.getSession()?.getMainCall(); console.log('[VoIP] Main call:', mainCall); if (mainCall && mainCall.callId === callId) { @@ -125,18 +149,19 @@ class MediaSessionInstance { console.log('[VoIP] Setting current call active:', callId); RNCallKeep.setCurrentCallActive(callId); useCallStore.getState().setCall(mainCall); - Navigation.navigate('CallView'); + this.onCallStarted(); this.resolveRoomIdFromContact(mainCall.contact).catch(error => { console.error('[VoIP] Error resolving room id from contact (answerCall):', error); }); - } else { - RNCallKeep.endCall(callId); - const st = useCallStore.getState(); - if (st.nativeAcceptedCallId === callId) { - st.resetNativeCallId(); - } - console.warn('[VoIP] Call not found:', callId); // TODO: Show error message? + return { success: true, callId }; } + RNCallKeep.endCall(callId); + const st = useCallStore.getState(); + if (st.nativeAcceptedCallId === callId) { + st.resetNativeCallId(); + } + console.warn('[VoIP] Call not found:', callId); + return { success: false, error: 'Call not found' }; }; public startCallByRoom = (room: TSubscriptionModel | ISubscription) => { @@ -147,22 +172,38 @@ class MediaSessionInstance { } }; - public startCall = (userId: string, actor: CallActorType) => { + public startCall = (userId: string, actor: CallActorType): CallResult => { requestPhoneStatePermission(); console.log('[VoIP] Starting call:', userId); - this.instance?.startCall(actor, userId); + const session = this.controller.getSession(); + if (!session) { + return { success: false, error: 'Session not initialized' }; + } + session.startCall(actor, userId); + return { success: true }; }; public endCall = (callId: string) => { - const mainCall = this.instance?.getMainCall(); + const mainCall = this.controller.getSession()?.getMainCall(); + let endedSynchronously = false; if (mainCall && mainCall.callId === callId) { - if (mainCall.state === 'ringing') { - mainCall.reject(); - } else { - mainCall.hangup(); + try { + if (mainCall.role === 'callee' && mainCall.state === 'ringing') { + mainCall.reject(); + } else { + mainCall.hangup(); + } + endedSynchronously = mainCall.state === 'hangup'; + } catch (error) { + console.error('[VoIP] Error ending call:', error); } } + + if (!endedSynchronously) { + this.onCallEnded(); + } + RNCallKeep.endCall(callId); RNCallKeep.setCurrentCallActive(''); RNCallKeep.setAvailable(true); @@ -184,53 +225,20 @@ class MediaSessionInstance { } } - private getIceServers() { - const iceServers = store.getState().settings.VoIP_TeamCollab_Ice_Servers as any; - return parseStringToIceServers(iceServers); - } - - private configureIceServers() { - this.iceServers = this.getIceServers(); - this.iceGatheringTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; - - this.storeTimeoutUnsubscribe = store.subscribe(() => { - const currentTimeout = store.getState().settings.VoIP_TeamCollab_Ice_Gathering_Timeout as number; - if (currentTimeout !== this.iceGatheringTimeout) { - this.iceGatheringTimeout = currentTimeout; - this.instance?.setIceGatheringTimeout(this.iceGatheringTimeout); - } - }); - - this.storeIceServersUnsubscribe = store.subscribe(() => { - const currentIceServers = this.getIceServers(); - if (currentIceServers !== this.iceServers) { - this.iceServers = currentIceServers; - // this.instance?.setIceServers(this.iceServers); - } - }); - } - public reset() { - if (this.mediaSessionStoreChangeUnsubscribe) { - this.mediaSessionStoreChangeUnsubscribe(); - this.mediaSessionStoreChangeUnsubscribe = null; + if (this.storeChangeUnsubscribe) { + this.storeChangeUnsubscribe(); + this.storeChangeUnsubscribe = null; } if (this.mediaSignalListener?.stop) { this.mediaSignalListener.stop(); } this.mediaSignalListener = null; - if (this.storeTimeoutUnsubscribe) { - this.storeTimeoutUnsubscribe(); - this.storeTimeoutUnsubscribe = null; - } - if (this.storeIceServersUnsubscribe) { - this.storeIceServersUnsubscribe(); - this.storeIceServersUnsubscribe = null; - } - mediaSessionStore.dispose(); - this.instance = null; + this.attachedSession = null; + this.controller.reset(); useCallStore.getState().reset(); } } -export const mediaSessionInstance = new MediaSessionInstance(); +export { CallOrchestrator, type CallOrchestratorConfig, type CallResult }; +export const mediaSessionInstance = new CallOrchestrator(); diff --git a/jest.config.js b/jest.config.js index fb58f54f7f5..2d54d9ad50d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { testPathIgnorePatterns: ['e2e', 'node_modules'], transformIgnorePatterns: [ - 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@rocket.chat/ui-kit)' + 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@rocket.chat/ui-kit|@rocket.chat/media-signaling)' ], preset: './jest.preset.js', coverageDirectory: './coverage/', diff --git a/jest.setup.js b/jest.setup.js index a2dbc5a37af..db0c16eef97 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -205,6 +205,29 @@ jest.mock('react-native-math-view', () => { jest.mock('react-native-keyboard-controller'); +jest.mock('react-native-webrtc', () => ({ + registerGlobals: jest.fn(), + mediaDevices: { getUserMedia: jest.fn() } +})); + +jest.mock('./app/lib/services/voip/MediaSessionStore', () => { + const mockSession = { + on: jest.fn(), + processSignal: jest.fn(), + startCall: jest.fn(), + getMainCall: jest.fn() + }; + return { + mediaSessionStore: { + setWebRTCProcessorFactory: jest.fn(), + setSendSignalFn: jest.fn(), + getInstance: jest.fn(() => mockSession), + dispose: jest.fn(), + onChange: jest.fn(() => jest.fn()) + } + }; +}); + jest.mock('react-native-webview', () => { const React = require('react'); const { View } = require('react-native');