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
17 changes: 14 additions & 3 deletions app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down
264 changes: 239 additions & 25 deletions app/lib/services/voip/MediaCallEvents.ios.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ((payload: unknown) => 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<string, ((payload: unknown) => 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);
}
}
}));
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jest.mock('../../methods/helpers', () => ({
...jest.requireActual('../../methods/helpers'),
isIOS: true
isIOS: true,
normalizeDeepLinkingServerHost: jest.requireActual('../../methods/helpers/normalizeDeepLinkingServerHost')
.normalizeDeepLinkingServerHost
}));

jest.mock('./useCallStore', () => ({
Expand All @@ -19,13 +69,6 @@ jest.mock('./useCallStore', () => ({
}
}));

jest.mock('../../store', () => ({
__esModule: true,
default: {
dispatch: jest.fn()
}
}));

jest.mock('../../native/NativeVoip', () => ({
__esModule: true,
default: {
Expand All @@ -36,14 +79,26 @@ jest.mock('../../native/NativeVoip', () => ({

jest.mock('./MediaSessionInstance', () => ({
mediaSessionInstance: {
endCall: jest.fn()
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 mockAddEventListener = jest.fn();

jest.mock('react-native-callkeep', () => ({
__esModule: true,
default: {
Expand All @@ -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> = {}): 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();
});
Expand All @@ -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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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