Skip to content
Merged
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
3 changes: 2 additions & 1 deletion app/lib/methods/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { isSsl } from './helpers';
import { BASIC_AUTH_KEY } from './helpers/fetch';
import database, { getDatabase } from '../database';
import log from './helpers/log';
import { disconnect } from '../services/connect';
import sdk from '../services/sdk';
import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys';
import UserPreferences from './userPreferences';
Expand Down Expand Up @@ -111,7 +112,7 @@ export async function logout({ server }: { server: string }): Promise<void> {
}

if (sdk.current) {
sdk.disconnect();
disconnect();
}

await removeServerData({ server });
Expand Down
4 changes: 4 additions & 0 deletions app/lib/services/connect.ios.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { determineAuthType } from './connect';

jest.mock('./voip/MediaSessionInstance', () => ({
mediaSessionInstance: { reset: jest.fn(), init: jest.fn() }
}));

// Mock the isIOS helper to return true for iOS-specific tests
jest.mock('../methods/helpers', () => ({
...jest.requireActual('../methods/helpers'),
Expand Down
4 changes: 4 additions & 0 deletions app/lib/services/connect.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { determineAuthType } from './connect';

jest.mock('./voip/MediaSessionInstance', () => ({
mediaSessionInstance: { reset: jest.fn(), init: jest.fn() }
}));

// Mock the isIOS helper
jest.mock('../methods/helpers/deviceInfo', () => ({
...jest.requireActual('../methods/helpers/deviceInfo'),
Expand Down
5 changes: 4 additions & 1 deletion app/lib/services/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { twoFactor } from './twoFactor';
import { store } from '../store/auxStore';
import { loginRequest, logout, setLoginServices, setUser } from '../../actions/login';
import sdk from './sdk';
import { mediaSessionInstance } from './voip/MediaSessionInstance';
import I18n from '../../i18n';
import { type ICredentials, type ILoggedUser, STATUSES } from '../../definitions';
import { connectRequest, connectSuccess, disconnect as disconnectAction } from '../../actions/connect';
Expand Down Expand Up @@ -407,7 +408,9 @@ function checkAndReopen() {
}

function disconnect() {
return sdk.disconnect();
const result = sdk.disconnect();
mediaSessionInstance.reset();
return result;
}

async function getWebsocketInfo({
Expand Down
159 changes: 159 additions & 0 deletions app/lib/services/voip/MediaSessionInstance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { mediaSessionStore } from './MediaSessionStore';
import { mediaSessionInstance } from './MediaSessionInstance';

const mockCallStoreReset = jest.fn();

jest.mock('./useCallStore', () => ({
useCallStore: {
getState: jest.fn(() => ({
reset: mockCallStoreReset,
setCall: jest.fn(),
callId: null as string | null
}))
}
}));

const mockOnStreamDataStop = jest.fn();
const mockOnStreamData = jest.fn(() => ({ stop: mockOnStreamDataStop }));
const mockMethodCall = jest.fn();

jest.mock('../sdk', () => ({
__esModule: true,
default: {
onStreamData: (...args: Parameters<typeof mockOnStreamData>) => mockOnStreamData(...args),
methodCall: (...args: unknown[]) => mockMethodCall(...args)
}
}));

jest.mock('../../store/auxStore', () => ({
store: {
getState: jest.fn(() => ({
settings: {
VoIP_TeamCollab_Ice_Servers: '',
VoIP_TeamCollab_Ice_Gathering_Timeout: 5000
}
})),
subscribe: jest.fn(() => jest.fn())
}
}));

jest.mock('react-native-webrtc', () => ({
registerGlobals: jest.fn(),
mediaDevices: { getUserMedia: jest.fn() }
}));

jest.mock('react-native-callkeep', () => ({}));

jest.mock('react-native-device-info', () => ({
getUniqueId: jest.fn(() => 'test-device-id')
}));

jest.mock('../../native/NativeVoip', () => ({
__esModule: true,
default: { stopNativeDDPClient: jest.fn() }
}));

jest.mock('../../navigation/appNavigation', () => ({
__esModule: true,
default: { navigate: jest.fn() }
}));

type SessionRecord = { userId: string; endSession: jest.Mock };
const createdSessions: SessionRecord[] = [];

jest.mock('@rocket.chat/media-signaling', () => ({
MediaCallWebRTCProcessor: jest.fn().mockImplementation(function MediaCallWebRTCProcessor(this: unknown) {
return this;
}),
MediaSignalingSession: jest.fn().mockImplementation(function MockMediaSignalingSession(this: any, config: { userId: string }) {
const endSession = jest.fn();
createdSessions.push({ userId: config.userId, endSession });
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();
})
}));

describe('MediaSessionInstance', () => {
beforeEach(() => {
jest.clearAllMocks();
createdSessions.length = 0;
mediaSessionInstance.reset();
});

afterEach(() => {
mediaSessionInstance.reset();
});

describe('init', () => {
it('should register stream-notify-user listener', () => {
mediaSessionInstance.init('user-1');
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 route sendSignal through sdk.methodCall with user media-calls channel', () => {
const spy = jest.spyOn(mediaSessionStore, 'setSendSignalFn');
mediaSessionInstance.init('user-xyz');
expect(spy).toHaveBeenCalled();
const sendFn = spy.mock.calls[spy.mock.calls.length - 1][0] as (signal: { type: string }) => void;
sendFn({ type: 'register' });
expect(mockMethodCall).toHaveBeenCalledWith(
'stream-notify-user',
'user-xyz/media-calls',
expect.stringContaining('register')
);
spy.mockRestore();
});
});

describe('teardown and user switch', () => {
it('should call endSession on previous session when init with different userId', () => {
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');
});

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 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 init after reset', () => {
mediaSessionInstance.init('user-1');
mediaSessionInstance.reset();
mediaSessionInstance.init('user-2');
expect(createdSessions[createdSessions.length - 1].userId).toBe('user-2');
});

it('should not throw when reset is called twice', () => {
mediaSessionInstance.init('user-1');
expect(() => {
mediaSessionInstance.reset();
mediaSessionInstance.reset();
}).not.toThrow();
});
});
});
26 changes: 16 additions & 10 deletions app/lib/services/voip/MediaSessionInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ class MediaSessionInstance {
private iceServers: IceServer[] = [];
private iceGatheringTimeout: number = 5000;
private mediaSignalListener: { stop: () => void } | null = null;
private mediaSignalsListener: { stop: () => void } | null = null;
private instance: MediaSignalingSession | null = null;
private mediaSessionStoreChangeUnsubscribe: (() => void) | null = null;
private storeTimeoutUnsubscribe: (() => void) | null = null;
private storeIceServersUnsubscribe: (() => void) | null = null;

public init(userId: string): void {
this.stop();
this.reset();
registerGlobals();
this.configureIceServers();
// prevent JS and native DDP clients from interfering with each other
Expand All @@ -50,7 +50,9 @@ class MediaSessionInstance {
sdk.methodCall('stream-notify-user', `${userId}/media-calls`, JSON.stringify(signal));
});
this.instance = mediaSessionStore.getInstance(userId);
mediaSessionStore.onChange(() => (this.instance = mediaSessionStore.getInstance(userId)));
this.mediaSessionStoreChangeUnsubscribe = mediaSessionStore.onChange(() => {
this.instance = mediaSessionStore.getInstance(userId);
});

this.mediaSignalListener = sdk.onStreamData('stream-notify-user', (ddpMessage: IDDPMessage) => {
if (!this.instance) {
Expand Down Expand Up @@ -168,22 +170,26 @@ class MediaSessionInstance {
});
}

private stop() {
public reset() {
if (this.mediaSessionStoreChangeUnsubscribe) {
this.mediaSessionStoreChangeUnsubscribe();
this.mediaSessionStoreChangeUnsubscribe = null;
}
if (this.mediaSignalListener?.stop) {
this.mediaSignalListener.stop();
}
if (this.mediaSignalsListener?.stop) {
this.mediaSignalsListener.stop();
}
this.mediaSignalListener = null;
if (this.storeTimeoutUnsubscribe) {
this.storeTimeoutUnsubscribe();
this.storeTimeoutUnsubscribe = null;
}
if (this.storeIceServersUnsubscribe) {
this.storeIceServersUnsubscribe();
this.storeIceServersUnsubscribe = null;
}
if (this.instance) {
this.instance.endSession();
}
mediaSessionStore.dispose();
this.instance = null;
useCallStore.getState().reset();
}
}

Expand Down
10 changes: 10 additions & 0 deletions app/lib/services/voip/MediaSessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ class MediaSessionStore extends Emitter<{ change: void }> {
public getCurrentInstance(): MediaSignalingSession | null {
return this.sessionInstance;
}

public dispose(): void {
if (this.sessionInstance !== null) {
this.sessionInstance.endSession();
this.sessionInstance = null;
}
this.sendSignalFn = null;
this._webrtcProcessorFactory = null;
this.change();
}
}

// TODO: change name
Expand Down
8 changes: 5 additions & 3 deletions app/sagas/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { getSlashCommands } from '../lib/methods/getSlashCommands';
import { getUserPresence, subscribeUsersPresence } from '../lib/methods/getUsersPresence';
import { logout, removeServerData, removeServerDatabase } from '../lib/methods/logout';
import { subscribeSettings } from '../lib/methods/getSettings';
import { loginWithPassword, login } from '../lib/services/connect';
import { disconnect, loginWithPassword, login } from '../lib/services/connect';
import { saveUserProfile, registerPushToken, getUsersRoles } from '../lib/services/restApi';
import { setUsersRoles } from '../actions/usersRoles';
import { getServerById } from '../lib/database/services/Server';
Expand Down Expand Up @@ -234,6 +234,8 @@ const startVoipFork = function* startVoipFork() {
if (isVoipModuleAvailable() && (hasPermissions[0] || hasPermissions[1])) {
const userId = yield select(state => state.login.user.id);
mediaSessionInstance.init(userId);
} else {
mediaSessionInstance.reset();
}
} catch (e) {
log(e);
Expand Down Expand Up @@ -411,10 +413,10 @@ const handleDeleteAccount = function* handleDeleteAccount() {
}
}
// if there's no servers, go outside
sdk.disconnect();
disconnect();
yield put(appStart({ root: RootEnum.ROOT_OUTSIDE }));
} catch (e) {
sdk.disconnect();
disconnect();
yield put(appStart({ root: RootEnum.ROOT_OUTSIDE }));
log(e);
}
Expand Down
2 changes: 2 additions & 0 deletions app/sagas/selectServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { setPermissions } from '../lib/methods/getPermissions';
import { setRoles } from '../lib/methods/getRoles';
import { connect, disconnect, getWebsocketInfo, getLoginServices } from '../lib/services/connect';
import sdk from '../lib/services/sdk';
import { mediaSessionInstance } from '../lib/services/voip/MediaSessionInstance';
import { appSelector } from '../lib/hooks/useAppSelector';
import { getServerById } from '../lib/database/services/Server';
import { getLoggedUserById } from '../lib/database/services/LoggedUser';
Expand Down Expand Up @@ -150,6 +151,7 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
yield put(inquiryReset());
yield put(encryptionStop());
yield put(clearActiveUsers());
mediaSessionInstance.reset();
const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`);
let user = null;
if (userId) {
Expand Down
4 changes: 2 additions & 2 deletions app/views/NewServerView/hooks/useConnectServer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Keyboard } from 'react-native';
import { useDispatch } from 'react-redux';

import sdk from '../../../lib/services/sdk';
import { disconnect } from '../../../lib/services/connect';
import { events, logEvent } from '../../../lib/methods/helpers/log';
import { selectServerClear, serverRequest } from '../../../actions/server';
import completeUrl from '../utils/completeUrl';
Expand All @@ -24,7 +24,7 @@ const useConnectServer = ({ workspaceUrl, certificate, previousServer }: TUseNew

// Clear the previous workspace to prevent being stuck on the previous server
if (!previousServer) {
sdk.disconnect();
disconnect();
dispatch(selectServerClear());
}
if (workspaceUrl || serverUrl) {
Expand Down
Loading