Skip to content

Commit 81dba28

Browse files
authored
feat: native accept success event (#7068)
1 parent e3a7a78 commit 81dba28

File tree

13 files changed

+521
-117
lines changed

13 files changed

+521
-117
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) {

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

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,17 +230,11 @@ class VoipNotification(private val context: Context) {
230230
// Guard so finish() is called at most once, whether by the DDP callback or the timeout.
231231
val finished = AtomicBoolean(false)
232232
val timeoutHandler = Handler(Looper.getMainLooper())
233-
val timeoutRunnable = Runnable {
234-
if (finished.compareAndSet(false, true)) {
235-
Log.w(TAG, "Native accept timed out for ${payload.callId}; falling back to JS recovery")
236-
finish(false)
237-
}
238-
}
239-
timeoutHandler.postDelayed(timeoutRunnable, 10_000L)
233+
var timeoutRunnable: Runnable? = null
240234

241235
fun finish(ddpSuccess: Boolean) {
242236
if (!finished.compareAndSet(false, true)) return
243-
timeoutHandler.removeCallbacks(timeoutRunnable)
237+
timeoutRunnable?.let { timeoutHandler.removeCallbacks(it) }
244238
stopDDPClientInternal()
245239
if (ddpSuccess) {
246240
answerIncomingCall(payload.callId)
@@ -261,6 +255,13 @@ class VoipNotification(private val context: Context) {
261255
}
262256
}
263257

258+
val postedTimeout = Runnable {
259+
Log.w(TAG, "Native accept timed out for ${payload.callId}; falling back to JS recovery")
260+
finish(false)
261+
}
262+
timeoutRunnable = postedTimeout
263+
timeoutHandler.postDelayed(postedTimeout, 10_000L)
264+
264265
val client = ddpClient
265266
if (client == null) {
266267
Log.d(TAG, "Native DDP client unavailable for accept ${payload.callId}")

app/containers/MediaCallHeader/MediaCallHeader.test.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,18 @@ describe('MediaCallHeader', () => {
8989
expect(queryByTestId('media-call-header-end')).toBeNull();
9090
});
9191

92+
it('should render empty placeholder when native accepted but call not bound yet (before answerCall completes)', () => {
93+
useCallStore.getState().setNativeAcceptedCallId('e3246c4d-d23a-412f-8a8b-37ec9f29ef1a');
94+
const { getByTestId, queryByTestId } = render(
95+
<Wrapper>
96+
<MediaCallHeader />
97+
</Wrapper>
98+
);
99+
100+
expect(getByTestId('media-call-header-empty')).toBeTruthy();
101+
expect(queryByTestId('media-call-header')).toBeNull();
102+
});
103+
92104
it('should render full header when call exists', () => {
93105
setStoreState();
94106
const { getByTestId } = render(

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().setCallId(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().setCallId(initialEvents.callId);
174+
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId);
169175

170176
store.dispatch(
171177
deepLinkingOpen({

0 commit comments

Comments
 (0)