diff --git a/app/lib/services/voip/MediaCallEvents.ios.test.ts b/app/lib/services/voip/MediaCallEvents.ios.test.ts new file mode 100644 index 0000000000..b6850417f1 --- /dev/null +++ b/app/lib/services/voip/MediaCallEvents.ios.test.ts @@ -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(); + }); +}); diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 86cfc73d96..74dc10e7b3 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -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 diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts index 706c79dd54..69feb829e6 100644 --- a/app/lib/services/voip/useCallStore.test.ts +++ b/app/lib/services/voip/useCallStore.test.ts @@ -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 void>> = {}; diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 1ed618f489..75f8c21751 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -175,6 +175,12 @@ export const useCallStore = create((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 = () => {