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
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactApplicationContext>? = null
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}")
Expand Down
12 changes: 12 additions & 0 deletions app/containers/MediaCallHeader/MediaCallHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Wrapper>
<MediaCallHeader />
</Wrapper>
);

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(
Expand Down
70 changes: 38 additions & 32 deletions app/lib/services/voip/MediaCallEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -165,7 +171,7 @@ export const getInitialMediaCallEvents = async (): Promise<boolean> => {
}

if (wasAnswered) {
useCallStore.getState().setCallId(initialEvents.callId);
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId);

store.dispatch(
deepLinkingOpen({
Expand Down
Loading
Loading