diff --git a/app/containers/MediaCallHeader/components/Content.tsx b/app/containers/MediaCallHeader/components/Content.tsx index 2072825ab8f..0d4fc0a328f 100644 --- a/app/containers/MediaCallHeader/components/Content.tsx +++ b/app/containers/MediaCallHeader/components/Content.tsx @@ -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'; @@ -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; @@ -29,7 +31,7 @@ export const Content = () => { testID='media-call-header-content' disabled={contentDisabled} onPress={() => { - navigateToCallRoom().catch(() => undefined); + navigateToCallRoom({ isMasterDetail }).catch(() => undefined); }} style={pressableStyle}> diff --git a/app/lib/services/connect.ios.test.ts b/app/lib/services/connect.ios.test.ts index 1db1ec22ed9..f09cf1778c2 100644 --- a/app/lib/services/connect.ios.test.ts +++ b/app/lib/services/connect.ios.test.ts @@ -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 })); diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts index 34e1ceea898..d632eb0f917 100644 --- a/app/lib/services/connect.test.ts +++ b/app/lib/services/connect.test.ts @@ -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 @@ -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 diff --git a/app/lib/services/restApi.test.ts b/app/lib/services/restApi.test.ts index d2f5b81e9f7..27ac04b16ae 100644 --- a/app/lib/services/restApi.test.ts +++ b/app/lib/services/restApi.test.ts @@ -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(); @@ -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; + 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; + 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' + }) + ); + }); +}); diff --git a/app/lib/services/voip/navigateToCallRoom.test.ts b/app/lib/services/voip/navigateToCallRoom.test.ts index 3f5a1bb8563..b1c4543c6bd 100644 --- a/app/lib/services/voip/navigateToCallRoom.test.ts +++ b/app/lib/services/voip/navigateToCallRoom.test.ts @@ -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'; @@ -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: { @@ -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; @@ -48,7 +40,6 @@ describe('navigateToCallRoom', () => { beforeEach(() => { jest.clearAllMocks(); - mockStoreGetState.mockReturnValue({ app: { isMasterDetail: true } } as ReturnType); mockNavigation.getCurrentRoute.mockReturnValue({ name: 'RoomsListView' } as any); }); @@ -62,7 +53,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(mockGoRoom).not.toHaveBeenCalled(); expect(toggleFocus).not.toHaveBeenCalled(); @@ -79,7 +70,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(mockGoRoom).not.toHaveBeenCalled(); expect(toggleFocus).not.toHaveBeenCalled(); @@ -96,7 +87,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(mockGoRoom).not.toHaveBeenCalled(); expect(toggleFocus).not.toHaveBeenCalled(); @@ -113,7 +104,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(toggleFocus).toHaveBeenCalledTimes(1); expect(mockGoRoom).toHaveBeenCalledWith({ @@ -133,7 +124,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(toggleFocus).not.toHaveBeenCalled(); expect(mockGoRoom).toHaveBeenCalledWith({ @@ -153,7 +144,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator'); expect(mockGoRoom).toHaveBeenCalledWith({ @@ -174,7 +165,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator'); expect(mockGoRoom).toHaveBeenCalled(); @@ -192,7 +183,7 @@ describe('navigateToCallRoom', () => { }) ); - await navigateToCallRoom(); + await navigateToCallRoom({ isMasterDetail: true }); expect(mockNavigation.navigate).toHaveBeenCalledWith('ChatsStackNavigator'); expect(mockGoRoom).toHaveBeenCalled(); @@ -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 + }); + }); }); diff --git a/app/lib/services/voip/navigateToCallRoom.ts b/app/lib/services/voip/navigateToCallRoom.ts index ff158b14bd0..fb6ea8fa977 100644 --- a/app/lib/services/voip/navigateToCallRoom.ts +++ b/app/lib/services/voip/navigateToCallRoom.ts @@ -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 { +export async function navigateToCallRoom({ isMasterDetail }: { isMasterDetail: boolean }): Promise { const { roomId, contact, focused, toggleFocus } = useCallStore.getState(); if (!roomId || contact.sipExtension) { @@ -24,10 +23,6 @@ export async function navigateToCallRoom(): Promise { 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; diff --git a/app/views/CallView/components/CallButtons.tsx b/app/views/CallView/components/CallButtons.tsx index ad6335d224e..27562823398 100644 --- a/app/views/CallView/components/CallButtons.tsx +++ b/app/views/CallView/components/CallButtons.tsx @@ -3,6 +3,7 @@ import { View } from 'react-native'; import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; import I18n from '../../../i18n'; +import { useAppSelector } from '../../../lib/hooks/useAppSelector'; import { navigateToCallRoom } from '../../../lib/services/voip/navigateToCallRoom'; import { useCallStore, useControlsVisible } from '../../../lib/services/voip/useCallStore'; import CallActionButton from './CallActionButton'; @@ -27,6 +28,7 @@ export const CallButtons = () => { 'use memo'; const { colors } = useTheme(); + const isMasterDetail = useAppSelector(state => state.app.isMasterDetail); const { layoutMode } = useCallLayoutMode(); const { width, height } = useResponsiveLayout(); const isLandscape = width > height; @@ -55,7 +57,7 @@ export const CallButtons = () => { const messageDisabled = Boolean(contact.sipExtension) || roomId == null; const handleMessage = () => { - navigateToCallRoom().catch(() => undefined); + navigateToCallRoom({ isMasterDetail }).catch(() => undefined); }; const handleDialpad = () => {