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
118 changes: 118 additions & 0 deletions app/lib/services/voip/MediaCallEvents.ios.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/**
* @jest-environment node
*
* iOS-only mute tests: requires isIOS = true so didPerformSetMutedCallAction listener is registered.
*/
import { setupMediaCallEvents } from './MediaCallEvents';
import { useCallStore } from './useCallStore';

const mockAddEventListener = jest.fn();

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

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

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

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

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

jest.mock('../restApi', () => ({
registerPushToken: jest.fn(() => Promise.resolve())
}));

jest.mock('react-native-callkeep', () => ({
__esModule: true,
default: {
addEventListener: (...args: unknown[]) => mockAddEventListener(...args),
clearInitialEvents: jest.fn(),
setCurrentCallActive: jest.fn(),
getInitialEvents: jest.fn(() => Promise.resolve([]))
}
}));

const activeCallBase = {
call: {} as object,
callId: 'uuid-1',
nativeAcceptedCallId: null as string | null
};

function getMuteHandler(): (payload: { 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;
}

describe('setupMediaCallEvents — didPerformSetMutedCallAction (iOS)', () => {
const toggleMute = jest.fn();
const getState = useCallStore.getState as jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
toggleMute.mockClear();
mockAddEventListener.mockImplementation(() => ({ remove: jest.fn() }));
getState.mockReturnValue({ ...activeCallBase, isMuted: false, toggleMute });
});

it('registers didPerformSetMutedCallAction via RNCallKeep.addEventListener', () => {
setupMediaCallEvents();
expect(mockAddEventListener).toHaveBeenCalledWith('didPerformSetMutedCallAction', expect.any(Function));
});

it('calls toggleMute when muted state differs from OS and UUIDs match', () => {
setupMediaCallEvents();
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();
getMuteHandler()({ muted: true, callUUID: 'uuid-1' });
expect(toggleMute).not.toHaveBeenCalled();
});

it('drops event when callUUID does not match active call id', () => {
setupMediaCallEvents();
getMuteHandler()({ muted: true, callUUID: 'uuid-2' });
expect(toggleMute).not.toHaveBeenCalled();
});

it('drops event when there is no active call object even if UUIDs match', () => {
getState.mockReturnValue({
call: null,
callId: 'uuid-1',
nativeAcceptedCallId: null,
isMuted: false,
toggleMute
});
setupMediaCallEvents();
getMuteHandler()({ muted: true, callUUID: 'uuid-1' });
expect(toggleMute).not.toHaveBeenCalled();
});
});
18 changes: 18 additions & 0 deletions app/lib/services/voip/MediaCallEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,24 @@ export const setupMediaCallEvents = (): (() => void) => {
})
);

subscriptions.push(
RNCallKeep.addEventListener('didPerformSetMutedCallAction', ({ muted, callUUID }) => {
const { call, callId, nativeAcceptedCallId, toggleMute, isMuted } = useCallStore.getState();
const eventUuid = callUUID.toLowerCase();
const activeUuid = (callId ?? nativeAcceptedCallId ?? '').toLowerCase();

// No active media call or event is for another CallKit/Telecom session — drop stale closure state
if (!call || !activeUuid || eventUuid !== activeUuid) {
return;
}

// Sync mute state if it doesn't match what the OS is reporting
if (muted !== isMuted) {
toggleMute();
}
})
);

// Note: there is intentionally no 'answerCall' listener here.
// VoipService.swift handles accept natively: handleObservedCallChanged detects
// hasConnected = true and calls handleNativeAccept(), which sends the DDP accept
Expand Down
10 changes: 9 additions & 1 deletion app/lib/services/voip/useCallStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ jest.mock('../../../containers/ActionSheet', () => ({
hideActionSheetRef: jest.fn()
}));

jest.mock('react-native-callkeep', () => ({}));
jest.mock('react-native-callkeep', () => ({
setCurrentCallActive: jest.fn(),
addEventListener: jest.fn(() => ({ remove: jest.fn() })),
endCall: jest.fn(),
start: jest.fn(),
stop: jest.fn(),
setForceSpeakerphoneOn: jest.fn(),
setAvailable: jest.fn()
}));

function createMockCall(callId: string) {
const listeners: Record<string, Set<(...args: unknown[]) => void>> = {};
Expand Down
6 changes: 6 additions & 0 deletions app/lib/services/voip/useCallStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ export const useCallStore = create<CallStore>((set, get) => ({
if (newState === 'active' && !get().callStartTime) {
set({ callStartTime: Date.now() });
}

// Tell CallKit the call is active so iOS shows it in the system UI (lock screen, Control Center, Dynamic Island)
if (newState === 'active') {
const { callId, nativeAcceptedCallId } = get();
RNCallKeep.setCurrentCallActive(callId ?? nativeAcceptedCallId ?? '');
}
};

const handleTrackStateChange = () => {
Expand Down
Loading