diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt index 8e67e69fa9a..941237e6a8e 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt @@ -16,7 +16,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo companion object { private const val TAG = "RocketChat.VoipModule" - private const val EVENT_INITIAL_EVENTS = "VoipPushInitialEvents" + private const val EVENT_VOIP_ACCEPT_SUCCEEDED = "VoipAcceptSucceeded" private const val EVENT_VOIP_ACCEPT_FAILED = "VoipAcceptFailed" private var reactContextRef: WeakReference? = null @@ -40,7 +40,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo if (context.hasActiveReactInstance()) { context .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(EVENT_INITIAL_EVENTS, voipPayload.toWritableMap()) + .emit(EVENT_VOIP_ACCEPT_SUCCEEDED, voipPayload.toWritableMap()) } } } catch (e: Exception) { diff --git a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt index f2fa81ffede..f3f24fc6094 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt @@ -230,17 +230,11 @@ class VoipNotification(private val context: Context) { // Guard so finish() is called at most once, whether by the DDP callback or the timeout. val finished = AtomicBoolean(false) val timeoutHandler = Handler(Looper.getMainLooper()) - val timeoutRunnable = Runnable { - if (finished.compareAndSet(false, true)) { - Log.w(TAG, "Native accept timed out for ${payload.callId}; falling back to JS recovery") - finish(false) - } - } - timeoutHandler.postDelayed(timeoutRunnable, 10_000L) + var timeoutRunnable: Runnable? = null fun finish(ddpSuccess: Boolean) { if (!finished.compareAndSet(false, true)) return - timeoutHandler.removeCallbacks(timeoutRunnable) + timeoutRunnable?.let { timeoutHandler.removeCallbacks(it) } stopDDPClientInternal() if (ddpSuccess) { answerIncomingCall(payload.callId) @@ -261,6 +255,13 @@ class VoipNotification(private val context: Context) { } } + val postedTimeout = Runnable { + Log.w(TAG, "Native accept timed out for ${payload.callId}; falling back to JS recovery") + finish(false) + } + timeoutRunnable = postedTimeout + timeoutHandler.postDelayed(postedTimeout, 10_000L) + val client = ddpClient if (client == null) { Log.d(TAG, "Native DDP client unavailable for accept ${payload.callId}") diff --git a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx b/app/containers/MediaCallHeader/MediaCallHeader.test.tsx index de7659ac1b6..bf229800b18 100644 --- a/app/containers/MediaCallHeader/MediaCallHeader.test.tsx +++ b/app/containers/MediaCallHeader/MediaCallHeader.test.tsx @@ -89,6 +89,18 @@ describe('MediaCallHeader', () => { expect(queryByTestId('media-call-header-end')).toBeNull(); }); + it('should render empty placeholder when native accepted but call not bound yet (before answerCall completes)', () => { + useCallStore.getState().setNativeAcceptedCallId('e3246c4d-d23a-412f-8a8b-37ec9f29ef1a'); + const { getByTestId, queryByTestId } = render( + + + + ); + + expect(getByTestId('media-call-header-empty')).toBeTruthy(); + expect(queryByTestId('media-call-header')).toBeNull(); + }); + it('should render full header when call exists', () => { setStoreState(); const { getByTestId } = render( diff --git a/app/lib/services/voip/MediaCallEvents.ts b/app/lib/services/voip/MediaCallEvents.ts index 127cbef46e6..e36f5daf341 100644 --- a/app/lib/services/voip/MediaCallEvents.ts +++ b/app/lib/services/voip/MediaCallEvents.ts @@ -15,9 +15,12 @@ const platform = isIOS ? 'iOS' : 'Android'; const TAG = `[MediaCallEvents][${platform}]`; const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed'; +const EVENT_VOIP_ACCEPT_SUCCEEDED = 'VoipAcceptSucceeded'; /** Dedupe native emit + stash replay for the same failed accept. */ let lastHandledVoipAcceptFailureCallId: string | null = null; +/** Idempotent warm delivery of native accept success. */ +let lastHandledVoipAcceptSucceededCallId: string | null = null; function dispatchVoipAcceptFailureFromNative(raw: VoipPayload & { voipAcceptFailed?: boolean }) { if (!raw.voipAcceptFailed) { @@ -38,6 +41,29 @@ function dispatchVoipAcceptFailureFromNative(raw: VoipPayload & { voipAcceptFail ); } +function handleVoipAcceptSucceededFromNative(data: VoipPayload) { + 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`); + return; + } + console.log(`${TAG} VoipAcceptSucceeded:`, data); + NativeVoipModule.clearInitialEvents(); + useCallStore.getState().setNativeAcceptedCallId(data.callId); + store.dispatch( + deepLinkingOpen({ + callId: data.callId, + host: data.host + }) + ); +} + /** * Sets up listeners for media call events. * @returns Cleanup function to remove listeners @@ -66,39 +92,19 @@ export const setupMediaCallEvents = (): (() => void) => { // 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 - // signal before JS runs. JS only reads the stored initialEventsData payload after the fact. - } else { - // Android listens for media call events from VoipModule - subscriptions.push( - Emitter.addListener('VoipPushInitialEvents', async (data: VoipPayload & { voipAcceptFailed?: boolean }) => { - try { - if (data.voipAcceptFailed) { - console.log(`${TAG} Accept failed initial event`); - dispatchVoipAcceptFailureFromNative(data); - NativeVoipModule.clearInitialEvents(); - return; - } - if (data.type !== 'incoming_call') { - console.log(`${TAG} Not an incoming call`); - return; - } - console.log(`${TAG} Initial events event:`, data); - NativeVoipModule.clearInitialEvents(); - useCallStore.getState().setCallId(data.callId); - store.dispatch( - deepLinkingOpen({ - callId: data.callId, - host: data.host - }) - ); - await mediaSessionInstance.answerCall(data.callId); - } catch (error) { - console.error(`${TAG} Error handling initial events event:`, error); - } - }) - ); + // signal before JS runs. JS receives VoipAcceptSucceeded after success. } + subscriptions.push( + Emitter.addListener(EVENT_VOIP_ACCEPT_SUCCEEDED, (data: VoipPayload) => { + try { + handleVoipAcceptSucceededFromNative(data); + } catch (error) { + console.error(`${TAG} Error handling VoipAcceptSucceeded:`, error); + } + }) + ); + subscriptions.push( Emitter.addListener(EVENT_VOIP_ACCEPT_FAILED, (data: VoipPayload & { voipAcceptFailed?: boolean }) => { console.log(`${TAG} VoipAcceptFailed event:`, data); @@ -165,7 +171,7 @@ export const getInitialMediaCallEvents = async (): Promise => { } if (wasAnswered) { - useCallStore.getState().setCallId(initialEvents.callId); + useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId); store.dispatch( deepLinkingOpen({ diff --git a/app/lib/services/voip/MediaSessionInstance.test.ts b/app/lib/services/voip/MediaSessionInstance.test.ts index ca475453b75..ccca424a58b 100644 --- a/app/lib/services/voip/MediaSessionInstance.test.ts +++ b/app/lib/services/voip/MediaSessionInstance.test.ts @@ -1,15 +1,20 @@ +import type { IDDPMessage } from '../../../definitions/IDDPMessage'; import { mediaSessionStore } from './MediaSessionStore'; import { mediaSessionInstance } from './MediaSessionInstance'; const mockCallStoreReset = jest.fn(); +const mockUseCallStoreGetState = jest.fn(() => ({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: null as unknown, + callId: null as string | null, + nativeAcceptedCallId: null as string | null +})); jest.mock('./useCallStore', () => ({ useCallStore: { - getState: jest.fn(() => ({ - reset: mockCallStoreReset, - setCall: jest.fn(), - callId: null as string | null - })) + getState: () => mockUseCallStoreGetState() } })); @@ -59,30 +64,64 @@ jest.mock('../../navigation/appNavigation', () => ({ default: { navigate: jest.fn() } })); -type SessionRecord = { userId: string; endSession: jest.Mock }; -const createdSessions: SessionRecord[] = []; +type MockMediaSignalingSession = { + userId: string; + sessionId: string; + endSession: jest.Mock; + on: jest.Mock; + processSignal: jest.Mock; + setIceGatheringTimeout: jest.Mock; + startCall: jest.Mock; + getMainCall: jest.Mock; +}; + +const createdSessions: MockMediaSignalingSession[] = []; jest.mock('@rocket.chat/media-signaling', () => ({ MediaCallWebRTCProcessor: jest.fn().mockImplementation(function MediaCallWebRTCProcessor(this: unknown) { return this; }), - MediaSignalingSession: jest.fn().mockImplementation(function MockMediaSignalingSession(this: any, config: { userId: string }) { - const endSession = jest.fn(); - createdSessions.push({ userId: config.userId, endSession }); - this.userId = config.userId; - this.endSession = endSession; - this.on = jest.fn(); - this.processSignal = jest.fn().mockResolvedValue(undefined); - this.setIceGatheringTimeout = jest.fn(); - this.startCall = jest.fn().mockResolvedValue(undefined); - this.getMainCall = jest.fn(); - }) + MediaSignalingSession: jest + .fn() + .mockImplementation(function MockMediaSignalingSession(this: MockMediaSignalingSession, config: { userId: string }) { + const endSession = jest.fn(); + this.userId = config.userId; + this.endSession = endSession; + this.on = jest.fn(); + this.processSignal = jest.fn().mockResolvedValue(undefined); + this.setIceGatheringTimeout = jest.fn(); + this.startCall = jest.fn().mockResolvedValue(undefined); + this.getMainCall = jest.fn(); + Object.defineProperty(this, 'sessionId', { value: `session-${config.userId}`, writable: false }); + createdSessions.push(this); + }) })); +const STREAM_NOTIFY_USER = 'stream-notify-user'; + +function getStreamNotifyHandler(): (ddpMessage: IDDPMessage) => void { + const calls = mockOnStreamData.mock.calls as unknown as [string, (m: IDDPMessage) => void][]; + for (let i = calls.length - 1; i >= 0; i--) { + const [eventName, handler] = calls[i]; + if (eventName === STREAM_NOTIFY_USER && typeof handler === 'function') { + return handler; + } + } + throw new Error('stream-notify-user handler not registered'); +} + describe('MediaSessionInstance', () => { beforeEach(() => { jest.clearAllMocks(); createdSessions.length = 0; + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: null + }); mediaSessionInstance.reset(); }); @@ -157,4 +196,122 @@ describe('MediaSessionInstance', () => { }).not.toThrow(); }); }); + + describe('stream-notify-user (notification/accepted gated)', () => { + it('does not call answerCall when nativeAcceptedCallId is null', async () => { + const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); + mediaSessionInstance.init('user-1'); + const streamHandler = getStreamNotifyHandler(); + streamHandler({ + msg: 'changed', + fields: { + eventName: 'uid/media-signal', + args: [ + { + type: 'notification', + notification: 'accepted', + signedContractId: 'test-device-id', + callId: 'from-signal' + } + ] + } + }); + await Promise.resolve(); + expect(answerSpy).not.toHaveBeenCalled(); + answerSpy.mockRestore(); + }); + + it('calls answerCall when nativeAcceptedCallId matches signal and contract matches device', async () => { + const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: 'from-signal' + }); + mediaSessionInstance.init('user-1'); + const streamHandler = getStreamNotifyHandler(); + streamHandler({ + msg: 'changed', + fields: { + eventName: 'uid/media-signal', + args: [ + { + type: 'notification', + notification: 'accepted', + signedContractId: 'test-device-id', + callId: 'from-signal' + } + ] + } + }); + await Promise.resolve(); + expect(answerSpy).toHaveBeenCalledWith('from-signal'); + answerSpy.mockRestore(); + }); + + it('calls answerCall when only nativeAcceptedCallId matches (transient callId null)', async () => { + const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: null, + callId: null, + nativeAcceptedCallId: 'sticky-only' + }); + mediaSessionInstance.init('user-1'); + const streamHandler = getStreamNotifyHandler(); + streamHandler({ + msg: 'changed', + fields: { + eventName: 'uid/media-signal', + args: [ + { + type: 'notification', + notification: 'accepted', + signedContractId: 'test-device-id', + callId: 'sticky-only' + } + ] + } + }); + await Promise.resolve(); + expect(answerSpy).toHaveBeenCalledWith('sticky-only'); + answerSpy.mockRestore(); + }); + + it('does not call answerCall when store call object is already set', async () => { + const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined); + mockUseCallStoreGetState.mockReturnValue({ + reset: mockCallStoreReset, + setCall: jest.fn(), + resetNativeCallId: jest.fn(), + call: { callId: 'from-signal' } as any, + callId: 'from-signal', + nativeAcceptedCallId: 'from-signal' + }); + mediaSessionInstance.init('user-1'); + const streamHandler = getStreamNotifyHandler(); + streamHandler({ + msg: 'changed', + fields: { + eventName: 'uid/media-signal', + args: [ + { + type: 'notification', + notification: 'accepted', + signedContractId: 'test-device-id', + callId: 'from-signal' + } + ] + } + }); + await Promise.resolve(); + expect(answerSpy).not.toHaveBeenCalled(); + answerSpy.mockRestore(); + }); + }); }); diff --git a/app/lib/services/voip/MediaSessionInstance.ts b/app/lib/services/voip/MediaSessionInstance.ts index 0c066d037ad..f60af626c28 100644 --- a/app/lib/services/voip/MediaSessionInstance.ts +++ b/app/lib/services/voip/MediaSessionInstance.ts @@ -32,6 +32,7 @@ class MediaSessionInstance { public init(userId: string): void { this.reset(); + registerGlobals(); this.configureIceServers(); @@ -64,18 +65,23 @@ class MediaSessionInstance { console.log('🤙 [VoIP] Processed signal:', signal); - // If the call was accepted from this device, answer it - if (signal.type === 'notification' && signal.notification === 'accepted' && signal.signedContractId === getUniqueIdSync()) { + // Answer when native already accepted and stream matches device contract + callId. + const storeSlice = useCallStore.getState(); + const { call, nativeAcceptedCallId } = storeSlice; + + if ( + signal.type === 'notification' && + signal.notification === 'accepted' && + signal.signedContractId === getUniqueIdSync() && + nativeAcceptedCallId === signal.callId && + call == null + ) { this.answerCall(signal.callId).catch(error => { - console.error('[VoIP] Error answering call :', error); + console.error('[VoIP] Error answering call on notification/accepted:', error); }); } }); - this.instance?.on('registered', ({ activeCalls }) => { - console.log('[VoIP] Media session registered, activeCalls:', activeCalls); - }); - this.instance?.on('newCall', ({ call }: { call: IClientMediaCall }) => { if (call && !call.hidden) { call.emitter.on('stateChange', oldState => { @@ -96,6 +102,12 @@ class MediaSessionInstance { } public answerCall = async (callId: string) => { + const { call: existingCall } = useCallStore.getState(); + if (existingCall != null && existingCall.callId === callId) { + console.log('[VoIP] answerCall skipped — call already bound in store:', callId); + return; + } + console.log('[VoIP] Answering call:', callId); const mainCall = this.instance?.getMainCall(); console.log('[VoIP] Main call:', mainCall); @@ -109,6 +121,10 @@ class MediaSessionInstance { Navigation.navigate('CallView'); } else { RNCallKeep.endCall(callId); + const st = useCallStore.getState(); + if (st.nativeAcceptedCallId === callId) { + st.resetNativeCallId(); + } console.warn('[VoIP] Call not found:', callId); // TODO: Show error message? } }; @@ -138,7 +154,7 @@ class MediaSessionInstance { RNCallKeep.endCall(callId); RNCallKeep.setCurrentCallActive(''); RNCallKeep.setAvailable(true); - // Reset Zustand store + useCallStore.getState().resetNativeCallId(); useCallStore.getState().reset(); }; diff --git a/app/lib/services/voip/useCallStore.test.ts b/app/lib/services/voip/useCallStore.test.ts new file mode 100644 index 00000000000..c112f99cab3 --- /dev/null +++ b/app/lib/services/voip/useCallStore.test.ts @@ -0,0 +1,132 @@ +import type { IClientMediaCall } from '@rocket.chat/media-signaling'; + +import { useCallStore } from './useCallStore'; + +jest.mock('../../navigation/appNavigation', () => ({ + __esModule: true, + default: { navigate: jest.fn(), back: jest.fn() } +})); + +jest.mock('../../../containers/ActionSheet', () => ({ + hideActionSheetRef: jest.fn() +})); + +jest.mock('react-native-callkeep', () => ({})); + +jest.mock('react-native-incall-manager', () => ({ + start: jest.fn(), + stop: jest.fn(), + setForceSpeakerphoneOn: jest.fn() +})); + +function createMockCall(callId: string): IClientMediaCall { + const listeners: Record void>> = {}; + const emitter = { + on: (ev: string, fn: (...args: unknown[]) => void) => { + if (!listeners[ev]) listeners[ev] = new Set(); + listeners[ev].add(fn); + }, + off: (ev: string, fn: (...args: unknown[]) => void) => { + listeners[ev]?.delete(fn); + } + }; + return { + callId, + state: 'active', + muted: false, + held: false, + remoteMute: false, + remoteHeld: false, + hidden: false, + role: 'callee', + contact: { id: 'u', displayName: 'U', username: 'u', sipExtension: '' }, + emitter, + setMuted: jest.fn(), + setHeld: jest.fn(), + sendDTMF: jest.fn(), + hangup: jest.fn(), + accept: jest.fn(), + reject: jest.fn() + } as unknown as IClientMediaCall; +} + +describe('useCallStore native accepted + stale timer', () => { + beforeEach(() => { + jest.useFakeTimers(); + useCallStore.getState().resetNativeCallId(); + useCallStore.getState().reset(); + }); + + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('reset preserves nativeAcceptedCallId', () => { + useCallStore.getState().setNativeAcceptedCallId('cid'); + useCallStore.getState().reset(); + const s = useCallStore.getState(); + expect(s.nativeAcceptedCallId).toBe('cid'); + expect(s.callId).toBeNull(); + expect(s.call).toBeNull(); + }); + + it('resetNativeCallId clears sticky id and callId when unbound', () => { + useCallStore.getState().setNativeAcceptedCallId('cid'); + useCallStore.getState().resetNativeCallId(); + const s = useCallStore.getState(); + expect(s.nativeAcceptedCallId).toBeNull(); + expect(s.callId).toBeNull(); + }); + + it('setNativeAcceptedCallId sets only nativeAcceptedCallId (not transient callId)', () => { + useCallStore.getState().setNativeAcceptedCallId('x'); + const s = useCallStore.getState(); + expect(s.nativeAcceptedCallId).toBe('x'); + expect(s.callId).toBeNull(); + }); + + it('setNativeAcceptedCallId overwrites previous sticky id', () => { + useCallStore.getState().setNativeAcceptedCallId('a'); + useCallStore.getState().setNativeAcceptedCallId('b'); + const s = useCallStore.getState(); + expect(s.nativeAcceptedCallId).toBe('b'); + expect(s.callId).toBeNull(); + }); + + it('after 15s unbound, clears nativeAcceptedCallId when id still matches scheduled token', () => { + useCallStore.getState().setNativeAcceptedCallId('stale'); + jest.advanceTimersByTime(15_000); + const s = useCallStore.getState(); + expect(s.nativeAcceptedCallId).toBeNull(); + expect(s.callId).toBeNull(); + }); + + it('setCall clears native id and cancels stale timer so advance does not clear bound call context', () => { + useCallStore.getState().setNativeAcceptedCallId('x'); + useCallStore.getState().setCall(createMockCall('x')); + jest.advanceTimersByTime(15_000); + expect(useCallStore.getState().call).not.toBeNull(); + expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); + }); + + it('reset() preserves id and restarts 15s window from last reset', () => { + useCallStore.getState().setNativeAcceptedCallId('keep'); + jest.advanceTimersByTime(14_000); + useCallStore.getState().reset(); + jest.advanceTimersByTime(14_000); + expect(useCallStore.getState().nativeAcceptedCallId).toBe('keep'); + jest.advanceTimersByTime(1_000); + expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); + }); + + it('replacing native id restarts timer so old deadline does not clear new id', () => { + useCallStore.getState().setNativeAcceptedCallId('a'); + jest.advanceTimersByTime(14_000); + useCallStore.getState().setNativeAcceptedCallId('b'); + jest.advanceTimersByTime(14_000); + expect(useCallStore.getState().nativeAcceptedCallId).toBe('b'); + jest.advanceTimersByTime(1_000); + expect(useCallStore.getState().nativeAcceptedCallId).toBeNull(); + }); +}); diff --git a/app/lib/services/voip/useCallStore.ts b/app/lib/services/voip/useCallStore.ts index 028cfb1ce49..c97ffd59db1 100644 --- a/app/lib/services/voip/useCallStore.ts +++ b/app/lib/services/voip/useCallStore.ts @@ -6,11 +6,59 @@ import InCallManager from 'react-native-incall-manager'; import Navigation from '../../navigation/appNavigation'; import { hideActionSheetRef } from '../../../containers/ActionSheet'; +const STALE_NATIVE_MS = 15_000; + +let callListenersCleanup: (() => void) | null = null; +let staleNativeTimer: ReturnType | null = null; +/** Call id this timer is for; only `nativeAcceptedCallId` is cleared when it fires, not `callId`. */ +let staleNativeScheduledId: string | null = null; + +export function cleanupCallListeners(): void { + callListenersCleanup?.(); + callListenersCleanup = null; +} + +function cancelStaleNativeTimer(): void { + if (staleNativeTimer != null) { + clearTimeout(staleNativeTimer); + staleNativeTimer = null; + } + staleNativeScheduledId = null; +} + +function clearStaleNativeIfStillUnbound(get: () => CallStore, scheduled: string): void { + const st = get(); + if (st.call != null || st.nativeAcceptedCallId !== scheduled) { + return; + } + useCallStore.setState({ nativeAcceptedCallId: null }); +} + +function createStaleNativeTimer(get: () => CallStore): void { + cancelStaleNativeTimer(); + const scheduledId = get().nativeAcceptedCallId; + if (scheduledId == null || scheduledId === '') { + return; + } + staleNativeScheduledId = scheduledId; + staleNativeTimer = setTimeout(() => { + staleNativeTimer = null; + // Timer uses the id from when it was created, not the current module variable. + if (staleNativeScheduledId === scheduledId) { + staleNativeScheduledId = null; + } + clearStaleNativeIfStillUnbound(get, scheduledId); + }, STALE_NATIVE_MS); +} + interface CallStoreState { // Call reference call: IClientMediaCall | null; callId: string | null; + /** Survives `reset()` until explicit clear — native-accepted incoming call id. */ + nativeAcceptedCallId: string | null; + // Call state callState: CallState; isMuted: boolean; @@ -27,25 +75,27 @@ interface CallStoreState { } interface CallStoreActions { - setCallId: (callId: string | null) => void; + /** Sets native-accepted call id and (re)starts the 15s timer. */ + setNativeAcceptedCallId: (callId: string) => void; + /** Clears native-accepted id and related state; cancels the timer. */ + resetNativeCallId: () => void; setCall: (call: IClientMediaCall) => void; - _cleanupCallListeners: () => void; toggleMute: () => void; toggleHold: () => void; toggleSpeaker: () => void; toggleFocus: () => void; endCall: () => void; + /** Clears UI/call fields but keeps nativeAcceptedCallId. Restarts the 15s timer (media init calls reset and clears the old timer first). */ reset: () => void; setDialpadValue: (value: string) => void; } export type CallStore = CallStoreState & CallStoreActions; -let callListenersCleanup: (() => void) | null = null; - const initialState: CallStoreState = { call: null, callId: null, + nativeAcceptedCallId: null, callState: 'none', isMuted: false, isOnHold: false, @@ -61,17 +111,24 @@ const initialState: CallStoreState = { export const useCallStore = create((set, get) => ({ ...initialState, - setCallId: (callId: string | null) => { - set({ callId }); + setNativeAcceptedCallId: (callId: string) => { + cancelStaleNativeTimer(); + set({ nativeAcceptedCallId: callId }); + createStaleNativeTimer(get); }, - _cleanupCallListeners: () => { - callListenersCleanup?.(); - callListenersCleanup = null; + resetNativeCallId: () => { + cancelStaleNativeTimer(); + const { call, callId } = get(); + set({ + nativeAcceptedCallId: null, + callId: call != null ? callId : null + }); }, setCall: (call: IClientMediaCall) => { - get()._cleanupCallListeners(); + cleanupCallListeners(); + get().resetNativeCallId(); // Update state with call info set({ call, @@ -123,6 +180,7 @@ export const useCallStore = create((set, get) => ({ }; const handleEnded = () => { + get().resetNativeCallId(); get().reset(); Navigation.back(); }; @@ -155,8 +213,8 @@ export const useCallStore = create((set, get) => ({ }, toggleSpeaker: async () => { - const { callId, isSpeakerOn } = get(); - if (!callId) return; + const { call, isSpeakerOn } = get(); + if (!call) return; const newSpeakerOn = !isSpeakerOn; @@ -187,31 +245,36 @@ export const useCallStore = create((set, get) => ({ set({ dialpadValue: newValue }); }, - // TODO: do it here or in MediaSessionInstance? endCall: () => { - const { call, callId } = get(); + const { call, callId, nativeAcceptedCallId } = get(); + // UUID for the native call UI layer (react-native-callkeep on iOS and Android). + const callUuid = callId ?? nativeAcceptedCallId; if (call) { call.hangup(); } - if (callId) { - RNCallKeep.endCall(callId); + if (callUuid) { + RNCallKeep.endCall(callUuid); } - // Navigation.back(); // TODO: It could be collapsed, so going back woudln't make sense + get().resetNativeCallId(); get().reset(); }, reset: () => { - get()._cleanupCallListeners(); + const { nativeAcceptedCallId } = get(); + cleanupCallListeners(); + cancelStaleNativeTimer(); try { InCallManager.stop(); } catch (error) { console.error('[VoIP] InCallManager.stop failed:', error); } - set(initialState); + set({ ...initialState, nativeAcceptedCallId }); hideActionSheetRef(); + // Old timer was cleared above; start a new one if nativeAcceptedCallId is still set. + createStaleNativeTimer(get); } })); diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 54452e83711..9f746ab44db 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -99,6 +99,7 @@ const navigate = function* navigate({ params }) { const handleVoipAcceptFailed = function* handleVoipAcceptFailed(params) { try { const { callId, username } = params; + useCallStore.getState().resetNativeCallId(); useCallStore.getState().reset(); if (callId) { RNCallKeep.endCall(callId); diff --git a/app/sagas/selectServer.ts b/app/sagas/selectServer.ts index ca9e76e0b33..5373f6b0fcf 100644 --- a/app/sagas/selectServer.ts +++ b/app/sagas/selectServer.ts @@ -39,7 +39,6 @@ import { setPermissions } from '../lib/methods/getPermissions'; import { setRoles } from '../lib/methods/getRoles'; import { connect, disconnect, getWebsocketInfo, getLoginServices } from '../lib/services/connect'; import sdk from '../lib/services/sdk'; -import { mediaSessionInstance } from '../lib/services/voip/MediaSessionInstance'; import { appSelector } from '../lib/hooks/useAppSelector'; import { getServerById } from '../lib/database/services/Server'; import { getLoggedUserById } from '../lib/database/services/LoggedUser'; @@ -151,7 +150,6 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch yield put(inquiryReset()); yield put(encryptionStop()); yield put(clearActiveUsers()); - mediaSessionInstance.reset(); const userId = UserPreferences.getString(`${TOKEN_KEY}-${server}`); let user = null; if (userId) { diff --git a/ios/Libraries/VoipModule.mm b/ios/Libraries/VoipModule.mm index 4fb3a329e19..99278fe5c1c 100644 --- a/ios/Libraries/VoipModule.mm +++ b/ios/Libraries/VoipModule.mm @@ -35,7 +35,7 @@ - (instancetype)init { } - (NSArray *)supportedEvents { - return @[@"VoipPushTokenRegistered", @"VoipAcceptFailed"]; + return @[@"VoipPushTokenRegistered", @"VoipAcceptFailed", @"VoipAcceptSucceeded"]; } - (void)startObserving { @@ -51,6 +51,11 @@ - (void)startObserving { name:@"VoipAcceptFailed" object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleVoipAcceptSucceeded:) + name:@"VoipAcceptSucceeded" + object:nil]; + // Send any delayed events for (NSDictionary *event in _delayedEvents) { NSString *name = event[@"name"]; @@ -76,6 +81,10 @@ - (void)handleVoipAcceptFailed:(NSNotification *)notification { [self sendEventWrapper:@"VoipAcceptFailed" body:notification.userInfo]; } +- (void)handleVoipAcceptSucceeded:(NSNotification *)notification { + [self sendEventWrapper:@"VoipAcceptSucceeded" body:notification.userInfo]; +} + - (void)sendEventWrapper:(NSString *)name body:(id)body { if (_hasListeners) { [self sendEventWithName:name body:body]; diff --git a/ios/Libraries/VoipService.swift b/ios/Libraries/VoipService.swift index e0903696c36..ae8ce33af2a 100644 --- a/ios/Libraries/VoipService.swift +++ b/ios/Libraries/VoipService.swift @@ -440,6 +440,11 @@ public final class VoipService: NSObject { if success { storeInitialEvents(payload) clearNativeAcceptDedupe(for: payload.callId) + NotificationCenter.default.post( + name: NSNotification.Name("VoipAcceptSucceeded"), + object: nil, + userInfo: payload.toDictionary() + ) } else { clearNativeAcceptDedupe(for: payload.callId) RNCallKeep.endCall(withUUID: payload.callId, reason: 6) diff --git a/yarn.lock b/yarn.lock index 975d3c6f904..f0c23b54099 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5059,11 +5059,6 @@ dependencies: typia "~9.7.2" -"@rtsao/scc@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" - integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== - "@samchon/openapi@^4.7.1": version "4.7.2" resolved "https://registry.yarnpkg.com/@samchon/openapi/-/openapi-4.7.2.tgz#b00c54b587b22e03e454ff58989c4a63144850b3" @@ -5110,6 +5105,13 @@ resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== +"@storybook/csf@^0.1.13": + version "0.1.13" + resolved "https://registry.yarnpkg.com/@storybook/csf/-/csf-0.1.13.tgz#c8a9bea2ae518a3d9700546748fa30a8b07f7f80" + integrity sha512-7xOOwCLGB3ebM87eemep89MYRFTko+D8qE7EdAAq74lgdqRR5cOUtYWJLjO2dLtP94nqoOdHJo6MdLLKzg412Q== + dependencies: + type-fest "^2.19.0" + "@storybook/global@^5.0.0": version "5.0.0" resolved "https://registry.yarnpkg.com/@storybook/global/-/global-5.0.0.tgz#b793d34b94f572c1d7d9e0f44fac4e0dbc9572ed" @@ -6171,20 +6173,6 @@ array-includes@^3.1.4, array-includes@^3.1.6, array-includes@^3.1.7: get-intrinsic "^1.2.4" is-string "^1.0.7" -array-includes@^3.1.8, array-includes@^3.1.9: - version "3.1.9" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" - integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.4" - define-properties "^1.2.1" - es-abstract "^1.24.0" - es-object-atoms "^1.1.1" - get-intrinsic "^1.3.0" - is-string "^1.1.1" - math-intrinsics "^1.1.0" - array-timsort@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/array-timsort/-/array-timsort-1.0.3.tgz#3c9e4199e54fb2b9c3fe5976396a21614ef0d926" @@ -6356,10 +6344,10 @@ axe-core@=4.7.0: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.0.tgz#34ba5a48a8b564f67e103f0aa5768d76e15bbbbf" integrity sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ== -axios@0.28.0: - version "0.28.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.28.0.tgz#801a4d991d0404961bccef46800e1170f8278c89" - integrity sha512-Tu7NYoGY4Yoc7I+Npf9HhUMtEEpV7ZiLH9yndTCoNhcpBH0kwcvFbzYN9/u5QKI5A6uefjsNNWaz5olJVYS62Q== +axios@~0.28.1: + version "0.28.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.28.1.tgz#2a7bcd34a3837b71ee1a5ca3762214b86b703e70" + integrity sha512-iUcGA5a7p0mVb4Gm/sy+FSECNkPFT4y7wt6OM/CDpO/OnNCvSs3PoMG8ibrC9jRoGYU0gUK5pXVC4NPXq6lHRQ== dependencies: follow-redirects "^1.15.0" form-data "^4.0.0" @@ -6740,7 +6728,7 @@ buffer@^5.4.3, buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" -bytebuffer@5.0.1: +bytebuffer@~5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/bytebuffer/-/bytebuffer-5.0.1.tgz#582eea4b1a873b6d020a48d58df85f0bba6cfddd" integrity sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ== @@ -6977,6 +6965,14 @@ cli-spinners@^2.0.0, cli-spinners@^2.5.0: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== +cli-truncate@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" + integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== + dependencies: + slice-ansi "^3.0.0" + string-width "^4.2.0" + cli-width@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" @@ -7730,7 +7726,7 @@ drange@^1.0.2: resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8" integrity sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA== -dunder-proto@^1.0.0, dunder-proto@^1.0.1: +dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== @@ -13580,7 +13576,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -rxjs@^7.5.5, rxjs@^7.8.1: +rxjs@^7.5.1, rxjs@^7.5.5, rxjs@^7.8.1: version "7.8.2" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== @@ -14601,7 +14597,15 @@ throat@^5.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== -through@^2.3.6: +through2@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +through@^2.3.6, through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==