Skip to content

Commit 4d65864

Browse files
committed
feat: Update VoIP call handling to support accept success events and refactor state management
1 parent 1d3be8a commit 4d65864

File tree

13 files changed

+261
-208
lines changed

13 files changed

+261
-208
lines changed

android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
1616

1717
companion object {
1818
private const val TAG = "RocketChat.VoipModule"
19-
private const val EVENT_INITIAL_EVENTS = "VoipPushInitialEvents"
19+
private const val EVENT_VOIP_ACCEPT_SUCCEEDED = "VoipAcceptSucceeded"
2020
private const val EVENT_VOIP_ACCEPT_FAILED = "VoipAcceptFailed"
2121

2222
private var reactContextRef: WeakReference<ReactApplicationContext>? = null
@@ -40,7 +40,7 @@ class VoipModule(reactContext: ReactApplicationContext) : NativeVoipSpec(reactCo
4040
if (context.hasActiveReactInstance()) {
4141
context
4242
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
43-
.emit(EVENT_INITIAL_EVENTS, voipPayload.toWritableMap())
43+
.emit(EVENT_VOIP_ACCEPT_SUCCEEDED, voipPayload.toWritableMap())
4444
}
4545
}
4646
} catch (e: Exception) {

app/lib/methods/logout.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import database, { getDatabase } from '../database';
88
import log from './helpers/log';
99
import { disconnect } from '../services/connect';
1010
import sdk from '../services/sdk';
11-
import { useCallStore } from '../services/voip/useCallStore';
1211
import { CURRENT_SERVER, E2E_PRIVATE_KEY, E2E_PUBLIC_KEY, E2E_RANDOM_PASSWORD_KEY, TOKEN_KEY } from '../constants/keys';
1312
import UserPreferences from './userPreferences';
1413
import { removePushToken } from '../services/restApi';
@@ -90,8 +89,6 @@ export async function removeServer({ server }: { server: string }): Promise<void
9089
}
9190

9291
export async function logout({ server }: { server: string }): Promise<void> {
93-
useCallStore.getState().clearNativePendingAccept();
94-
9592
if (roomsSubscription?.stop) {
9693
roomsSubscription.stop();
9794
}

app/lib/services/voip/MediaCallEvents.ts

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ const platform = isIOS ? 'iOS' : 'Android';
1515
const TAG = `[MediaCallEvents][${platform}]`;
1616

1717
const EVENT_VOIP_ACCEPT_FAILED = 'VoipAcceptFailed';
18+
const EVENT_VOIP_ACCEPT_SUCCEEDED = 'VoipAcceptSucceeded';
1819

1920
/** Dedupe native emit + stash replay for the same failed accept. */
2021
let lastHandledVoipAcceptFailureCallId: string | null = null;
22+
/** Idempotent warm delivery of native accept success. */
23+
let lastHandledVoipAcceptSucceededCallId: string | null = null;
2124

2225
function dispatchVoipAcceptFailureFromNative(raw: VoipPayload & { voipAcceptFailed?: boolean }) {
2326
if (!raw.voipAcceptFailed) {
@@ -38,6 +41,29 @@ function dispatchVoipAcceptFailureFromNative(raw: VoipPayload & { voipAcceptFail
3841
);
3942
}
4043

44+
function handleVoipAcceptSucceededFromNative(data: VoipPayload) {
45+
const { callId } = data;
46+
if (callId && lastHandledVoipAcceptSucceededCallId === callId) {
47+
return;
48+
}
49+
if (callId) {
50+
lastHandledVoipAcceptSucceededCallId = callId;
51+
}
52+
if (data.type !== 'incoming_call') {
53+
console.log(`${TAG} VoipAcceptSucceeded: not an incoming call`);
54+
return;
55+
}
56+
console.log(`${TAG} VoipAcceptSucceeded:`, data);
57+
NativeVoipModule.clearInitialEvents();
58+
useCallStore.getState().setNativeAcceptedCallId(data.callId);
59+
store.dispatch(
60+
deepLinkingOpen({
61+
callId: data.callId,
62+
host: data.host
63+
})
64+
);
65+
}
66+
4167
/**
4268
* Sets up listeners for media call events.
4369
* @returns Cleanup function to remove listeners
@@ -66,39 +92,19 @@ export const setupMediaCallEvents = (): (() => void) => {
6692
// Note: there is intentionally no 'answerCall' listener here.
6793
// VoipService.swift handles accept natively: handleObservedCallChanged detects
6894
// hasConnected = true and calls handleNativeAccept(), which sends the DDP accept
69-
// signal before JS runs. JS only reads the stored initialEventsData payload after the fact.
70-
} else {
71-
// Android listens for media call events from VoipModule
72-
subscriptions.push(
73-
Emitter.addListener('VoipPushInitialEvents', async (data: VoipPayload & { voipAcceptFailed?: boolean }) => {
74-
try {
75-
if (data.voipAcceptFailed) {
76-
console.log(`${TAG} Accept failed initial event`);
77-
dispatchVoipAcceptFailureFromNative(data);
78-
NativeVoipModule.clearInitialEvents();
79-
return;
80-
}
81-
if (data.type !== 'incoming_call') {
82-
console.log(`${TAG} Not an incoming call`);
83-
return;
84-
}
85-
console.log(`${TAG} Initial events event:`, data);
86-
NativeVoipModule.clearInitialEvents();
87-
useCallStore.getState().setNativePendingAccept(data.callId);
88-
store.dispatch(
89-
deepLinkingOpen({
90-
callId: data.callId,
91-
host: data.host
92-
})
93-
);
94-
// await mediaSessionInstance.answerCall(data.callId);
95-
} catch (error) {
96-
console.error(`${TAG} Error handling initial events event:`, error);
97-
}
98-
})
99-
);
95+
// signal before JS runs. JS receives VoipAcceptSucceeded after success.
10096
}
10197

98+
subscriptions.push(
99+
Emitter.addListener(EVENT_VOIP_ACCEPT_SUCCEEDED, (data: VoipPayload) => {
100+
try {
101+
handleVoipAcceptSucceededFromNative(data);
102+
} catch (error) {
103+
console.error(`${TAG} Error handling VoipAcceptSucceeded:`, error);
104+
}
105+
})
106+
);
107+
102108
subscriptions.push(
103109
Emitter.addListener(EVENT_VOIP_ACCEPT_FAILED, (data: VoipPayload & { voipAcceptFailed?: boolean }) => {
104110
console.log(`${TAG} VoipAcceptFailed event:`, data);
@@ -165,7 +171,7 @@ export const getInitialMediaCallEvents = async (): Promise<boolean> => {
165171
}
166172

167173
if (wasAnswered) {
168-
useCallStore.getState().setNativePendingAccept(initialEvents.callId);
174+
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId);
169175

170176
store.dispatch(
171177
deepLinkingOpen({

app/lib/services/voip/MediaSessionInstance.test.ts

Lines changed: 39 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,11 @@ import { mediaSessionStore } from './MediaSessionStore';
33
import { mediaSessionInstance } from './MediaSessionInstance';
44

55
const mockCallStoreReset = jest.fn();
6-
const mockSyncTransientCallIdFromNativePending = jest.fn();
76
const mockUseCallStoreGetState = jest.fn(() => ({
87
reset: mockCallStoreReset,
98
setCall: jest.fn(),
109
setCallId: jest.fn(),
11-
clearNativePendingAccept: jest.fn(),
12-
syncTransientCallIdFromNativePending: mockSyncTransientCallIdFromNativePending,
10+
resetNativeCallId: jest.fn(),
1311
call: null as unknown,
1412
callId: null as string | null,
1513
nativeAcceptedCallId: null as string | null
@@ -116,8 +114,7 @@ describe('MediaSessionInstance', () => {
116114
reset: mockCallStoreReset,
117115
setCall: jest.fn(),
118116
setCallId: jest.fn(),
119-
clearNativePendingAccept: jest.fn(),
120-
syncTransientCallIdFromNativePending: mockSyncTransientCallIdFromNativePending,
117+
resetNativeCallId: jest.fn(),
121118
call: null,
122119
callId: null,
123120
nativeAcceptedCallId: null
@@ -155,35 +152,6 @@ describe('MediaSessionInstance', () => {
155152
spy.mockRestore();
156153
});
157154

158-
it('should sync transient callId from sticky native pending after reset inside init', () => {
159-
mockUseCallStoreGetState.mockReturnValue({
160-
reset: mockCallStoreReset,
161-
setCall: jest.fn(),
162-
setCallId: jest.fn(),
163-
clearNativePendingAccept: jest.fn(),
164-
syncTransientCallIdFromNativePending: mockSyncTransientCallIdFromNativePending,
165-
call: null,
166-
callId: null,
167-
nativeAcceptedCallId: 'native-accepted-call-id'
168-
});
169-
mediaSessionInstance.init('user-1');
170-
expect(mockSyncTransientCallIdFromNativePending).toHaveBeenCalledWith();
171-
});
172-
173-
it('should still invoke sync when store already has call object', () => {
174-
mockUseCallStoreGetState.mockReturnValue({
175-
reset: mockCallStoreReset,
176-
setCall: jest.fn(),
177-
setCallId: jest.fn(),
178-
clearNativePendingAccept: jest.fn(),
179-
syncTransientCallIdFromNativePending: mockSyncTransientCallIdFromNativePending,
180-
call: { callId: 'x' } as any,
181-
callId: 'x',
182-
nativeAcceptedCallId: 'native-accepted-call-id'
183-
});
184-
mediaSessionInstance.init('user-1');
185-
expect(mockSyncTransientCallIdFromNativePending).toHaveBeenCalledWith();
186-
});
187155
});
188156

189157
describe('teardown and user switch', () => {
@@ -251,16 +219,47 @@ describe('MediaSessionInstance', () => {
251219
answerSpy.mockRestore();
252220
});
253221

254-
it('calls answerCall when native-accepted store callId matches signal and contract matches device', async () => {
222+
it('does not call answerCall when transient callId matches signal but nativeAcceptedCallId does not', async () => {
255223
const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined);
256224
mockUseCallStoreGetState.mockReturnValue({
257225
reset: mockCallStoreReset,
258226
setCall: jest.fn(),
259227
setCallId: jest.fn(),
260-
clearNativePendingAccept: jest.fn(),
261-
syncTransientCallIdFromNativePending: mockSyncTransientCallIdFromNativePending,
228+
resetNativeCallId: jest.fn(),
262229
call: null,
263230
callId: 'from-signal',
231+
nativeAcceptedCallId: null
232+
});
233+
mediaSessionInstance.init('user-1');
234+
const streamHandler = getStreamNotifyHandler();
235+
streamHandler({
236+
msg: 'changed',
237+
fields: {
238+
eventName: 'uid/media-signal',
239+
args: [
240+
{
241+
type: 'notification',
242+
notification: 'accepted',
243+
signedContractId: 'test-device-id',
244+
callId: 'from-signal'
245+
}
246+
]
247+
}
248+
});
249+
await Promise.resolve();
250+
expect(answerSpy).not.toHaveBeenCalled();
251+
answerSpy.mockRestore();
252+
});
253+
254+
it('calls answerCall when nativeAcceptedCallId matches signal and contract matches device', async () => {
255+
const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined);
256+
mockUseCallStoreGetState.mockReturnValue({
257+
reset: mockCallStoreReset,
258+
setCall: jest.fn(),
259+
setCallId: jest.fn(),
260+
resetNativeCallId: jest.fn(),
261+
call: null,
262+
callId: null,
264263
nativeAcceptedCallId: 'from-signal'
265264
});
266265
mediaSessionInstance.init('user-1');
@@ -284,14 +283,13 @@ describe('MediaSessionInstance', () => {
284283
answerSpy.mockRestore();
285284
});
286285

287-
it('calls answerCall when only sticky native id matches (transient callId null)', async () => {
286+
it('calls answerCall when only nativeAcceptedCallId matches (transient callId null)', async () => {
288287
const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined);
289288
mockUseCallStoreGetState.mockReturnValue({
290289
reset: mockCallStoreReset,
291290
setCall: jest.fn(),
292291
setCallId: jest.fn(),
293-
clearNativePendingAccept: jest.fn(),
294-
syncTransientCallIdFromNativePending: mockSyncTransientCallIdFromNativePending,
292+
resetNativeCallId: jest.fn(),
295293
call: null,
296294
callId: null,
297295
nativeAcceptedCallId: 'sticky-only'
@@ -323,8 +321,7 @@ describe('MediaSessionInstance', () => {
323321
reset: mockCallStoreReset,
324322
setCall: jest.fn(),
325323
setCallId: jest.fn(),
326-
clearNativePendingAccept: jest.fn(),
327-
syncTransientCallIdFromNativePending: mockSyncTransientCallIdFromNativePending,
324+
resetNativeCallId: jest.fn(),
328325
call: { callId: 'from-signal' } as any,
329326
callId: 'from-signal',
330327
nativeAcceptedCallId: 'from-signal'

app/lib/services/voip/MediaSessionInstance.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { getUniqueIdSync } from 'react-native-device-info';
1212

1313
import { mediaSessionStore } from './MediaSessionStore';
1414
import { useCallStore } from './useCallStore';
15-
import { getEffectiveNativeAcceptedCallId } from './nativeAcceptHelpers';
1615
import { store } from '../../store/auxStore';
1716
import sdk from '../sdk';
1817
import Navigation from '../../navigation/appNavigation';
@@ -34,8 +33,6 @@ class MediaSessionInstance {
3433
public init(userId: string): void {
3534
this.reset();
3635

37-
useCallStore.getState().syncTransientCallIdFromNativePending();
38-
3936
registerGlobals();
4037
this.configureIceServers();
4138

@@ -68,16 +65,15 @@ class MediaSessionInstance {
6865

6966
console.log('🤙 [VoIP] Processed signal:', signal);
7067

71-
// Answer when native already accepted (sticky/transient id) and stream matches device contract + callId.
68+
// Answer when native already accepted and stream matches device contract + callId.
7269
const storeSlice = useCallStore.getState();
73-
const { call } = storeSlice;
74-
const effectiveNativeCallId = getEffectiveNativeAcceptedCallId(storeSlice);
70+
const { call, nativeAcceptedCallId } = storeSlice;
7571

7672
if (
7773
signal.type === 'notification' &&
7874
signal.notification === 'accepted' &&
7975
signal.signedContractId === getUniqueIdSync() &&
80-
effectiveNativeCallId === signal.callId &&
76+
nativeAcceptedCallId === signal.callId &&
8177
call == null
8278
) {
8379
this.answerCall(signal.callId).catch(error => {
@@ -127,7 +123,7 @@ class MediaSessionInstance {
127123
RNCallKeep.endCall(callId);
128124
const st = useCallStore.getState();
129125
if (st.nativeAcceptedCallId === callId) {
130-
st.clearNativePendingAccept();
126+
st.resetNativeCallId();
131127
}
132128
console.warn('[VoIP] Call not found:', callId); // TODO: Show error message?
133129
}
@@ -158,7 +154,7 @@ class MediaSessionInstance {
158154
RNCallKeep.endCall(callId);
159155
RNCallKeep.setCurrentCallActive('');
160156
RNCallKeep.setAvailable(true);
161-
useCallStore.getState().clearNativePendingAccept();
157+
useCallStore.getState().resetNativeCallId();
162158
useCallStore.getState().reset();
163159
};
164160

app/lib/services/voip/nativeAcceptHelpers.test.ts

Lines changed: 0 additions & 47 deletions
This file was deleted.

app/lib/services/voip/nativeAcceptHelpers.ts

Lines changed: 0 additions & 16 deletions
This file was deleted.

0 commit comments

Comments
 (0)