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
4 changes: 3 additions & 1 deletion app/containers/MediaCallHeader/components/Content.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Pressable, StyleSheet, View } from 'react-native';

import { useAppSelector } from '../../../lib/hooks/useAppSelector';
import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom';
import { useCallStore } from '../../../lib/services/voip/useCallStore';
import Title from './Title';
Expand All @@ -19,6 +20,7 @@ const styles = StyleSheet.create({
});

export const Content = () => {
const isMasterDetail = useAppSelector(state => state.app.isMasterDetail);
const roomId = useCallStore(state => state.roomId);
const contact = useCallStore(state => state.contact);
const contentDisabled = Boolean(contact.sipExtension) || roomId == null;
Expand All @@ -29,7 +31,7 @@ export const Content = () => {
testID='media-call-header-content'
disabled={contentDisabled}
onPress={() => {
navigateToCallRoom().catch(() => undefined);
navigateToCallRoom({ isMasterDetail }).catch(() => undefined);
}}
style={pressableStyle}>
<View style={styles.container}>
Expand Down
7 changes: 3 additions & 4 deletions app/lib/services/connect.ios.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { determineAuthType } from './connect';

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

// Mock the isIOS helper to return true for iOS-specific tests
jest.mock('../methods/helpers', () => ({
...jest.requireActual('../methods/helpers'),
jest.mock('../methods/helpers/deviceInfo', () => ({
...jest.requireActual('../methods/helpers/deviceInfo'),
isIOS: true
}));

Expand Down
12 changes: 10 additions & 2 deletions app/lib/services/connect.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { determineAuthType } from './connect';
import { determineAuthType, disconnect } from './connect';
import { mediaSessionInstance } from './voip/MediaSessionInstance';

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

// Mock the isIOS helper
Expand Down Expand Up @@ -305,4 +306,11 @@ describe('determineAuthType', () => {
});
});

describe('VoIP media session lifecycle (disconnect)', () => {
it('calls mediaSessionInstance.reset when disconnect runs', () => {
disconnect();
expect(mediaSessionInstance.reset).toHaveBeenCalledTimes(1);
});
});

// Note: Apple authentication when isIOS is true is tested in connect.ios.test.ts
142 changes: 141 additions & 1 deletion app/lib/services/restApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,61 @@
import type { ServerMediaSignal } from '@rocket.chat/media-signaling';
import { Platform } from 'react-native';

import { mediaCallsStateSignals } from './restApi';

const mockSdkGet = jest.fn();
const mockSdkPost = jest.fn();

jest.mock('./sdk', () => ({
__esModule: true,
default: {
get: (...args: unknown[]) => mockSdkGet(...args)
get: (...args: unknown[]) => mockSdkGet(...args),
post: (...args: unknown[]) => mockSdkPost(...args)
}
}));

jest.mock('../notifications', () => ({
getDeviceToken: jest.fn()
}));

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

jest.mock('react-native-device-info', () => {
const mock = require('react-native-device-info/jest/react-native-device-info-mock');
const getUniqueId = jest.fn(() => Promise.resolve('unique-device-id'));
const defaultExport = {
...mock,
getUniqueId
};
return {
__esModule: true,
default: defaultExport,
getUniqueId
};
});

function loadRegisterPushToken(platform: 'ios' | 'android' = 'android') {
jest.resetModules();
Object.defineProperty(Platform, 'OS', { configurable: true, writable: true, value: platform });
// eslint-disable-next-line @typescript-eslint/no-require-imports
const notifications = require('../notifications');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const voipNative = require('../native/NativeVoip').default;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { registerPushToken } = require('./restApi');
return {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
registerPushToken: registerPushToken as typeof import('./restApi').registerPushToken,
getDeviceToken: jest.mocked(notifications.getDeviceToken),
getLastVoipToken: jest.mocked(voipNative.getLastVoipToken)
};
}

describe('mediaCallsStateSignals', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -55,3 +101,97 @@ describe('mediaCallsStateSignals', () => {
expect(result.success).toBe(false);
});
});

describe('registerPushToken', () => {
const platformOsAtSuiteStart = Platform.OS;

afterEach(() => {
Object.defineProperty(Platform, 'OS', { configurable: true, writable: true, value: platformOsAtSuiteStart });
});

beforeEach(() => {
jest.clearAllMocks();
mockSdkPost.mockResolvedValue(undefined);
});

it('returns early when there is no device push token', async () => {
const { registerPushToken, getDeviceToken: getToken } = loadRegisterPushToken();
getToken.mockReturnValue('');

await registerPushToken();

expect(mockSdkPost).not.toHaveBeenCalled();
});

it('on iOS registers apn payload without voipToken when VoIP token is missing', async () => {
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios');
getToken.mockReturnValue('apns-token');
getVoip.mockReturnValue('');

await registerPushToken();

expect(mockSdkPost).toHaveBeenCalledTimes(1);
expect(mockSdkPost).toHaveBeenCalledWith(
'push.token',
expect.objectContaining({
id: 'unique-device-id',
value: 'apns-token',
type: 'apn',
appName: expect.any(String)
})
);
const payload = mockSdkPost.mock.calls[0][1] as Record<string, unknown>;
expect(Object.prototype.hasOwnProperty.call(payload, 'voipToken')).toBe(false);
});

it('on Android still registers when VoIP token is missing', async () => {
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('android');
getToken.mockReturnValue('fcm-token');
getVoip.mockReturnValue('');

await registerPushToken();

expect(mockSdkPost).toHaveBeenCalledTimes(1);
expect(mockSdkPost).toHaveBeenCalledWith(
'push.token',
expect.objectContaining({
id: 'unique-device-id',
value: 'fcm-token',
type: 'gcm',
appName: expect.any(String)
})
);
const payload = mockSdkPost.mock.calls[0][1] as Record<string, unknown>;
expect(Object.prototype.hasOwnProperty.call(payload, 'voipToken')).toBe(false);
});

it('dedupes when the same push and VoIP tokens are registered again', async () => {
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios');
getToken.mockReturnValue('apns-token');
getVoip.mockReturnValue('voip-token');

await registerPushToken();
await registerPushToken();

expect(mockSdkPost).toHaveBeenCalledTimes(1);
});

it('on iOS posts apn payload with voipToken when both tokens are present', async () => {
const { registerPushToken, getDeviceToken: getToken, getLastVoipToken: getVoip } = loadRegisterPushToken('ios');
getToken.mockReturnValue('apns-token');
getVoip.mockReturnValue('voip-token');

await registerPushToken();

expect(mockSdkPost).toHaveBeenCalledWith(
'push.token',
expect.objectContaining({
id: 'unique-device-id',
value: 'apns-token',
type: 'apn',
appName: expect.any(String),
voipToken: 'voip-token'
})
);
});
});
45 changes: 27 additions & 18 deletions app/lib/services/voip/navigateToCallRoom.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { goRoom } from '../../methods/helpers/goRoom';
import Navigation from '../../navigation/appNavigation';
import { store } from '../../store/auxStore';
import { useCallStore } from './useCallStore';
import { navigateToCallRoom } from './navigateToCallRoom';
import { SubscriptionType } from '../../../definitions';
Expand All @@ -15,12 +14,6 @@ jest.mock('../../methods/helpers/goRoom', () => ({
goRoom: jest.fn().mockResolvedValue(undefined)
}));

jest.mock('../../store/auxStore', () => ({
store: {
getState: jest.fn()
}
}));

jest.mock('../../navigation/appNavigation', () => ({
__esModule: true,
default: {
Expand All @@ -31,7 +24,6 @@ jest.mock('../../navigation/appNavigation', () => ({

const mockGetState = jest.mocked(useCallStore.getState);
const mockGoRoom = jest.mocked(goRoom);
const mockStoreGetState = jest.mocked(store.getState);
const mockNavigation = jest.mocked(Navigation);

type CallStoreSnapshot = ReturnType<typeof useCallStore.getState>;
Expand All @@ -48,7 +40,6 @@ describe('navigateToCallRoom', () => {

beforeEach(() => {
jest.clearAllMocks();
mockStoreGetState.mockReturnValue({ app: { isMasterDetail: true } } as ReturnType<typeof store.getState>);
mockNavigation.getCurrentRoute.mockReturnValue({ name: 'RoomsListView' } as any);
});

Expand All @@ -62,7 +53,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(mockGoRoom).not.toHaveBeenCalled();
expect(toggleFocus).not.toHaveBeenCalled();
Expand All @@ -79,7 +70,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(mockGoRoom).not.toHaveBeenCalled();
expect(toggleFocus).not.toHaveBeenCalled();
Expand All @@ -96,7 +87,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(mockGoRoom).not.toHaveBeenCalled();
expect(toggleFocus).not.toHaveBeenCalled();
Expand All @@ -113,7 +104,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(toggleFocus).toHaveBeenCalledTimes(1);
expect(mockGoRoom).toHaveBeenCalledWith({
Expand All @@ -133,7 +124,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(toggleFocus).not.toHaveBeenCalled();
expect(mockGoRoom).toHaveBeenCalledWith({
Expand All @@ -153,7 +144,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator');
expect(mockGoRoom).toHaveBeenCalledWith({
Expand All @@ -174,7 +165,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator');
expect(mockGoRoom).toHaveBeenCalled();
Expand All @@ -192,7 +183,7 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator');
expect(mockGoRoom).toHaveBeenCalled();
Expand All @@ -210,9 +201,27 @@ describe('navigateToCallRoom', () => {
})
);

await navigateToCallRoom();
await navigateToCallRoom({ isMasterDetail: true });

expect(mockNavigation.navigate).not.toHaveBeenCalled();
expect(mockGoRoom).toHaveBeenCalled();
});

it('passes isMasterDetail from the caller into goRoom', async () => {
mockGetState.mockReturnValue(
mockCallStoreState({
roomId: 'rid-1',
contact: { username: 'alice', sipExtension: '' },
focused: false,
toggleFocus
})
);

await navigateToCallRoom({ isMasterDetail: false });

expect(mockGoRoom).toHaveBeenCalledWith({
item: { rid: 'rid-1', name: 'alice', t: SubscriptionType.DIRECT },
isMasterDetail: false
});
});
});
7 changes: 1 addition & 6 deletions app/lib/services/voip/navigateToCallRoom.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { SubscriptionType } from '../../../definitions';
import { goRoom } from '../../methods/helpers/goRoom';
import Navigation from '../../navigation/appNavigation';
import { store } from '../../store/auxStore';
import { useCallStore } from './useCallStore';

/**
* From the VoIP UI, open the DM for the active call: minimizes CallView when it is focused, then navigates.
* No-ops for SIP calls or when room id or username is missing.
*/
export async function navigateToCallRoom(): Promise<void> {
export async function navigateToCallRoom({ isMasterDetail }: { isMasterDetail: boolean }): Promise<void> {
const { roomId, contact, focused, toggleFocus } = useCallStore.getState();

if (!roomId || contact.sipExtension) {
Expand All @@ -24,10 +23,6 @@ export async function navigateToCallRoom(): Promise<void> {
toggleFocus();
}

const {
app: { isMasterDetail }
} = store.getState();

// If we're not in the chats navigator (e.g., in Profile/Settings/Accessibility screens),
// navigate to ChatsStackNavigator first to ensure goRoom works correctly
const currentRoute = Navigation.getCurrentRoute() as any;
Expand Down
Loading
Loading