diff --git a/app/index.tsx b/app/index.tsx index 8ff8e9d1d1b..3c3ceeef2d0 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -33,7 +33,11 @@ import { } from './lib/methods/helpers/theme'; import { initializePushNotifications, onNotification } from './lib/notifications'; import { getInitialNotification, setupVideoConfActionListener } from './lib/notifications/videoConf/getInitialNotification'; -import { getInitialMediaCallEvents, setupMediaCallEvents } from './lib/services/voip/MediaCallEvents'; +import { + getInitialMediaCallEvents, + setupMediaCallEvents, + type MediaCallEventsAdapters +} from './lib/services/voip/MediaCallEvents'; import store from './lib/store'; import { initStore } from './lib/store/auxStore'; import { type TSupportedThemes, ThemeContext } from './theme'; @@ -109,6 +113,13 @@ export default class Root extends React.Component<{}, IState> { setNativeTheme(theme); } + private getMediaCallEventsAdapters(): MediaCallEventsAdapters { + return { + getActiveServerUrl: () => store.getState().server.server, + onOpenDeepLink: params => store.dispatch(deepLinkingOpen(params)) + }; + } + componentDidMount() { this.listenerTimeout = setTimeout(() => { Linking.addEventListener('url', ({ url }) => { @@ -123,7 +134,7 @@ export default class Root extends React.Component<{}, IState> { // Set up video conf action listener for background accept/decline this.videoConfActionCleanup = setupVideoConfActionListener(); // Set up media call event listeners for incoming calls - this.mediaCallEventCleanup = setupMediaCallEvents(); + this.mediaCallEventCleanup = setupMediaCallEvents(this.getMediaCallEventsAdapters()); } componentWillUnmount() { @@ -153,7 +164,7 @@ export default class Root extends React.Component<{}, IState> { return; } - const voipInitialHandled = await getInitialMediaCallEvents(); + const voipInitialHandled = await getInitialMediaCallEvents(this.getMediaCallEventsAdapters()); if (voipInitialHandled) { // VoIP path already dispatched navigation (or will via deep linking); do not call appInit() in parallel return; diff --git a/app/lib/services/voip/MediaCallEvents.ios.test.ts b/app/lib/services/voip/MediaCallEvents.ios.test.ts index b6850417f11..3025e9f64cf 100644 --- a/app/lib/services/voip/MediaCallEvents.ios.test.ts +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -1,16 +1,66 @@ /** * @jest-environment node * - * iOS-only mute tests: requires isIOS = true so didPerformSetMutedCallAction listener is registered. + * iOS-only paths: isIOS = true, NativeEventEmitter for VoIP events, CallKit listeners. */ -import { setupMediaCallEvents } from './MediaCallEvents'; +import RNCallKeep from 'react-native-callkeep'; + +import type { VoipPayload } from '../../../definitions/Voip'; +import NativeVoipModule from '../../native/NativeVoip'; +import { registerPushToken } from '../restApi'; +import { + getInitialMediaCallEvents, + resetMediaCallEventsStateForTesting, + setupMediaCallEvents, + type MediaCallEventsAdapters +} from './MediaCallEvents'; import { useCallStore } from './useCallStore'; -const mockAddEventListener = jest.fn(); +/** Shared bucket for NativeEventEmitter / DeviceEventEmitter VoIP listeners (Jest allows `mock*` refs inside factories). */ +const mockNativeVoipListeners: Record void)[]> = {}; + +/** Factory: returns an addListener implementation that stores listeners in `bucket`. + * Named with `mock` prefix so Jest factory scope rules allow it inside jest.mock() calls. */ +function mockMakeAddListener(bucket: Record void)[]>) { + return (eventType: string, listener: (payload: unknown) => void) => { + bucket[eventType] = bucket[eventType] || []; + bucket[eventType].push(listener); + return { + remove() { + const list = bucket[eventType]; + if (!list) { + return; + } + const idx = list.indexOf(listener); + if (idx >= 0) { + list.splice(idx, 1); + } + } + }; + }; +} + +/** Minimal RN surface for MediaCallEvents — avoid `requireActual('react-native')` in @jest-environment node. + * addListener is defined as a method (not a field initializer) so that mockNativeVoipListeners is + * captured lazily at call time rather than eagerly when the mock factory / class field runs. */ +jest.mock('react-native', () => ({ + Platform: { OS: 'ios' }, + DeviceEventEmitter: { + addListener(eventType: string, listener: (payload: unknown) => void) { + return mockMakeAddListener(mockNativeVoipListeners)(eventType, listener); + } + }, + NativeEventEmitter: class { + addListener(eventType: string, listener: (payload: unknown) => void) { + return mockMakeAddListener(mockNativeVoipListeners)(eventType, listener); + } + } +})); jest.mock('../../methods/helpers', () => ({ - ...jest.requireActual('../../methods/helpers'), - isIOS: true + isIOS: true, + normalizeDeepLinkingServerHost: jest.requireActual('../../methods/helpers/normalizeDeepLinkingServerHost') + .normalizeDeepLinkingServerHost })); jest.mock('./useCallStore', () => ({ @@ -19,13 +69,6 @@ jest.mock('./useCallStore', () => ({ } })); -jest.mock('../../store', () => ({ - __esModule: true, - default: { - dispatch: jest.fn() - } -})); - jest.mock('../../native/NativeVoip', () => ({ __esModule: true, default: { @@ -36,7 +79,8 @@ jest.mock('../../native/NativeVoip', () => ({ jest.mock('./MediaSessionInstance', () => ({ mediaSessionInstance: { - endCall: jest.fn() + endCall: jest.fn(), + applyRestStateSignals: jest.fn(() => Promise.resolve()) } })); @@ -44,6 +88,17 @@ jest.mock('../restApi', () => ({ registerPushToken: jest.fn(() => Promise.resolve()) })); +jest.mock('./MediaCallLogger', () => ({ + MediaCallLogger: class { + log = jest.fn(); + debug = jest.fn(); + error = jest.fn(); + warn = jest.fn(); + } +})); + +const mockAddEventListener = jest.fn(); + jest.mock('react-native-callkeep', () => ({ __esModule: true, default: { @@ -54,51 +109,90 @@ jest.mock('react-native-callkeep', () => ({ } })); -const activeCallBase = { - call: {} as object, - callId: 'uuid-1', - nativeAcceptedCallId: null as string | null -}; +const mockOnOpenDeepLink = jest.fn(); +const mockServerSelector = jest.fn(() => 'https://workspace-ios.example.com'); + +function makeTestAdapters(): MediaCallEventsAdapters { + return { + getActiveServerUrl: () => mockServerSelector(), + onOpenDeepLink: mockOnOpenDeepLink + }; +} + +function emitNativeVoipEvent(eventType: string, payload: unknown): void { + mockNativeVoipListeners[eventType]?.forEach(fn => { + fn(payload); + }); +} + +function getEndCallHandler(): (payload: { callUUID: string }) => void { + const call = mockAddEventListener.mock.calls.find(([name]) => name === 'endCall'); + if (!call) { + throw new Error('endCall listener not registered'); + } + return call[1] as (payload: { callUUID: string }) => void; +} -function getMuteHandler(): (payload: { muted: boolean; callUUID: string }) => void { +function getMuteHandler(): (p: { muted: boolean; callUUID: string }) => void { const call = mockAddEventListener.mock.calls.find(([name]) => name === 'didPerformSetMutedCallAction'); if (!call) { throw new Error('didPerformSetMutedCallAction listener not registered'); } - return call[1] as (payload: { muted: boolean; callUUID: string }) => void; + return call[1] as (p: { muted: boolean; callUUID: string }) => void; +} + +function buildIncomingPayload(overrides: Partial = {}): VoipPayload { + return { + callId: 'ios-call-uuid', + caller: 'caller-id', + username: 'caller', + host: 'https://other-server.example.com', + hostName: 'Other', + type: 'incoming_call', + notificationId: 1, + ...overrides + }; } +const activeCallBase = { + call: {} as object, + callId: 'uuid-1', + nativeAcceptedCallId: null as string | null +}; + describe('setupMediaCallEvents — didPerformSetMutedCallAction (iOS)', () => { const toggleMute = jest.fn(); const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); + Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); toggleMute.mockClear(); mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute }); }); it('registers didPerformSetMutedCallAction via RNCallKeep.addEventListener', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); expect(mockAddEventListener).toHaveBeenCalledWith('didPerformSetMutedCallAction', expect.any(Function)); }); it('calls toggleMute when muted state differs from OS and UUIDs match', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); expect(toggleMute).toHaveBeenCalledTimes(1); }); it('does not call toggleMute when muted state already matches OS even if UUIDs match', () => { getState.mockReturnValue({ ...activeCallBase, isMuted: true, toggleMute }); - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); expect(toggleMute).not.toHaveBeenCalled(); }); it('drops event when callUUID does not match active call id', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); getMuteHandler()({ muted: true, callUUID: 'uuid-2' }); expect(toggleMute).not.toHaveBeenCalled(); }); @@ -111,8 +205,128 @@ describe('setupMediaCallEvents — didPerformSetMutedCallAction (iOS)', () => { isMuted: false, toggleMute }); - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); getMuteHandler()({ muted: true, callUUID: 'uuid-1' }); expect(toggleMute).not.toHaveBeenCalled(); }); }); + +describe('setupMediaCallEvents — VoipPushTokenRegistered (iOS)', () => { + const getState = useCallStore.getState as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); + Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + getState.mockReturnValue({}); + }); + + it('registers push token with no arguments when native emits VoipPushTokenRegistered', async () => { + setupMediaCallEvents(makeTestAdapters()); + emitNativeVoipEvent('VoipPushTokenRegistered', { token: 'voip-token-xyz' }); + await Promise.resolve(); + expect(registerPushToken).toHaveBeenCalledWith(); + }); +}); + +describe('getInitialMediaCallEvents — iOS cold start', () => { + const getState = useCallStore.getState as jest.Mock; + const mockSetNativeAcceptedCallId = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); + Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + (NativeVoipModule.getInitialEvents as jest.Mock).mockReset(); + (RNCallKeep.getInitialEvents as jest.Mock).mockReset(); + getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); + }); + + it('returns true and applies REST signals when CallKit shows answered and host matches workspace', async () => { + const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); + const callId = 'answered-ios-uuid'; + mockServerSelector.mockReturnValue('https://same.example.com'); + (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( + buildIncomingPayload({ + callId, + host: 'https://same.example.com' + }) + ); + (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([ + { name: 'RNCallKeepPerformAnswerCallAction', data: { callUUID: callId } } + ]); + + const result = await getInitialMediaCallEvents(makeTestAdapters()); + + expect(result).toBe(true); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith(callId); + expect(mediaSessionInstance.applyRestStateSignals).toHaveBeenCalled(); + expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); + }); + + it('returns true and opens deep link when answered on cold start but host differs from workspace', async () => { + const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); + const callId = 'answered-cross-ws'; + mockServerSelector.mockReturnValue('https://workspace-ios.example.com'); + (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( + buildIncomingPayload({ + callId, + host: 'https://foreign.example.com' + }) + ); + (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([ + { name: 'RNCallKeepPerformAnswerCallAction', data: { callUUID: callId } } + ]); + + const result = await getInitialMediaCallEvents(makeTestAdapters()); + + expect(result).toBe(true); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith(callId); + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ + callId, + host: 'https://foreign.example.com' + }); + expect(mediaSessionInstance.applyRestStateSignals).not.toHaveBeenCalled(); + }); + + it('returns false when CallKit initial events have no RNCallKeepPerformAnswerCallAction', async () => { + const callId = 'unanswered-ios-uuid'; + (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( + buildIncomingPayload({ callId, host: 'https://workspace-ios.example.com' }) + ); + (RNCallKeep.getInitialEvents as jest.Mock).mockResolvedValue([ + { name: 'RNCallKeepDidDisplayIncomingCall', data: { callUUID: callId } } + ]); + + const result = await getInitialMediaCallEvents(makeTestAdapters()); + + expect(result).toBe(false); + expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); + expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); + }); +}); + +describe('setupMediaCallEvents — endCall clears accept dedupe (iOS)', () => { + const getState = useCallStore.getState as jest.Mock; + const mockSetNativeAcceptedCallId = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); + Object.keys(mockNativeVoipListeners).forEach(k => delete mockNativeVoipListeners[k]); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); + }); + + it('allows a second VoipAcceptSucceeded with the same callId after endCall', () => { + setupMediaCallEvents(makeTestAdapters()); + const payload = buildIncomingPayload({ callId: 'reuse-id', host: 'https://foreign.example.com' }); + emitNativeVoipEvent('VoipAcceptSucceeded', payload); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + getEndCallHandler()({ callUUID: 'any' }); + emitNativeVoipEvent('VoipAcceptSucceeded', payload); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/lib/services/voip/MediaCallEvents.test.ts b/app/lib/services/voip/MediaCallEvents.test.ts index 4fc9b9d0e5f..c61c61315dd 100644 --- a/app/lib/services/voip/MediaCallEvents.test.ts +++ b/app/lib/services/voip/MediaCallEvents.test.ts @@ -1,13 +1,17 @@ import { DeviceEventEmitter } from 'react-native'; import RNCallKeep from 'react-native-callkeep'; -import { DEEP_LINKING } from '../../../actions/actionsTypes'; import type { VoipPayload } from '../../../definitions/Voip'; import NativeVoipModule from '../../native/NativeVoip'; -import { getInitialMediaCallEvents, setupMediaCallEvents } from './MediaCallEvents'; +import { + getInitialMediaCallEvents, + resetMediaCallEventsStateForTesting, + setupMediaCallEvents, + type MediaCallEventsAdapters +} from './MediaCallEvents'; import { useCallStore } from './useCallStore'; -const mockDispatch = jest.fn(); +const mockOnOpenDeepLink = jest.fn(); const mockSetNativeAcceptedCallId = jest.fn(); const mockAddEventListener = jest.fn(); const mockRNCallKeepClearInitialEvents = jest.fn(); @@ -19,13 +23,13 @@ jest.mock('../../methods/helpers', () => ({ })); const mockServerSelector = jest.fn(() => 'https://workspace-a.example.com'); -jest.mock('../../store', () => ({ - __esModule: true, - default: { - dispatch: (...args: unknown[]) => mockDispatch(...args), - getState: () => ({ server: { server: mockServerSelector() } }) - } -})); + +function makeTestAdapters(): MediaCallEventsAdapters { + return { + getActiveServerUrl: () => mockServerSelector(), + onOpenDeepLink: mockOnOpenDeepLink + }; +} jest.mock('./useCallStore', () => ({ useCallStore: { @@ -62,6 +66,15 @@ jest.mock('../restApi', () => ({ registerPushToken: jest.fn(() => Promise.resolve()) })); +jest.mock('./MediaCallLogger', () => ({ + MediaCallLogger: class { + log = jest.fn(); + debug = jest.fn(); + error = jest.fn(); + warn = jest.fn(); + } +})); + function buildIncomingPayload(overrides: Partial = {}): VoipPayload { return { callId: 'call-b-uuid', @@ -98,10 +111,11 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { beforeEach(() => { jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue(null); getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); - teardown = setupMediaCallEvents(); + teardown = setupMediaCallEvents(makeTestAdapters()); }); afterEach(() => { @@ -110,7 +124,7 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { }); describe('VoipAcceptSucceeded', () => { - it('sets nativeAcceptedCallId and dispatches deepLinkingOpen with host and callId for incoming_call', () => { + it('sets nativeAcceptedCallId and opens deep link with host and callId for incoming_call', () => { const payload = buildIncomingPayload({ callId: 'workspace-b-call', host: 'https://workspace-b.open.rocket.chat' @@ -120,17 +134,14 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalledTimes(1); expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('workspace-b-call'); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ - type: DEEP_LINKING.OPEN, - params: { - callId: 'workspace-b-call', - host: 'https://workspace-b.open.rocket.chat' - } + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ + callId: 'workspace-b-call', + host: 'https://workspace-b.open.rocket.chat' }); }); - it('skips deepLinkingOpen and replays REST state signals when host matches active workspace', () => { + it('skips deep link open and replays REST state signals when host matches active workspace', () => { const { mediaSessionInstance } = jest.requireMock('./MediaSessionInstance'); mockServerSelector.mockReturnValueOnce('https://workspace-a.example.com'); const payload = buildIncomingPayload({ @@ -142,10 +153,10 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('same-ws-call'); expect(mediaSessionInstance.applyRestStateSignals).toHaveBeenCalledTimes(1); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); }); - it('does not dispatch or set native id when type is not incoming_call', () => { + it('does not open deep link or set native id when type is not incoming_call', () => { DeviceEventEmitter.emit( 'VoipAcceptSucceeded', buildIncomingPayload({ @@ -156,7 +167,7 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { expect(NativeVoipModule.clearInitialEvents).not.toHaveBeenCalled(); expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); - expect(mockDispatch).not.toHaveBeenCalled(); + expect(mockOnOpenDeepLink).not.toHaveBeenCalled(); }); it('dedupes duplicate VoipAcceptSucceeded for the same callId (idempotent native delivery)', () => { @@ -166,12 +177,12 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); }); }); describe('VoipAcceptFailed', () => { - it('dispatches deepLinkingOpen with voipAcceptFailed after native failure event', () => { + it('opens deep link with voipAcceptFailed after native failure event', () => { DeviceEventEmitter.emit( 'VoipAcceptFailed', buildIncomingPayload({ @@ -181,15 +192,12 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { }) ); - expect(mockDispatch).toHaveBeenCalledTimes(1); - expect(mockDispatch).toHaveBeenCalledWith({ - type: DEEP_LINKING.OPEN, - params: { - host: 'https://workspace-b.example.com', - callId: 'failed-b', - username: 'remote-user', - voipAcceptFailed: true - } + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ + host: 'https://workspace-b.example.com', + callId: 'failed-b', + username: 'remote-user', + voipAcceptFailed: true }); expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalled(); }); @@ -200,7 +208,16 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { DeviceEventEmitter.emit('VoipAcceptFailed', raw); DeviceEventEmitter.emit('VoipAcceptFailed', raw); - expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + }); + + it('allows a second failure delivery for the same callId after resetMediaCallEventsStateForTesting', () => { + const raw = buildIncomingPayload({ callId: 'fail-reset' }); + DeviceEventEmitter.emit('VoipAcceptFailed', raw); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + resetMediaCallEventsStateForTesting(); + DeviceEventEmitter.emit('VoipAcceptFailed', raw); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(2); }); }); }); @@ -208,6 +225,7 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { describe('getInitialMediaCallEvents', () => { beforeEach(() => { jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); mockRNCallKeepClearInitialEvents.mockClear(); (NativeVoipModule.getInitialEvents as jest.Mock).mockReset(); @@ -216,7 +234,7 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); }); - it('returns true and dispatches failure deep link when stash has voipAcceptFailed + host + callId', async () => { + it('returns true and opens failure deep link when stash has voipAcceptFailed + host + callId', async () => { (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue({ voipAcceptFailed: true, callId: 'cold-fail-call', @@ -228,24 +246,21 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { notificationId: 1 }); - const result = await getInitialMediaCallEvents(); + const result = await getInitialMediaCallEvents(makeTestAdapters()); expect(result).toBe(true); - expect(mockDispatch).toHaveBeenCalledWith({ - type: DEEP_LINKING.OPEN, - params: { - host: 'https://server-b.cold', - callId: 'cold-fail-call', - username: 'caller-cold', - voipAcceptFailed: true - } + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ + host: 'https://server-b.cold', + callId: 'cold-fail-call', + username: 'caller-cold', + voipAcceptFailed: true }); expect(mockRNCallKeepClearInitialEvents).toHaveBeenCalled(); expect(NativeVoipModule.clearInitialEvents).toHaveBeenCalled(); expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); }); - it('on Android cold start, dispatches success deep link when incoming payload is present (answered path)', async () => { + it('on Android cold start, opens success deep link when incoming payload is present (answered path)', async () => { (NativeVoipModule.getInitialEvents as jest.Mock).mockReturnValue( buildIncomingPayload({ callId: 'android-cold-accept', @@ -253,39 +268,101 @@ describe('MediaCallEvents cross-server accept (slice 3)', () => { }) ); - const result = await getInitialMediaCallEvents(); + const result = await getInitialMediaCallEvents(makeTestAdapters()); expect(result).toBe(true); expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('android-cold-accept'); - expect(mockDispatch).toHaveBeenCalledWith({ - type: DEEP_LINKING.OPEN, - params: { - callId: 'android-cold-accept', - host: 'https://android-b.example.com' - } + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ + callId: 'android-cold-accept', + host: 'https://android-b.example.com' }); }); }); }); +describe('VoipAcceptSucceeded sentinel-correctness (Android)', () => { + const getState = useCallStore.getState as jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); + mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); + getState.mockReturnValue({ setNativeAcceptedCallId: mockSetNativeAcceptedCallId }); + }); + + it('B1: outgoing_call with same callId does not poison sentinel for subsequent incoming_call', () => { + setupMediaCallEvents(makeTestAdapters()); + + // First emit: outgoing_call — type guard should bail before setting sentinel + DeviceEventEmitter.emit('VoipAcceptSucceeded', buildIncomingPayload({ callId: 'shared-id', type: 'outgoing_call' })); + expect(mockSetNativeAcceptedCallId).not.toHaveBeenCalled(); + + // Second emit: incoming_call with same callId — must NOT be suppressed + DeviceEventEmitter.emit( + 'VoipAcceptSucceeded', + buildIncomingPayload({ callId: 'shared-id', type: 'incoming_call', host: 'https://server-b.example.com' }) + ); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('shared-id'); + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + }); + + it('B2: different callIds are both processed (not suppressed)', () => { + setupMediaCallEvents(makeTestAdapters()); + + DeviceEventEmitter.emit( + 'VoipAcceptSucceeded', + buildIncomingPayload({ callId: 'call-A', host: 'https://server-b.example.com' }) + ); + DeviceEventEmitter.emit( + 'VoipAcceptSucceeded', + buildIncomingPayload({ callId: 'call-B', host: 'https://server-b.example.com' }) + ); + + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('call-A'); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledWith('call-B'); + }); + + it('B3: getActiveServerUrl returning undefined falls through to deep-link path', () => { + const adapters: MediaCallEventsAdapters = { + getActiveServerUrl: () => undefined, + onOpenDeepLink: mockOnOpenDeepLink + }; + setupMediaCallEvents(adapters); + + DeviceEventEmitter.emit( + 'VoipAcceptSucceeded', + buildIncomingPayload({ callId: 'any-call', host: 'https://server-b.example.com' }) + ); + + // isVoipIncomingHostCurrentWorkspace returns false when active URL is falsy + expect(mockOnOpenDeepLink).toHaveBeenCalledTimes(1); + expect(mockOnOpenDeepLink).toHaveBeenCalledWith({ + callId: 'any-call', + host: 'https://server-b.example.com' + }); + }); +}); + describe('setupMediaCallEvents — didToggleHoldCallAction', () => { const toggleHold = jest.fn(); const getState = useCallStore.getState as jest.Mock; beforeEach(() => { jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); toggleHold.mockClear(); mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() })); getState.mockReturnValue({ ...activeCallBase, isOnHold: false, toggleHold }); }); it('registers didToggleHoldCallAction via RNCallKeep.addEventListener', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); expect(mockAddEventListener).toHaveBeenCalledWith('didToggleHoldCallAction', expect.any(Function)); }); it('hold: true when isOnHold is false calls toggleHold once and does not setCurrentCallActive', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' }); expect(toggleHold).toHaveBeenCalledTimes(1); expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); @@ -293,13 +370,13 @@ describe('setupMediaCallEvents — didToggleHoldCallAction', () => { it('hold: true when isOnHold is true does not call toggleHold', () => { getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); getToggleHoldHandler()({ hold: true, callUUID: 'uuid-1' }); expect(toggleHold).not.toHaveBeenCalled(); }); it('hold: false after OS-initiated hold calls toggleHold once (auto-resume) and setCurrentCallActive', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); const handler = getToggleHoldHandler(); handler({ hold: true, callUUID: 'uuid-1' }); getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); @@ -310,14 +387,14 @@ describe('setupMediaCallEvents — didToggleHoldCallAction', () => { }); it('hold: false without prior OS-initiated hold does not call toggleHold or setCurrentCallActive', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); getToggleHoldHandler()({ hold: false, callUUID: 'uuid-1' }); expect(toggleHold).not.toHaveBeenCalled(); expect(mockSetCurrentCallActive).not.toHaveBeenCalled(); }); it('consecutive hold: true events call toggleHold only once', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); const handler = getToggleHoldHandler(); handler({ hold: true, callUUID: 'uuid-1' }); getState.mockReturnValue({ ...activeCallBase, isOnHold: true, toggleHold }); @@ -326,7 +403,7 @@ describe('setupMediaCallEvents — didToggleHoldCallAction', () => { }); it('clears stale auto-hold when callUUID does not match current call id (e.g. new workspace / call)', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); const handler = getToggleHoldHandler(); handler({ hold: true, callUUID: 'uuid-1' }); expect(toggleHold).toHaveBeenCalledTimes(1); @@ -346,7 +423,7 @@ describe('setupMediaCallEvents — didToggleHoldCallAction', () => { }); it('does not toggle when there is no active call object even if ids match', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); const handler = getToggleHoldHandler(); getState.mockReturnValue({ call: null, @@ -360,7 +437,7 @@ describe('setupMediaCallEvents — didToggleHoldCallAction', () => { }); it('hold: false does not call toggleHold when user already manually resumed before OS unhold arrives', () => { - setupMediaCallEvents(); + setupMediaCallEvents(makeTestAdapters()); const handler = getToggleHoldHandler(); // OS holds the call @@ -384,7 +461,7 @@ describe('setupMediaCallEvents — didToggleHoldCallAction', () => { } return { remove: jest.fn() }; }); - const cleanup = setupMediaCallEvents(); + const cleanup = setupMediaCallEvents(makeTestAdapters()); cleanup(); expect(remove).toHaveBeenCalled(); }); diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 0dcba950376..a058f5bf02a 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -2,24 +2,40 @@ import RNCallKeep from 'react-native-callkeep'; import { DeviceEventEmitter, NativeEventEmitter } from 'react-native'; import { isIOS, normalizeDeepLinkingServerHost } from '../../methods/helpers'; -import store from '../../store'; -import { deepLinkingOpen } from '../../../actions/deepLinking'; import { useCallStore } from './useCallStore'; import { mediaSessionInstance } from './MediaSessionInstance'; import type { VoipPayload } from '../../../definitions/Voip'; import NativeVoipModule from '../../native/NativeVoip'; import { registerPushToken } from '../restApi'; +import { MediaCallLogger } from './MediaCallLogger'; const Emitter = isIOS ? new NativeEventEmitter(NativeVoipModule) : DeviceEventEmitter; const platform = isIOS ? 'iOS' : 'Android'; const TAG = `[MediaCallEvents][${platform}]`; +const mediaCallLogger = new MediaCallLogger(); const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed'; const EVENT_VOIP_ACCEPT_SUCCEEDED = 'VoipAcceptSucceeded'; +/** Params forwarded into the app deep-linking pipeline for VoIP-driven navigation. */ +export type VoipDeepLinkParams = { + host?: string; + callId?: string; + username?: string; + voipAcceptFailed?: boolean; +}; + +export type MediaCallEventsAdapters = { + getActiveServerUrl: () => string | null | undefined; + onOpenDeepLink: (params: VoipDeepLinkParams) => void; +}; + /** True when normalized incoming host matches the active Redux workspace (no server switch needed). */ -function isVoipIncomingHostCurrentWorkspace(incomingHost: string): boolean { - const active = store.getState().server.server; +function isVoipIncomingHostCurrentWorkspace( + incomingHost: string, + getActiveServerUrl: MediaCallEventsAdapters['getActiveServerUrl'] +): boolean { + const active = getActiveServerUrl(); if (!active || !incomingHost) { return false; } @@ -31,7 +47,20 @@ let lastHandledVoipAcceptFailureCallId: string | null = null; /** Idempotent warm delivery of native accept success. */ let lastHandledVoipAcceptSucceededCallId: string | null = null; -function dispatchVoipAcceptFailureFromNative(raw: VoipPayload & { voipAcceptFailed?: boolean }) { +export function clearVoipAcceptDedupeSentinels(): void { + lastHandledVoipAcceptFailureCallId = null; + lastHandledVoipAcceptSucceededCallId = null; +} + +/** Exported for tests only. Clears accept-dedupe sentinels between test cases. Production code calls clearVoipAcceptDedupeSentinels() directly. */ +export function resetMediaCallEventsStateForTesting(): void { + clearVoipAcceptDedupeSentinels(); +} + +function dispatchVoipAcceptFailureFromNative( + raw: VoipPayload & { voipAcceptFailed?: boolean }, + onOpenDeepLink: MediaCallEventsAdapters['onOpenDeepLink'] +) { if (!raw.voipAcceptFailed) { return; } @@ -39,67 +68,64 @@ function dispatchVoipAcceptFailureFromNative(raw: VoipPayload & { voipAcceptFail if (callId && lastHandledVoipAcceptFailureCallId === callId) { return; } - lastHandledVoipAcceptFailureCallId = callId; - store.dispatch( - deepLinkingOpen({ - host: raw.host, - callId: raw.callId, - username: raw.username, - voipAcceptFailed: true - }) - ); + lastHandledVoipAcceptFailureCallId = callId ?? null; + onOpenDeepLink({ + host: raw.host, + callId: raw.callId, + username: raw.username, + voipAcceptFailed: true + }); } -function handleVoipAcceptSucceededFromNative(data: VoipPayload) { +function handleVoipAcceptSucceededFromNative(data: VoipPayload, adapters: MediaCallEventsAdapters) { const { callId } = data; if (callId && lastHandledVoipAcceptSucceededCallId === callId) { return; } - if (callId) { - lastHandledVoipAcceptSucceededCallId = callId; - } if (data.type !== 'incoming_call') { - console.log(`${TAG} VoipAcceptSucceeded: not an incoming call`); + mediaCallLogger.log(`${TAG} VoipAcceptSucceeded: not an incoming call`); return; } - console.log(`${TAG} VoipAcceptSucceeded:`, data); + if (callId) { + lastHandledVoipAcceptSucceededCallId = callId; + } + mediaCallLogger.log(`${TAG} VoipAcceptSucceeded:`, data); NativeVoipModule.clearInitialEvents(); useCallStore.getState().setNativeAcceptedCallId(data.callId); - if (data.host && isVoipIncomingHostCurrentWorkspace(data.host)) { + if (data.host && isVoipIncomingHostCurrentWorkspace(data.host, adapters.getActiveServerUrl)) { mediaSessionInstance.applyRestStateSignals().catch(error => { - console.error(`${TAG} applyRestStateSignals failed:`, error); + mediaCallLogger.error(`${TAG} applyRestStateSignals failed:`, error); }); return; } - store.dispatch( - deepLinkingOpen({ - callId: data.callId, - host: data.host - }) - ); + adapters.onOpenDeepLink({ + callId: data.callId, + host: data.host + }); } /** * Sets up listeners for media call events. * @returns Cleanup function to remove listeners */ -export const setupMediaCallEvents = (): (() => void) => { +export const setupMediaCallEvents = (adapters: MediaCallEventsAdapters): (() => void) => { const subscriptions: { remove: () => void }[] = []; // iOS listens for VoIP push token registration and CallKeep events if (isIOS) { subscriptions.push( Emitter.addListener('VoipPushTokenRegistered', ({ token }: { token: string }) => { - console.log(`${TAG} Registered VoIP push token:`, token); + mediaCallLogger.log(`${TAG} Registered VoIP push token:`, token); registerPushToken().catch(error => { - console.log(`${TAG} Failed to register push token after VoIP update:`, error); + mediaCallLogger.warn(`${TAG} Failed to register push token after VoIP update:`, error); }); }) ); subscriptions.push( RNCallKeep.addEventListener('endCall', ({ callUUID }) => { - console.log(`${TAG} End call event listener:`, callUUID); + mediaCallLogger.log(`${TAG} End call event listener:`, callUUID); + clearVoipAcceptDedupeSentinels(); mediaSessionInstance.endCall(callUUID); }) ); @@ -163,17 +189,17 @@ export const setupMediaCallEvents = (): (() => void) => { subscriptions.push( Emitter.addListener(EVENT_VOIP_ACCEPT_SUCCEEDED, (data: VoipPayload) => { try { - handleVoipAcceptSucceededFromNative(data); + handleVoipAcceptSucceededFromNative(data, adapters); } catch (error) { - console.error(`${TAG} Error handling VoipAcceptSucceeded:`, error); + mediaCallLogger.error(`${TAG} Error handling VoipAcceptSucceeded:`, error); } }) ); subscriptions.push( Emitter.addListener(EVENT_VOIP_ACCEPT_FAILED, (data: VoipPayload & { voipAcceptFailed?: boolean }) => { - console.log(`${TAG} VoipAcceptFailed event:`, data); - dispatchVoipAcceptFailureFromNative({ ...data, voipAcceptFailed: true }); + mediaCallLogger.log(`${TAG} VoipAcceptFailed event:`, data); + dispatchVoipAcceptFailureFromNative({ ...data, voipAcceptFailed: true }, adapters.onOpenDeepLink); NativeVoipModule.clearInitialEvents(); }) ); @@ -187,18 +213,18 @@ export const setupMediaCallEvents = (): (() => void) => { * Handles initial media call events (cold start). * @returns true if startup should skip the default `appInit()` path (answered call, or accept failure handed to deep linking) */ -export const getInitialMediaCallEvents = async (): Promise => { +export const getInitialMediaCallEvents = async (adapters: MediaCallEventsAdapters): Promise => { try { const initialEvents = NativeVoipModule.getInitialEvents() as (VoipPayload & { voipAcceptFailed?: boolean }) | null; if (!initialEvents) { - console.log(`${TAG} No initial events from native module`); + mediaCallLogger.log(`${TAG} No initial events from native module`); RNCallKeep.clearInitialEvents(); return false; } - console.log(`${TAG} Found initial events:`, initialEvents); + mediaCallLogger.log(`${TAG} Found initial events:`, initialEvents); if (initialEvents.voipAcceptFailed && initialEvents.callId && initialEvents.host) { - dispatchVoipAcceptFailureFromNative(initialEvents); + dispatchVoipAcceptFailureFromNative(initialEvents, adapters.onOpenDeepLink); RNCallKeep.clearInitialEvents(); NativeVoipModule.clearInitialEvents(); // Avoid racing `appInit()` with the deep-linking saga that handles the failure @@ -206,7 +232,7 @@ export const getInitialMediaCallEvents = async (): Promise => { } if (!initialEvents.callId || !initialEvents.host || initialEvents.type !== 'incoming_call') { - console.log(`${TAG} Missing required call data`); + mediaCallLogger.log(`${TAG} Missing required call data`); RNCallKeep.clearInitialEvents(); return false; } @@ -217,7 +243,7 @@ export const getInitialMediaCallEvents = async (): Promise => { if (isIOS) { const callKeepInitialEvents = await RNCallKeep.getInitialEvents(); RNCallKeep.clearInitialEvents(); - console.log(`${TAG} CallKeep initial events:`, JSON.stringify(callKeepInitialEvents, null, 2)); + mediaCallLogger.log(`${TAG} CallKeep initial events:`, JSON.stringify(callKeepInitialEvents, null, 2)); for (const event of callKeepInitialEvents) { const { name, data } = event; @@ -225,7 +251,7 @@ export const getInitialMediaCallEvents = async (): Promise => { const { callUUID } = data; if (initialEvents.callId === callUUID) { wasAnswered = true; - console.log(`${TAG} Call was already answered via CallKit`); + mediaCallLogger.log(`${TAG} Call was already answered via CallKit`); break; } } @@ -238,26 +264,24 @@ export const getInitialMediaCallEvents = async (): Promise => { if (wasAnswered) { useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId); - if (initialEvents.host && isVoipIncomingHostCurrentWorkspace(initialEvents.host)) { + if (initialEvents.host && isVoipIncomingHostCurrentWorkspace(initialEvents.host, adapters.getActiveServerUrl)) { mediaSessionInstance.applyRestStateSignals().catch(error => { - console.error(`${TAG} applyRestStateSignals (initial) failed:`, error); + mediaCallLogger.error(`${TAG} applyRestStateSignals (initial) failed:`, error); }); - console.log(`${TAG} Same workspace as VoIP host; skipped deepLinkingOpen`); + mediaCallLogger.log(`${TAG} Same workspace as VoIP host; skipped deepLinkingOpen`); return true; } - store.dispatch( - deepLinkingOpen({ - callId: initialEvents.callId, - host: initialEvents.host - }) - ); - console.log(`${TAG} Dispatched deepLinkingOpen for VoIP`); + adapters.onOpenDeepLink({ + callId: initialEvents.callId, + host: initialEvents.host + }); + mediaCallLogger.log(`${TAG} Dispatched deepLinkingOpen for VoIP`); } - return Promise.resolve(wasAnswered); + return wasAnswered; } catch (error) { - console.error(`${TAG} Error:`, error); + mediaCallLogger.error(`${TAG} Error:`, error); return false; } }; diff --git a/app/lib/services/voip/MediaCallLogger.ts b/app/lib/services/voip/MediaCallLogger.ts index 0d9444d7f7a..7b30d580799 100644 --- a/app/lib/services/voip/MediaCallLogger.ts +++ b/app/lib/services/voip/MediaCallLogger.ts @@ -12,10 +12,10 @@ export class MediaCallLogger implements IMediaSignalLogger { } error(...args: unknown[]): void { - console.log(`[Media Call Error] ${JSON.stringify(args)}`); + console.error(`[Media Call Error] ${JSON.stringify(args)}`); } warn(...args: unknown[]): void { - console.log(`[Media Call Warning] ${JSON.stringify(args)}`); + console.warn(`[Media Call Warning] ${JSON.stringify(args)}`); } } diff --git a/app/lib/services/voip/resetVoipState.test.ts b/app/lib/services/voip/resetVoipState.test.ts new file mode 100644 index 00000000000..105ec87deba --- /dev/null +++ b/app/lib/services/voip/resetVoipState.test.ts @@ -0,0 +1,149 @@ +import { DeviceEventEmitter } from 'react-native'; + +import { resetMediaCallEventsStateForTesting, setupMediaCallEvents, type MediaCallEventsAdapters } from './MediaCallEvents'; +import { resetVoipState } from './resetVoipState'; +import { useCallStore } from './useCallStore'; + +jest.mock('../../methods/helpers', () => ({ + ...jest.requireActual('../../methods/helpers'), + isIOS: false +})); + +jest.mock('./useCallStore', () => ({ + useCallStore: { + getState: jest.fn() + } +})); + +jest.mock('../../native/NativeVoip', () => ({ + __esModule: true, + default: { + clearInitialEvents: jest.fn(), + getInitialEvents: jest.fn(() => null) + } +})); + +jest.mock('react-native-callkeep', () => ({ + __esModule: true, + default: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + clearInitialEvents: jest.fn(), + setCurrentCallActive: jest.fn(), + getInitialEvents: jest.fn(() => Promise.resolve([])) + } +})); + +jest.mock('./MediaSessionInstance', () => ({ + mediaSessionInstance: { + endCall: jest.fn(), + applyRestStateSignals: jest.fn(() => Promise.resolve()) + } +})); + +jest.mock('../restApi', () => ({ + registerPushToken: jest.fn(() => Promise.resolve()) +})); + +jest.mock('./MediaCallLogger', () => ({ + MediaCallLogger: class { + log = jest.fn(); + debug = jest.fn(); + error = jest.fn(); + warn = jest.fn(); + } +})); + +const mockOnOpenDeepLink = jest.fn(); +const mockSetNativeAcceptedCallId = jest.fn(); + +function makeTestAdapters(): MediaCallEventsAdapters { + return { + getActiveServerUrl: () => undefined, + onOpenDeepLink: mockOnOpenDeepLink + }; +} + +describe('resetVoipState', () => { + it('calls resetNativeCallId before reset (native id must clear before store reset)', () => { + const order: string[] = []; + const resetNativeCallId = jest.fn(() => { + order.push('resetNativeCallId'); + }); + const reset = jest.fn(() => { + order.push('reset'); + }); + (useCallStore.getState as jest.Mock).mockReturnValue({ resetNativeCallId, reset }); + + resetVoipState(); + + expect(order).toEqual(['resetNativeCallId', 'reset']); + }); + + it('C2: clearVoipAcceptDedupeSentinels runs before resetNativeCallId and reset', () => { + const order: string[] = []; + const clearSpy = jest.spyOn( + jest.requireActual('./MediaCallEvents') as { clearVoipAcceptDedupeSentinels: () => void }, + 'clearVoipAcceptDedupeSentinels' + ); + + const resetNativeCallId = jest.fn(() => { + order.push('resetNativeCallId'); + }); + const reset = jest.fn(() => { + order.push('reset'); + }); + clearSpy.mockImplementation(() => { + order.push('clearVoipAcceptDedupeSentinels'); + }); + (useCallStore.getState as jest.Mock).mockReturnValue({ resetNativeCallId, reset }); + + resetVoipState(); + + expect(order).toEqual(['clearVoipAcceptDedupeSentinels', 'resetNativeCallId', 'reset']); + + clearSpy.mockRestore(); + }); + + it('C1: after resetVoipState, a previously-handled callId is processed again (sentinel cleared)', () => { + (useCallStore.getState as jest.Mock).mockReturnValue({ + setNativeAcceptedCallId: mockSetNativeAcceptedCallId, + resetNativeCallId: jest.fn(), + reset: jest.fn() + }); + jest.clearAllMocks(); + resetMediaCallEventsStateForTesting(); + + // Wire up event listeners so DeviceEventEmitter delivers VoipAcceptSucceeded + setupMediaCallEvents(makeTestAdapters()); + + const payload = { + callId: 'reused-call-id', + caller: 'caller-id', + username: 'caller', + host: 'https://server.example.com', + hostName: 'Server', + type: 'incoming_call' as const, + notificationId: 1 + }; + + // First delivery — handled, sentinel set + DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); + + // Second delivery without reset — suppressed by dedupe + DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1); + + // Reset clears sentinel + (useCallStore.getState as jest.Mock).mockReturnValue({ + setNativeAcceptedCallId: mockSetNativeAcceptedCallId, + resetNativeCallId: jest.fn(), + reset: jest.fn() + }); + resetVoipState(); + + // Third delivery — must be processed again + DeviceEventEmitter.emit('VoipAcceptSucceeded', payload); + expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2); + }); +}); diff --git a/app/lib/services/voip/resetVoipState.ts b/app/lib/services/voip/resetVoipState.ts new file mode 100644 index 00000000000..c839e1fb9a6 --- /dev/null +++ b/app/lib/services/voip/resetVoipState.ts @@ -0,0 +1,10 @@ +import { useCallStore } from './useCallStore'; +import { clearVoipAcceptDedupeSentinels } from './MediaCallEvents'; + +/** Resets VoIP UI / native-call-id state after accept failure or similar teardown (deep linking saga). Also clears accept-dedupe sentinels so Android cold-start and re-delivery paths are not poisoned by a prior call. */ +export function resetVoipState(): void { + clearVoipAcceptDedupeSentinels(); + const { resetNativeCallId, reset } = useCallStore.getState(); + resetNativeCallId(); + reset(); +} diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 692e3bb8159..92a15b26ce2 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -27,7 +27,7 @@ import { loginOAuthOrSso } from '../lib/services/connect'; import { notifyUser } from '../lib/services/restApi'; import sdk from '../lib/services/sdk'; import Navigation from '../lib/navigation/appNavigation'; -import { useCallStore } from '../lib/services/voip/useCallStore'; +import { resetVoipState } from '../lib/services/voip/resetVoipState'; const roomTypes = { channel: 'c', @@ -99,8 +99,7 @@ const navigate = function* navigate({ params }) { const handleVoipAcceptFailed = function* handleVoipAcceptFailed(params) { try { const { callId, username } = params; - useCallStore.getState().resetNativeCallId(); - useCallStore.getState().reset(); + resetVoipState(); if (callId) { RNCallKeep.endCall(callId); }