Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
57 changes: 57 additions & 0 deletions app/lib/services/restApi.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
12 changes: 12 additions & 0 deletions app/lib/services/restApi.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getUniqueId } from 'react-native-device-info';
import type { ServerMediaSignal } from '@rocket.chat/media-signaling';

import {
type IAvatarSuggestion,
Expand Down Expand Up @@ -1215,3 +1216,14 @@ export const getUsersRoles = async (): Promise<boolean | IRoleUser[]> => {

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 };
}
};
71 changes: 49 additions & 22 deletions app/lib/services/voip/MediaSessionInstance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ 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 { mediaSessionStore } from './MediaSessionStore';

jest.mock('../../database/services/Subscription', () => ({
getDMSubscriptionByUsername: jest.fn()
Expand Down Expand Up @@ -51,6 +51,11 @@ jest.mock('../sdk', () => ({
}
}));

const mockMediaCallsStateSignals = jest.fn((..._args: unknown[]) => Promise.resolve({ signals: [] }));
jest.mock('../../services/restApi', () => ({
mediaCallsStateSignals: (...args: unknown[]) => mockMediaCallsStateSignals(...args)
}));

jest.mock('../../store/auxStore', () => ({
store: {
getState: jest.fn(() => ({
Expand Down Expand Up @@ -78,8 +83,31 @@ jest.mock('react-native-callkeep', () => ({
}));

jest.mock('react-native-device-info', () => ({
__esModule: true,
default: {
getUniqueId: jest.fn(() => 'test-device-id'),
getUniqueIdSync: jest.fn(() => 'test-device-id'),
getSystemVersion: jest.fn(() => '15.0'),
getVersion: jest.fn(() => '4.0.0'),
getBuildNumber: jest.fn(() => '1'),
hasNotch: jest.fn(() => false),
getReadableVersion: jest.fn(() => '4.0.0'),
getBundleId: jest.fn(() => 'com.rocket.chat'),
getDeviceModel: jest.fn(() => 'iPhone 14'),
getModel: jest.fn(() => 'iPhone 14'),
isTablet: jest.fn(() => false)
},
getUniqueId: jest.fn(() => 'test-device-id'),
getUniqueIdSync: jest.fn(() => 'test-device-id')
getUniqueIdSync: jest.fn(() => 'test-device-id'),
getSystemVersion: jest.fn(() => '15.0'),
getVersion: jest.fn(() => '4.0.0'),
getBuildNumber: jest.fn(() => '1'),
hasNotch: jest.fn(() => false),
getReadableVersion: jest.fn(() => '4.0.0'),
getBundleId: jest.fn(() => 'com.rocket.chat'),
getDeviceModel: jest.fn(() => 'iPhone 14'),
getModel: jest.fn(() => 'iPhone 14'),
isTablet: jest.fn(() => false)
}));

jest.mock('../../native/NativeVoip', () => ({
Expand All @@ -105,7 +133,7 @@ type MockMediaSignalingSession = {
processSignal: jest.Mock;
setIceGatheringTimeout: jest.Mock;
startCall: jest.Mock;
getMainCall: jest.Mock;
register: jest.Mock;
};

const createdSessions: MockMediaSignalingSession[] = [];
Expand All @@ -124,7 +152,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.register = jest.fn();
Object.defineProperty(this, 'sessionId', { value: `session-${config.userId}`, writable: false });
createdSessions.push(this);
})
Expand Down Expand Up @@ -165,7 +193,21 @@ function buildClientMediaCall(options: {
const emitter = { on: jest.fn(), off: jest.fn(), emit: jest.fn() };
return {
callId: options.callId,
role: options.role,
localParticipant: {
local: true as const,
participantId: 'mock-participant-id',
actorType: 'user' as const,
actorId: 'mock-actor-id',
role: options.role,
muted: false,
held: false,
contact: { id: 'u', displayName: 'U', username: 'u', sipExtension: '' },
getMediaStream: () => null,
setMuted: () => {},
setHeld: () => {}
},
remoteParticipants: [],
participants: [],
hidden: options.hidden ?? false,
reject,
emitter: emitter as unknown as IClientMediaCall['emitter']
Expand Down Expand Up @@ -196,8 +238,8 @@ describe('MediaSessionInstance', () => {
});

describe('init', () => {
it('should register stream-notify-user listener', () => {
mediaSessionInstance.init('user-1');
it('should register stream-notify-user listener', async () => {
await mediaSessionInstance.init('user-1');
expect(mockOnStreamData).toHaveBeenCalledWith('stream-notify-user', expect.any(Function));
});

Expand Down Expand Up @@ -571,29 +613,14 @@ describe('MediaSessionInstance', () => {
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);

await mediaSessionInstance.answerCall('call-ans');

await waitFor(() => expect(mockSetRoomId).toHaveBeenCalledWith('dm-rid'));
expect(mockGetDMSubscriptionByUsername).toHaveBeenCalledWith('bob');
});

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);

await mediaSessionInstance.answerCall('call-sip');

Expand Down
65 changes: 35 additions & 30 deletions app/lib/services/voip/MediaSessionInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
type IClientMediaCall,
type CallActorType,
type MediaSignalingSession,
type WebRTCProcessorConfig
type WebRTCProcessorConfig,
type CallContact
} from '@rocket.chat/media-signaling';
import RNCallKeep from 'react-native-callkeep';
import { registerGlobals } from 'react-native-webrtc';
Expand All @@ -14,6 +15,7 @@ import { mediaSessionStore } from './MediaSessionStore';
import { useCallStore } from './useCallStore';
import { store } from '../../store/auxStore';
import sdk from '../sdk';
import { mediaCallsStateSignals } from '../restApi';
import Navigation from '../../navigation/appNavigation';
import { parseStringToIceServers } from './parseStringToIceServers';
import type { IceServer } from '../../../definitions/Voip';
Expand All @@ -32,7 +34,7 @@ class MediaSessionInstance {
private storeTimeoutUnsubscribe: (() => void) | null = null;
private storeIceServersUnsubscribe: (() => void) | null = null;

public init(userId: string): void {
public async init(userId: string): Promise<void> {
this.reset();

registerGlobals();
Expand All @@ -49,7 +51,23 @@ class MediaSessionInstance {
mediaSessionStore.setSendSignalFn((signal: ClientMediaSignal) => {
sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal));
});
this.instance = mediaSessionStore.getInstance(userId);
const instance = mediaSessionStore.getInstance(userId);
this.instance = instance;

if (instance) {
// Fetch initial call state via REST before DDP register fires
try {
const { signals } = await mediaCallsStateSignals(getUniqueIdSync());
for (const signal of signals) {
instance.processSignal(signal);
}
} catch (error) {
console.error('[VoIP] Failed to fetch initial state signals:', error);
}

// instance.register(false);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

this.mediaSessionStoreChangeUnsubscribe = mediaSessionStore.onChange(() => {
this.instance = mediaSessionStore.getInstance(userId);
});
Expand All @@ -65,8 +83,6 @@ class MediaSessionInstance {
const signal = ddpMessage.fields.args[0];
this.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;
Expand All @@ -86,16 +102,13 @@ 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);
});
call.emitter.on('stateChange', () => {});

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.localParticipant.contact).catch(error => {
console.error('[VoIP] Error resolving room id from contact (newCall):', error);
});
}
Expand All @@ -111,22 +124,17 @@ class MediaSessionInstance {
public answerCall = async (callId: string) => {
const { call: existingCall } = useCallStore.getState();
if (existingCall != null && existingCall.callId === callId) {
console.log('[VoIP] answerCall skipped — call already bound in store:', callId);
return;
}

console.log('[VoIP] Answering call:', callId);
const mainCall = this.instance?.getMainCall();
console.log('[VoIP] Main call:', mainCall);
const { call } = useCallStore.getState();

if (mainCall && mainCall.callId === callId) {
console.log('[VoIP] Accepting call:', callId);
await mainCall.accept();
console.log('[VoIP] Setting current call active:', callId);
if (call && call.callId === callId) {
await call.accept();
RNCallKeep.setCurrentCallActive(callId);
useCallStore.getState().setCall(mainCall);
useCallStore.getState().setCall(call);
Navigation.navigate('CallView');
this.resolveRoomIdFromContact(mainCall.contact).catch(error => {
this.resolveRoomIdFromContact(call.localParticipant.contact).catch(error => {
console.error('[VoIP] Error resolving room id from contact (answerCall):', error);
});
} else {
Expand All @@ -135,7 +143,6 @@ class MediaSessionInstance {
if (st.nativeAcceptedCallId === callId) {
st.resetNativeCallId();
}
console.warn('[VoIP] Call not found:', callId); // TODO: Show error message?
}
};

Expand All @@ -149,18 +156,17 @@ class MediaSessionInstance {

public startCall = (userId: string, actor: CallActorType) => {
requestPhoneStatePermission();
console.log('[VoIP] Starting call:', userId);
this.instance?.startCall(actor, userId);
};

public endCall = (callId: string) => {
const mainCall = this.instance?.getMainCall();
const { call } = useCallStore.getState();

if (mainCall && mainCall.callId === callId) {
if (mainCall.state === 'ringing') {
mainCall.reject();
if (call && call.callId === callId) {
if (call.state === 'ringing') {
call.reject();
} else {
mainCall.hangup();
call.hangup();
}
}
RNCallKeep.endCall(callId);
Expand All @@ -170,7 +176,7 @@ class MediaSessionInstance {
useCallStore.getState().reset();
};

private async resolveRoomIdFromContact(contact: IClientMediaCall['contact']): Promise<void> {
private async resolveRoomIdFromContact(contact: CallContact): Promise<void> {
if (contact.sipExtension) {
return;
}
Expand Down Expand Up @@ -205,7 +211,6 @@ class MediaSessionInstance {
const currentIceServers = this.getIceServers();
if (currentIceServers !== this.iceServers) {
this.iceServers = currentIceServers;
// this.instance?.setIceServers(this.iceServers);
}
});
}
Expand Down
3 changes: 2 additions & 1 deletion app/lib/services/voip/MediaSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ class MediaSessionStore extends Emitter<{ change: void }> {
randomStringFactory,
logger: new MediaCallLogger(),
features: ['audio'],
mobileDeviceId
mobileDeviceId,
autoSync: true
});

this.change();
Expand Down
Loading
Loading