diff --git a/app/lib/services/restApi.test.ts b/app/lib/services/restApi.test.ts new file mode 100644 index 00000000000..d2f5b81e9f7 --- /dev/null +++ b/app/lib/services/restApi.test.ts @@ -0,0 +1,57 @@ +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; + +import { mediaCallsStateSignals } from './restApi'; + +const mockSdkGet = jest.fn(); +jest.mock('./sdk', () => ({ + __esModule: true, + default: { + get: (...args: unknown[]) => mockSdkGet(...args) + } +})); + +describe('mediaCallsStateSignals', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls sdk.get with media-calls.stateSignals and the contractId', async () => { + mockSdkGet.mockResolvedValueOnce({ signals: [], success: true }); + + const result = await mediaCallsStateSignals('device-contract-id-123'); + + expect(mockSdkGet).toHaveBeenCalledWith('media-calls.stateSignals', { contractId: 'device-contract-id-123' }); + expect(result).toEqual({ signals: [], success: true }); + }); + + it('returns signals and success from the API response', async () => { + const mockSignals = [ + { type: 'new', callId: 'call-1' } as unknown as ServerMediaSignal, + { type: 'notification', notification: 'ringing' } as unknown as ServerMediaSignal + ]; + mockSdkGet.mockResolvedValueOnce({ signals: mockSignals, success: true }); + + const result = await mediaCallsStateSignals('device-id'); + + expect(result.signals).toHaveLength(2); + expect(result.success).toBe(true); + }); + + it('returns empty signals and success false when sdk.get throws', async () => { + mockSdkGet.mockRejectedValueOnce(new Error('Network error')); + + const result = await mediaCallsStateSignals('device-id'); + + expect(result.signals).toEqual([]); + expect(result.success).toBe(false); + }); + + it('returns empty signals and success false when sdk.get returns an error response', async () => { + mockSdkGet.mockResolvedValueOnce({ signals: [], success: false }); + + const result = await mediaCallsStateSignals('device-id'); + + expect(result.signals).toEqual([]); + expect(result.success).toBe(false); + }); +}); diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 2a883ba30ad..972ce169ac7 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1,4 +1,5 @@ import { getUniqueId } from 'react-native-device-info'; +import type { ServerMediaSignal } from '@rocket.chat/media-signaling'; import { type IAvatarSuggestion, @@ -1215,3 +1216,14 @@ export const getUsersRoles = async (): Promise => { export const getSupportedVersionsCloud = (uniqueId?: string, domain?: string) => fetch(`https://releases.rocket.chat/v2/server/supportedVersions?uniqueId=${uniqueId}&domain=${domain}&source=mobile`); + +export const mediaCallsStateSignals = async (contractId: string): Promise<{ signals: ServerMediaSignal[]; success: boolean }> => { + try { + const result = await ( + sdk.get as unknown as (path: string, params?: object) => Promise<{ signals: ServerMediaSignal[]; success: boolean }> + )('media-calls.stateSignals', { contractId }); + return result; + } catch { + return { signals: [], success: false }; + } +}; diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index 288a2d2eded..f802c9f577f 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -105,7 +105,7 @@ type MockMediaSignalingSession = { processSignal: jest.Mock; setIceGatheringTimeout: jest.Mock; startCall: jest.Mock; - getMainCall: jest.Mock; + getCallData: jest.Mock; }; const createdSessions: MockMediaSignalingSession[] = []; @@ -124,7 +124,7 @@ jest.mock('@rocket.chat/media-signaling', () => ({ this.processSignal = jest.fn().mockResolvedValue(undefined); this.setIceGatheringTimeout = jest.fn(); this.startCall = jest.fn().mockResolvedValue(undefined); - this.getMainCall = jest.fn(); + this.getCallData = jest.fn(); Object.defineProperty(this, 'sessionId', { value: `session-${config.userId}`, writable: false }); createdSessions.push(this); }) @@ -160,13 +160,23 @@ function buildClientMediaCall(options: { role: 'caller' | 'callee'; hidden?: boolean; reject?: jest.Mock; + contact?: { username?: string; sipExtension?: string }; }): 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, + localParticipant: { local: true, role: options.role, muted: false, held: false, contact: {} }, + remoteParticipants: [ + { + local: false, + role: options.role === 'caller' ? 'callee' : 'caller', + muted: false, + held: false, + contact: options.contact ?? {} + } + ], reject, emitter: emitter as unknown as IClientMediaCall['emitter'] } as unknown as IClientMediaCall; @@ -505,9 +515,9 @@ describe('MediaSessionInstance', () => { newCallHandler({ call: { hidden: false, - role: 'caller', + localParticipant: { role: 'caller' }, + remoteParticipants: [{ contact: { username: 'alice', sipExtension: '' } }], callId: 'c1', - contact: { username: 'alice', sipExtension: '' }, emitter: { on: jest.fn(), off: jest.fn() } } as unknown as IClientMediaCall }); @@ -536,9 +546,9 @@ describe('MediaSessionInstance', () => { newCallHandler({ call: { hidden: false, - role: 'caller', + localParticipant: { role: 'caller' }, + remoteParticipants: [{ contact: { username: 'alice', sipExtension: '' } }], callId: 'c1', - contact: { username: 'alice', sipExtension: '' }, emitter: { on: jest.fn(), off: jest.fn() } } as unknown as IClientMediaCall }); @@ -557,9 +567,9 @@ describe('MediaSessionInstance', () => { newCallHandler({ call: { hidden: false, - role: 'caller', + localParticipant: { role: 'caller' }, + remoteParticipants: [{ contact: { username: 'alice', sipExtension: '100' } }], callId: 'c1', - contact: { username: 'alice', sipExtension: '100' }, emitter: { on: jest.fn(), off: jest.fn() } } as unknown as IClientMediaCall }); @@ -575,9 +585,9 @@ describe('MediaSessionInstance', () => { const mainCall = { callId: 'call-ans', accept: jest.fn().mockResolvedValue(undefined), - contact: { username: 'bob', sipExtension: '' } + remoteParticipants: [{ contact: { username: 'bob', sipExtension: '' } }] }; - session.getMainCall.mockReturnValue(mainCall); + session.getCallData.mockReturnValue(mainCall); await mediaSessionInstance.answerCall('call-ans'); @@ -591,9 +601,9 @@ describe('MediaSessionInstance', () => { const mainCall = { callId: 'call-sip', accept: jest.fn().mockResolvedValue(undefined), - contact: { username: 'bob', sipExtension: 'ext' } + remoteParticipants: [{ contact: { username: 'bob', sipExtension: 'ext' } }] }; - session.getMainCall.mockReturnValue(mainCall); + session.getCallData.mockReturnValue(mainCall); await mediaSessionInstance.answerCall('call-sip'); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 7175bf4da1b..79e1c4ed70e 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -1,5 +1,6 @@ import { MediaCallWebRTCProcessor, + type CallContact, type ClientMediaSignal, type IClientMediaCall, type CallActorType, @@ -91,11 +92,11 @@ class MediaSessionInstance { console.log('🤙 [VoIP] New call data:', call); }); - if (call.role === 'caller') { + if (call.localParticipant.role === 'caller') { useCallStore.getState().setCall(call); Navigation.navigate('CallView'); if (useCallStore.getState().roomId == null) { - this.resolveRoomIdFromContact(call.contact).catch(error => { + this.resolveRoomIdFromContact(call.remoteParticipants[0]?.contact).catch(error => { console.error('[VoIP] Error resolving room id from contact (newCall):', error); }); } @@ -116,7 +117,7 @@ class MediaSessionInstance { } console.log('[VoIP] Answering call:', callId); - const mainCall = this.instance?.getMainCall(); + const mainCall = this.instance?.getCallData(callId); console.log('[VoIP] Main call:', mainCall); if (mainCall && mainCall.callId === callId) { @@ -126,7 +127,7 @@ class MediaSessionInstance { RNCallKeep.setCurrentCallActive(callId); useCallStore.getState().setCall(mainCall); Navigation.navigate('CallView'); - this.resolveRoomIdFromContact(mainCall.contact).catch(error => { + this.resolveRoomIdFromContact(mainCall.remoteParticipants[0]?.contact).catch(error => { console.error('[VoIP] Error resolving room id from contact (answerCall):', error); }); } else { @@ -154,7 +155,7 @@ class MediaSessionInstance { }; public endCall = (callId: string) => { - const mainCall = this.instance?.getMainCall(); + const mainCall = this.instance?.getCallData(callId); if (mainCall && mainCall.callId === callId) { if (mainCall.state === 'ringing') { @@ -170,8 +171,8 @@ class MediaSessionInstance { useCallStore.getState().reset(); }; - private async resolveRoomIdFromContact(contact: IClientMediaCall['contact']): Promise { - if (contact.sipExtension) { + private async resolveRoomIdFromContact(contact: CallContact | undefined): Promise { + if (!contact || contact.sipExtension) { return; } const { username } = contact; diff --git a/app/lib/services/voip/MediaSessionStore.ts b/app/lib/services/voip/MediaSessionStore.ts index ede6e89063a..f59cda874a1 100644 --- a/app/lib/services/voip/MediaSessionStore.ts +++ b/app/lib/services/voip/MediaSessionStore.ts @@ -67,7 +67,8 @@ class MediaSessionStore extends Emitter<{ change: void }> { randomStringFactory, logger: new MediaCallLogger(), features: ['audio'], - mobileDeviceId + mobileDeviceId, + autoSync: true }); this.change(); diff --git a/app/lib/services/voip/mockCall.ts b/app/lib/services/voip/mockCall.ts index 887c3a3d9b1..ee0aac4dda5 100644 --- a/app/lib/services/voip/mockCall.ts +++ b/app/lib/services/voip/mockCall.ts @@ -33,16 +33,21 @@ export function createMockCall(overrides: MockCallOverrides = {}): IClientMediaC const contact = { ...DEFAULT_CONTACT, ...overrides.contact }; const callState: CallState = overrides.callState ?? 'active'; - const mock = { - callId: 'mock-call-id', - state: callState, + const localParticipant = { + local: true, + role: 'caller', muted: overrides.isMuted ?? false, held: overrides.isOnHold ?? false, - remoteMute: false, - remoteHeld: false, - contact, + contact: {}, setMuted: () => {}, - setHeld: () => {}, + setHeld: () => {} + }; + const remoteParticipants = [{ local: false, role: 'callee', muted: false, held: false, contact }]; + const mock = { + callId: 'mock-call-id', + state: callState, + localParticipant, + remoteParticipants, hangup: () => {}, reject: () => {}, sendDTMF: () => {}, diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index 69feb829e69..05b916b964a 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -35,19 +35,31 @@ function createMockCall(callId: string) { const emit = (ev: string, ...args: unknown[]) => { listeners[ev]?.forEach(fn => fn(...args)); }; + const localParticipant = { + local: true, + role: 'callee', + muted: false, + held: false, + contact: {}, + setMuted: jest.fn(), + setHeld: jest.fn() + }; + const remoteParticipants = [ + { + local: false, + role: 'caller', + muted: false, + held: false, + contact: { id: 'u', displayName: 'U', username: 'u', sipExtension: '' } + } + ]; const call = { callId, state: 'active', - muted: false, - held: false, - remoteMute: false, - remoteHeld: false, hidden: false, - role: 'callee', - contact: { id: 'u', displayName: 'U', username: 'u', sipExtension: '' }, + localParticipant, + remoteParticipants, emitter, - setMuted: jest.fn(), - setHeld: jest.fn(), sendDTMF: jest.fn(), hangup: jest.fn(), accept: jest.fn(), diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 75f8c217518..74040b495c5 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -140,19 +140,21 @@ export const useCallStore = create((set, get) => ({ cleanupCallListeners(); get().resetNativeCallId(); // Update state with call info + const remote = call.remoteParticipants[0]; + const remoteContact = remote?.contact; set({ call, callId: call.callId, callState: call.state, - isMuted: call.muted, - isOnHold: call.held, - remoteMute: call.remoteMute, - remoteHeld: call.remoteHeld, + isMuted: call.localParticipant.muted, + isOnHold: call.localParticipant.held, + remoteMute: remote?.muted ?? false, + remoteHeld: remote?.held ?? false, contact: { - id: call.contact.id, - displayName: call.contact.displayName, - username: call.contact.username, - sipExtension: call.contact.sipExtension + id: remoteContact?.id, + displayName: remoteContact?.displayName, + username: remoteContact?.username, + sipExtension: remoteContact?.sipExtension }, callStartTime: call.state === 'active' ? Date.now() : null }); @@ -187,11 +189,12 @@ export const useCallStore = create((set, get) => ({ const currentCall = get().call; if (!currentCall) return; + const currentRemote = currentCall.remoteParticipants[0]; set({ - isMuted: currentCall.muted, - isOnHold: currentCall.held, - remoteMute: currentCall.remoteMute, - remoteHeld: currentCall.remoteHeld, + isMuted: currentCall.localParticipant.muted, + isOnHold: currentCall.localParticipant.held, + remoteMute: currentRemote?.muted ?? false, + remoteHeld: currentRemote?.held ?? false, controlsVisible: true }); }; @@ -225,7 +228,7 @@ export const useCallStore = create((set, get) => ({ const { call, isMuted } = get(); if (!call) return; - call.setMuted(!isMuted); + call.localParticipant.setMuted(!isMuted); set({ isMuted: !isMuted }); }, @@ -233,7 +236,7 @@ export const useCallStore = create((set, get) => ({ const { call, isOnHold } = get(); if (!call) return; - call.setHeld(!isOnHold); + call.localParticipant.setHeld(!isOnHold); set({ isOnHold: !isOnHold }); }, diff --git a/package.json b/package.json index 1ac57fc4cd6..7707813cd9c 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@react-navigation/elements": "^2.6.1", "@react-navigation/native": "^7.1.16", "@react-navigation/native-stack": "^7.3.23", - "@rocket.chat/media-signaling": "file:./packages/rocket.chat-media-signaling-0.1.3.tgz", + "@rocket.chat/media-signaling": "file:./packages/rocket.chat-media-signaling-0.2.0.tgz", "@rocket.chat/message-parser": "0.31.32", "@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto", "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#mobile", diff --git a/packages/rocket.chat-media-signaling-0.1.3.tgz b/packages/rocket.chat-media-signaling-0.1.3.tgz deleted file mode 100644 index 77146990c5e..00000000000 Binary files a/packages/rocket.chat-media-signaling-0.1.3.tgz and /dev/null differ diff --git a/packages/rocket.chat-media-signaling-0.2.0.tgz b/packages/rocket.chat-media-signaling-0.2.0.tgz new file mode 100644 index 00000000000..4fa5387e0da Binary files /dev/null and b/packages/rocket.chat-media-signaling-0.2.0.tgz differ diff --git a/yarn.lock b/yarn.lock index 907fc555ce8..be6d2a3f931 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4348,9 +4348,9 @@ dependencies: eslint-plugin-import "^2.17.2" -"@rocket.chat/media-signaling@file:./packages/rocket.chat-media-signaling-0.1.3.tgz": - version "0.1.3" - resolved "file:./packages/rocket.chat-media-signaling-0.1.3.tgz#d120c37812a26c2223a53761c7936938ad05ccfb" +"@rocket.chat/media-signaling@file:./packages/rocket.chat-media-signaling-0.2.0.tgz": + version "0.2.0" + resolved "file:./packages/rocket.chat-media-signaling-0.2.0.tgz#6fec3d25b95d62e69cd999c0cd649c2b5465acc6" dependencies: "@rocket.chat/emitter" "^0.32.0" ajv "^8.17.1"