Skip to content

fix(voip): pending-accept test mocks#7239

Closed
diegolmello wants to merge 4 commits into
feat.voip-lib-newfrom
fix/voip-locked-fast-accept
Closed

fix(voip): pending-accept test mocks#7239
diegolmello wants to merge 4 commits into
feat.voip-lib-newfrom
fix/voip-locked-fast-accept

Conversation

@diegolmello

@diegolmello diegolmello commented Apr 24, 2026

Copy link
Copy Markdown
Member

Summary

Fixes 3 failing VoIP acceptance tests (AC1, AC5, AC7) that verify the pending-accept fast-path behavior in MediaCallEvents.test.ts.

Root Cause

The tests used DeviceEventEmitter.emit() to trigger VoipPendingAccept events, but handlers registered via NativeEventEmitter.addListener() store handlers in the native RCTDeviceEventEmitter, not in any JavaScript-accessible location. So the emitted events never reached the listeners.

Fix

Mock react-native completely with a shared __nativeEventBus__ Map on global that both NativeEventEmitter.addListener (which stores handlers) and DeviceEventEmitter.emit (which invokes them) use. This allows tests to synchronously trigger events that the module under test listens for.

Additional fixes:

  • Added __esModule: true to the appNavigation mock so its default export is recognized
  • Added proceedAccept and resetFromLogout to the NativeVoip mock (missing from the original mock)
  • Removed debug console.error from the VoipPendingAccept handler in MediaCallEvents.ts

Test Plan

  • yarn test -- --testPathPattern='MediaCallEvents.test.ts' --testNamePattern="AC1.*navigates" — passes
  • yarn test -- --testPathPattern='MediaCallEvents.test.ts' --testNamePattern="AC5" — passes
  • yarn test -- --testPathPattern='MediaCallEvents.test.ts' --testNamePattern="AC7" — passes

Summary by CodeRabbit

Release Notes

  • New Features
    • Added pending accept mechanism for VoIP calls with automatic 15-second timeout for better handling during initialization
    • New "Connecting" status indicator displays during the call acceptance phase
    • Improved call state management when accepting and initializing calls

Phase 1 of Strategy C (native holds call until JS signals readiness).

- useCallStore: add PENDING_ACCEPT_TTL_MS = 15_000 (native TTL, NOT STALE_NATIVE_MS)
- MediaCallEvents: add isCallIdQueued() + queuedCallIds Set for pending-accept queue
- MediaSessionInstance: add pendingInitCallbacks + registerOnInitComplete for JS-ready handoff
- MediaSessionInstance: add tryAnswerIfNativeAcceptedNotification race guard — blocks
  live DDP notification/accepted from firing answerCall for queued callIds;
  proceedAccept is the sole answerCall trigger during pending window
- MediaSessionInstance: add invariant class comment documenting the protocol
- MediaSessionInstance.test.ts: comprehensive suite covering init, teardown,
  newCall, stream-notify-user gating, REST state replay, roomId population
… pendingAccept payload

- Add `handleVoipPendingAccept()` to MediaCallEvents.ts: queues callId,
  navigates to CallView, registers init-complete callback to call
  `NativeVoipModule.proceedAccept(callId)`.
- Add `clearPendingCallView()` helper that resets native call id and
  navigates back from CallView.
- Add `pendingAccept?: boolean` to VoipPayload interface and all native
  implementations (Swift, Kotlin).
- Add `proceedAccept(callId)` to NativeVoip.ts, VoipModule.mm, and
  VoipModule.kt (no-op on Android).
- Add `VoipPendingAccept` to iOS/Android supported events.
- iOS VoipService: store pending-accept payloads and re-invoke
  `handleNativeAccept` on `proceedAccept` after JS init completes.
- Extend `handleVoipAcceptSucceeded` and `handleVoipAcceptFailed` to
  clean up queued callIds.
- Extend cold-start `getInitialMediaCallEvents` to detect
  `initialEvents.pendingAccept` and route to `handleVoipPendingAccept`.
- Add setContact to useCallStore for seeding caller info during pending accept
- handleVoipPendingAccept seeds contact from VoipPayload (caller/username)
- CallView renders when call === null && nativeAcceptedCallId !== null (connecting state)
- CallerInfo shows "Connecting…" label with fontHint color in connecting state
- CallButtons hidden in connecting state
- Update stories to test connecting state with null call + nativeAcceptedCallId
…itter emit

Tests AC1, AC5, AC7 were failing because DeviceEventEmitter.emit() from
tests didn't reach handlers registered via NativeEventEmitter.addListener().
This happened because NativeEventEmitter stores handlers in the native module
(RCTDeviceEventEmitter), not in JavaScript-accessible maps.

Solution: mock react-native completely with a shared __nativeEventBus__ Map
accessible by both NativeEventEmitter.addListener (which stores handlers) and
DeviceEventEmitter.emit (which retrieves and invokes them). Also added
__esModule: true to appNavigation and NativeVoip mocks, and proceedAccept to
NativeVoip mock.
@coderabbitai

coderabbitai Bot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This change introduces a pending-accept fast-accept flow for VoIP calls across native Android/iOS and JavaScript layers. When a call arrives, it's queued and deferred until media session initialization completes, displaying a "Connecting" state in CallView. A new proceedAccept() method resumes the queued acceptance after initialization.

Changes

Cohort / File(s) Summary
Android VoIP Module
android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt, android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
Adds VoipPendingAccept event constant and emitPendingAcceptEvent() to push pending-accept signals to JS; implements proceedAccept(callId) stub method. Extends VoipPayload with pendingAccept boolean flag (default false), updating serialization/deserialization paths in toBundle(), toWritableMap(), and fromBundle().
iOS VoIP Module
ios/Libraries/VoipModule.mm, ios/Libraries/VoipPayload.swift, ios/Libraries/VoipService.swift
Adds VoipPendingAccept to supported events list and proceedAccept(callId) TurboModule method forwarding to VoipService. Extends VoipPayload with pendingAccept property and conditional serialization in toDictionary(). Implements storePendingAccept() and proceedAccept() to manage a pending-payload map, enabling deferred call acceptance and deduplication.
TypeScript Native Bindings
app/definitions/Voip.ts, app/lib/native/NativeVoip.ts
Adds optional pendingAccept?: boolean field to VoipPayload interface and new proceedAccept(callId: string): void TurboModule method with stubbed fallback.
VoIP Service Logic
app/lib/services/voip/MediaCallEvents.ts, app/lib/services/voip/MediaCallEvents.test.ts
Introduces queued-call tracking and new isCallIdQueued() query function. Listener for VoipPendingAccept enqueues callId, updates store/contact, navigates to CallView, and registers proceedAccept() deferred until media-session init. Clears queue on VoipAcceptSucceeded/VoipAcceptFailed. Comprehensive test coverage for queueing, dedup, TTL expiry, and cold-start flows.
Media Session Lifecycle
app/lib/services/voip/MediaSessionInstance.ts, app/lib/services/voip/MediaSessionInstance.test.ts
Adds registerOnInitComplete() lifecycle hook and guards tryAnswerIfNativeAcceptedNotification() against queued callIds. Drains registered callbacks post-init with error handling. Adds notifyNativeReset() stub. Updates test mocks for iOS behavior override and deep-link normalization.
Call Store & Utilities
app/lib/services/voip/useCallStore.ts, app/lib/services/voip/clearPendingCallView.ts
Exports PENDING_ACCEPT_TTL_MS (15s) constant and adds setContact store action. New clearPendingCallView() function resets native call ID and navigates back.
UI Components
app/views/CallView/index.tsx, app/views/CallView/components/CallerInfo.tsx, app/views/CallView/CallView.stories.tsx
CallView now renders intermediate "connecting" state when nativeAcceptedCallId is present without active call, suppressing CallButtons. CallerInfo accepts optional isConnecting prop to display "Connecting" status text. Stories updated to reflect pre-accepted/connecting state setup.

Sequence Diagram(s)

sequenceDiagram
    participant Native as Native (iOS/Android)
    participant JS as JS Runtime
    participant Store as Call Store
    participant MediaSession as Media Session
    participant UI as CallView

    Note over Native,UI: Pending Accept Fast-Accept Flow

    Native->>JS: emit VoipPendingAccept<br/>(callId, VoipPayload)
    JS->>Store: enqueue callId<br/>set nativeAcceptedCallId
    JS->>Store: setContact(caller)
    JS->>UI: navigate to CallView
    UI->>UI: render "Connecting" state
    
    JS->>MediaSession: registerOnInitComplete(cb)
    Note over JS,MediaSession: Wait for media session init
    
    MediaSession->>MediaSession: init() completes<br/>REST state replayed
    MediaSession->>MediaSession: drainQueued callbacks
    JS->>JS: invoke proceedAccept(callId)
    JS->>Native: proceedAccept(callId)
    
    Native->>Native: lookup pendingAccept<br/>payload, remove from map
    Native->>Native: handleNativeAccept()<br/>(with dedup guard)
    
    alt Accept Succeeds
        Native->>JS: emit VoipAcceptSucceeded
        JS->>Store: clear queued callId
        UI->>UI: render active call
    else Accept Fails (TTL)
        Native->>JS: emit VoipAcceptFailed
        JS->>Store: clear queued callId<br/>resetNativeCallId()
        JS->>UI: clearPendingCallView()
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

type: bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix(voip): pending-accept test mocks' accurately describes the primary focus of the changeset — fixing test mock issues that were blocking VoIP pending-accept tests. However, the PR includes substantial feature implementation beyond test fixes, including new event handling, state management, UI components, and native method implementations across both Android and iOS.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
app/views/CallView/components/CallerInfo.tsx (1)

32-37: ⚠️ Potential issue | 🟡 Minor

Disable the controls toggle while connecting.

When isConnecting is true, CallView hides CallButtons, but this Pressable still advertises “Toggle call controls” and keeps button semantics. That leaves a no-op action in the UI and a misleading accessibility label.

Suggested fix
 const CallerInfo = ({ isConnecting = false }: CallerInfoProps): React.ReactElement => {
 	const { colors } = useTheme();
 	const contact = useCallContact();
 	const toggleControlsVisible = useCallStore(state => state.toggleControlsVisible);
 	const controlsVisible = useControlsVisible();
 	const isScreenReaderEnabled = useIsScreenReaderEnabled();
+	const canToggleControls = !isConnecting && !isScreenReaderEnabled;

 	return (
 		<Pressable
 			style={styles.callerInfoContainer}
 			testID='caller-info-toggle'
-			onPress={isScreenReaderEnabled ? undefined : toggleControlsVisible}
-			accessibilityLabel={I18n.t('Toggle_call_controls')}
-			accessibilityRole={isScreenReaderEnabled ? 'none' : 'button'}>
+			onPress={canToggleControls ? toggleControlsVisible : undefined}
+			accessibilityLabel={canToggleControls ? I18n.t('Toggle_call_controls') : undefined}
+			accessibilityRole={canToggleControls ? 'button' : undefined}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/CallView/components/CallerInfo.tsx` around lines 32 - 37, The
Pressable in CallerInfo.tsx still exposes a toggle when the app is connecting;
update its props so when isConnecting is true it becomes non-interactive and
non-button for accessibility: set onPress to undefined (or noop) and
accessibilityRole to 'none', and remove or change accessibilityLabel (e.g.,
clear or provide a connecting label) while isConnecting is true; use the
existing isConnecting, toggleControlsVisible and isScreenReaderEnabled variables
to conditionally apply these props so the control is inert and not announced as
a toggle during connection.
ios/Libraries/VoipService.swift (1)

502-545: ⚠️ Potential issue | 🟡 Minor

Failure branch leaves payload in pendingAcceptPayloads.

On finishAccept(true) the payload is removed from pendingAcceptPayloads, but the finishAccept(false) branch doesn't perform the same cleanup. When the native REST accept fails (including the 10-second timeout path), clearNativeAcceptDedupe runs but the stored payload remains indefinitely. That leaks one entry per failed callId and also means a later proceedAccept(callId) from JS would retrieve the already-failed payload and re-drive handleNativeAccept (a new REST attempt, since the dedupe set was cleared). Consider removing from pendingAcceptPayloads in the failure branch as well, or gating proceedAccept to skip when the accept has terminally failed.

🔒️ Proposed cleanup in the failure branch
             } else {
+                bridgeStateQueue.sync {
+                    pendingAcceptPayloads.removeValue(forKey: payload.callId)
+                }
                 clearNativeAcceptDedupe(for: payload.callId)
                 RNCallKeep.endCall(withUUID: payload.callId, reason: 6)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Libraries/VoipService.swift` around lines 502 - 545, The failure branch
of the finishAccept closure leaks the payload because pendingAcceptPayloads is
only removed on success; update the failure path in finishAccept (the closure
defined as let finishAccept: (Bool) -> Void) to also remove the entry from
pendingAcceptPayloads under bridgeStateQueue.sync (mirror the removal done in
the success branch), and/or add gating in proceedAccept to ignore callIds that
have a terminal failure flag so handleNativeAccept isn't retried for
already-failed payloads; ensure you still call clearNativeAcceptDedupe(callId:)
and post the VoipAcceptFailed notification as before.
🧹 Nitpick comments (2)
app/lib/services/voip/MediaCallEvents.test.ts (1)

682-693: AC5 could assert that proceedAccept was actually invoked.

The mock invokes the registerOnInitComplete callback synchronously, so the real end-to-end behavior you care about is that NativeVoipModule.proceedAccept is called with the queued callId. Right now the test only verifies registerOnInitComplete was called once; a faulty implementation that forgot to pass () => NativeVoipModule.proceedAccept(callId) would still pass.

🧪 Strengthen the assertion
 		expect(mediaSessionInstance.registerOnInitComplete).toHaveBeenCalledTimes(1);
-		// Callback was invoked immediately since init was complete in the mock
+		// Mock invokes the callback synchronously, so proceedAccept should have fired
+		expect(NativeVoipModule.proceedAccept).toHaveBeenCalledWith('init-callback-test');
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/MediaCallEvents.test.ts` around lines 682 - 693, The
test currently only checks that mediaSessionInstance.registerOnInitComplete was
called; update the test to also assert that NativeVoipModule.proceedAccept was
invoked with the queued callId when the registered callback runs. Specifically,
after emitting DeviceEventEmitter.emit('VoipPendingAccept', { callId:
'init-callback-test', payload }), capture the registered callback from
mediaSessionInstance.registerOnInitComplete's mock (or trigger it if the mock
already invokes it) and verify NativeVoipModule.proceedAccept was called with
'init-callback-test'; keep the existing teardown() call.
app/lib/services/voip/MediaSessionInstance.ts (1)

52-73: Optional: narrow signal.type first to avoid as any for callId.

The isCallIdQueued((signal as any).callId) check fires for every inbound signal and loses type safety. Since callId only exists on the notification variant of ServerMediaSignal, narrowing up front removes the cast and keeps the guard colocated with the accepted-notification logic:

♻️ Proposed refactor
 	private tryAnswerIfNativeAcceptedNotification(signal: ServerMediaSignal): void {
-		// Live-DDP race guard: during pending-accept window, `proceedAccept` is the sole
-		// trigger for `answerCall`. Covers both call sites (`applyRestStateSignals` and the
-		// stream-notify-user listener). See class-level Invariant comment.
-		// TypeScript doesn't narrow ServerMediaSignal union via `type === 'notification'` alone,
-		// so we cast to access callId on the notification variant.
-		if (isCallIdQueued((signal as any).callId)) {
-			return;
-		}
-		const { call, nativeAcceptedCallId } = useCallStore.getState();
-		if (
-			signal.type === 'notification' &&
-			signal.notification === 'accepted' &&
-			signal.signedContractId === getUniqueIdSync() &&
-			nativeAcceptedCallId === signal.callId &&
-			call == null
-		) {
+		if (signal.type !== 'notification') {
+			return;
+		}
+		// Live-DDP race guard: during the pending-accept window, `proceedAccept` is the
+		// sole trigger for `answerCall`. See class-level Invariant comment.
+		if (isCallIdQueued(signal.callId)) {
+			return;
+		}
+		const { call, nativeAcceptedCallId } = useCallStore.getState();
+		if (
+			signal.notification === 'accepted' &&
+			signal.signedContractId === getUniqueIdSync() &&
+			nativeAcceptedCallId === signal.callId &&
+			call == null
+		) {
 			this.answerCall(signal.callId).catch(error => {
 				console.error('[VoIP] Error answering call on notification/accepted:', error);
 			});
 		}
 	}

As per coding guidelines, "Use TypeScript for type safety; add explicit type annotations".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/MediaSessionInstance.ts` around lines 52 - 73, Move the
check that reads signal.callId behind a type-narrow for the notification variant
so you can avoid the (signal as any) cast; specifically, in
tryAnswerIfNativeAcceptedNotification first test signal.type === 'notification'
(and optionally signal.notification === 'accepted') then call
isCallIdQueued(signal.callId) and early-return if queued, and keep the existing
checks (signedContractId === getUniqueIdSync(), nativeAcceptedCallId ===
signal.callId, call == null) before invoking this.answerCall(signal.callId).
This preserves behavior while restoring TypeScript type-safety for the
notification.callId access.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt`:
- Around line 88-101: In emitPendingAcceptEvent, don't pass a Kotlin Map to
DeviceEventManagerModule.emit; build and pass a React WritableMap instead:
obtain a WritableMap via Arguments.createMap(), putString("callId",
voipPayload.callId) and putMap("payload", voipPayload.toWritableMap()), then
emit that map for EVENT_VOIP_PENDING_ACCEPT; update the emit call in
emitPendingAcceptEvent (referencing reactContextRef, EVENT_VOIP_PENDING_ACCEPT
and VoipPayload.toWritableMap) accordingly.

In `@app/lib/services/voip/clearPendingCallView.ts`:
- Around line 4-7: The function clearPendingCallView currently always calls
Navigation.back() which can pop the wrong screen; change it so after calling
useCallStore.getState().resetNativeCallId() it first verifies the active route
is the CallView (e.g. via your navigation API's current/active route getter) and
only then calls Navigation.back(); reference clearPendingCallView,
Navigation.back, and the CallView route name when implementing the guard.

In `@app/lib/services/voip/MediaCallEvents.test.ts`:
- Around line 150-153: The failing lint rule is caused by space indentation in
the jest.mock block; update the indentation in the
jest.mock('../../navigation/appNavigation', ...) mock so lines for "__esModule:
true," and "default: { navigate: jest.fn(), back: jest.fn() }" use tabs instead
of four spaces; ensure the jest.mock call and the properties (default, navigate,
back) keep the same structure and single quotes but replace leading spaces with
tabs to satisfy the project's indentation rule.

In `@app/lib/services/voip/MediaCallEvents.ts`:
- Around line 240-245: The log label `TTL_EXPIRED` is misleading because any
VoipAcceptFailed for a queued call (native REST failure, timeout, constructor
error, etc.) will hit this branch; update the branch in MediaCallEvents where
you check queuedCallIds and call clearPendingCallView to log a neutral failure
instead (e.g., use mediaCallLogger.debug(`${TAG} VoIP FAST ACCEPT FAILED`, {
callId: data.callId, reason: data.reason || 'unknown' }) ) and only change the
label to `TTL_EXPIRED` when you have explicit TTL evidence (e.g., a ttl flag or
timer expiry value in the event payload `data`); keep references to
queuedCallIds, mediaCallLogger, TAG, clearPendingCallView and include the raw
payload/reason in the log to aid triage.

In `@app/views/CallView/CallView.stories.tsx`:
- Around line 159-165: TabletConnectingCall story sets call: null but
TabletCallView currently returns early on !call, so add the same
nativeAcceptedCallId handling as in CallView: in TabletCallView (the component
that currently exits on !call) update the early-return logic to check for
nativeAcceptedCallId (e.g., if (!call && !nativeAcceptedCallId) return null) and
when nativeAcceptedCallId is present render the tablet "connecting" state path
that CallView uses; reference the nativeAcceptedCallId prop/state and reuse the
same connecting-state rendering branch or helper used by CallView so the story
shows the connecting UI.

In `@ios/Libraries/VoipModule.mm`:
- Around line 37-39: The supportedEvents list includes "VoipPendingAccept" but
startObserving does not subscribe to that notification, so pending-accept
notifications are dropped; update startObserving to add an observer for the
"VoipPendingAccept" notification (using the same pattern as the other observers
in startObserving) and ensure its selector calls
sendEventWithName(@"VoipPendingAccept", ...) (create a new handler like
onVoipPendingAccept: if none exists). Also mirror the change in stopObserving to
remove that observer if the class removes observers explicitly.
- Around line 120-123: The VoipService class is missing a declaration for the
class selector +proceedAccept:, which is used by VoipModule's -proceedAccept:
method; add a declaration for +proceedAccept:(NSString *)callId to the local
VoipService interface (the `@interface` VoipService ... block in this file) so the
compiler knows the selector signature and avoids warnings—ensure the declaration
matches the call site (class method taking an NSString *) and is placed before
VoipModule's implementation.

---

Outside diff comments:
In `@app/views/CallView/components/CallerInfo.tsx`:
- Around line 32-37: The Pressable in CallerInfo.tsx still exposes a toggle when
the app is connecting; update its props so when isConnecting is true it becomes
non-interactive and non-button for accessibility: set onPress to undefined (or
noop) and accessibilityRole to 'none', and remove or change accessibilityLabel
(e.g., clear or provide a connecting label) while isConnecting is true; use the
existing isConnecting, toggleControlsVisible and isScreenReaderEnabled variables
to conditionally apply these props so the control is inert and not announced as
a toggle during connection.

In `@ios/Libraries/VoipService.swift`:
- Around line 502-545: The failure branch of the finishAccept closure leaks the
payload because pendingAcceptPayloads is only removed on success; update the
failure path in finishAccept (the closure defined as let finishAccept: (Bool) ->
Void) to also remove the entry from pendingAcceptPayloads under
bridgeStateQueue.sync (mirror the removal done in the success branch), and/or
add gating in proceedAccept to ignore callIds that have a terminal failure flag
so handleNativeAccept isn't retried for already-failed payloads; ensure you
still call clearNativeAcceptDedupe(callId:) and post the VoipAcceptFailed
notification as before.

---

Nitpick comments:
In `@app/lib/services/voip/MediaCallEvents.test.ts`:
- Around line 682-693: The test currently only checks that
mediaSessionInstance.registerOnInitComplete was called; update the test to also
assert that NativeVoipModule.proceedAccept was invoked with the queued callId
when the registered callback runs. Specifically, after emitting
DeviceEventEmitter.emit('VoipPendingAccept', { callId: 'init-callback-test',
payload }), capture the registered callback from
mediaSessionInstance.registerOnInitComplete's mock (or trigger it if the mock
already invokes it) and verify NativeVoipModule.proceedAccept was called with
'init-callback-test'; keep the existing teardown() call.

In `@app/lib/services/voip/MediaSessionInstance.ts`:
- Around line 52-73: Move the check that reads signal.callId behind a
type-narrow for the notification variant so you can avoid the (signal as any)
cast; specifically, in tryAnswerIfNativeAcceptedNotification first test
signal.type === 'notification' (and optionally signal.notification ===
'accepted') then call isCallIdQueued(signal.callId) and early-return if queued,
and keep the existing checks (signedContractId === getUniqueIdSync(),
nativeAcceptedCallId === signal.callId, call == null) before invoking
this.answerCall(signal.callId). This preserves behavior while restoring
TypeScript type-safety for the notification.callId access.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1b115349-d7e2-43f4-bc3c-5331bcf74ea7

📥 Commits

Reviewing files that changed from the base of the PR and between 86d780c and 59142ce.

📒 Files selected for processing (16)
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
  • app/definitions/Voip.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/clearPendingCallView.ts
  • app/lib/services/voip/useCallStore.ts
  • app/views/CallView/CallView.stories.tsx
  • app/views/CallView/components/CallerInfo.tsx
  • app/views/CallView/index.tsx
  • ios/Libraries/VoipModule.mm
  • ios/Libraries/VoipPayload.swift
  • ios/Libraries/VoipService.swift
📜 Review details
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,ts,jsx,tsx}: Use descriptive names for functions, variables, and classes that clearly convey their purpose
Write comments that explain the 'why' behind code decisions, not the 'what'
Keep functions small and focused on a single responsibility
Use const by default, let when reassignment is needed, and avoid var
Prefer async/await over .then() chains for handling asynchronous operations
Use explicit error handling with try/catch blocks for async operations
Avoid deeply nested code; refactor complex logic into helper functions

Files:

  • app/definitions/Voip.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/clearPendingCallView.ts
  • app/views/CallView/index.tsx
  • app/lib/services/voip/useCallStore.ts
  • app/views/CallView/CallView.stories.tsx
  • app/views/CallView/components/CallerInfo.tsx
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Use TypeScript for type safety; add explicit type annotations to function parameters and return types
Prefer interfaces over type aliases for defining object shapes in TypeScript
Use enums for sets of related constants rather than magic strings or numbers

**/*.{ts,tsx}: Use TypeScript with strict mode enabled and baseUrl set to app/ for module imports
Support iOS 13.4+ and Android 6.0+ as minimum target platforms

Files:

  • app/definitions/Voip.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/clearPendingCallView.ts
  • app/views/CallView/index.tsx
  • app/lib/services/voip/useCallStore.ts
  • app/views/CallView/CallView.stories.tsx
  • app/views/CallView/components/CallerInfo.tsx
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

**/*.{ts,tsx,js,jsx}: Use tabs for indentation with single quotes, 130 character line width, no trailing commas, and avoid arrow function parentheses when possible
Use ESLint with @rocket.chat/eslint-config base including React, React Native, TypeScript, and Jest plugins

Files:

  • app/definitions/Voip.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/clearPendingCallView.ts
  • app/views/CallView/index.tsx
  • app/lib/services/voip/useCallStore.ts
  • app/views/CallView/CallView.stories.tsx
  • app/views/CallView/components/CallerInfo.tsx
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
app/definitions/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Define shared TypeScript types and interfaces in app/definitions/ directory

Files:

  • app/definitions/Voip.ts
app/lib/services/voip/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Implement VoIP features in app/lib/services/voip/ directory using Zustand stores for WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration

Files:

  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/clearPendingCallView.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
app/views/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Create view components (screens) in app/views/ directory

Files:

  • app/views/CallView/index.tsx
  • app/views/CallView/CallView.stories.tsx
  • app/views/CallView/components/CallerInfo.tsx
🧠 Learnings (7)
📓 Common learnings
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP features in app/lib/services/voip/ directory using Zustand stores for WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP features in app/lib/services/voip/ directory using Zustand stores for WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration

Applied to files:

  • app/definitions/Voip.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/clearPendingCallView.ts
  • app/views/CallView/index.tsx
  • ios/Libraries/VoipModule.mm
  • app/lib/services/voip/useCallStore.ts
  • app/views/CallView/CallView.stories.tsx
  • ios/Libraries/VoipService.swift
  • app/views/CallView/components/CallerInfo.tsx
  • app/lib/services/voip/MediaSessionInstance.ts
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
📚 Learning: 2026-03-30T15:49:30.957Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6875
File: app/containers/RoomItem/Actions.tsx:12-12
Timestamp: 2026-03-30T15:49:30.957Z
Learning: In RocketChat/Rocket.Chat.ReactNative, `react-native-worklets` version 0.6.1 does NOT export a built-in Jest mock (e.g., no `react-native-worklets/lib/module/mock`). The correct Jest mock approach for this version is to add a manual mock in `jest.setup.js`: `jest.mock('react-native-worklets', () => ({ scheduleOnRN: jest.fn((fn, ...args) => fn(...args)) }))`.

Applied to files:

  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
📚 Learning: 2026-03-10T15:21:45.098Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7046
File: app/containers/InAppNotification/NotifierComponent.stories.tsx:46-75
Timestamp: 2026-03-10T15:21:45.098Z
Learning: In `app/containers/InAppNotification/NotifierComponent.tsx` (React Native, Rocket.Chat), `NotifierComponent` is exported as a Redux-connected component via `connect(mapStateToProps)`. The `isMasterDetail` prop is automatically injected from `state.app.isMasterDetail` and does not need to be passed explicitly at call sites or in Storybook stories that use the default (connected) export.

Applied to files:

  • app/views/CallView/index.tsx
  • app/views/CallView/components/CallerInfo.tsx
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/AppContainer.tsx : Use AppContainer.tsx as the root navigation container that switches between authentication states

Applied to files:

  • app/views/CallView/index.tsx
📚 Learning: 2026-03-05T06:06:12.277Z
Learnt from: divyanshu-patil
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6957
File: ios/RCTWatchModule.mm:19-24
Timestamp: 2026-03-05T06:06:12.277Z
Learning: Do not re-activate or reset the WCSession singleton in iOS Objective-C/Swift bridge modules. Ensure WCSession is activated and its delegate is set in a single, central place (e.g., ios/RocketChat Watch App/Loaders/WatchSession.swift) and avoid duplicating activation or delegate assignment in other iOS bridge files like ios/RCTWatchModule.mm. If WCSession is already activated via the central loader, relying on WCSession.defaultSession is sufficient and maintains a single session lifecycle.

Applied to files:

  • ios/Libraries/VoipModule.mm
📚 Learning: 2026-03-15T13:55:42.038Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6911
File: app/containers/markdown/Markdown.stories.tsx:104-104
Timestamp: 2026-03-15T13:55:42.038Z
Learning: In Rocket.Chat React Native, the markdown parser requires a space between the underscore wrapping italic text and a mention sigil (_ mention _ instead of _mention_). Ensure stories and tests that include italic-wrapped mentions follow this form to guarantee proper parsing. Specifically, for files like app/containers/markdown/Markdown.stories.tsx, and any test/content strings that exercise italic-mentions, use the pattern _ mention _ (with spaces) to prevent the mention from being treated as plain text. Validate any test strings or story content accordingly.

Applied to files:

  • app/views/CallView/CallView.stories.tsx
🪛 ESLint
app/lib/services/voip/MediaCallEvents.test.ts

[error] 151-151: Replace ···· with

(prettier/prettier)


[error] 152-152: Replace ···· with

(prettier/prettier)

🪛 GitHub Check: ESLint and Test / run-eslint-and-test
app/lib/services/voip/MediaCallEvents.test.ts

[failure] 152-152:
Replace ···· with


[failure] 151-151:
Replace ···· with

🔇 Additional comments (2)
android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt (1)

46-46: LGTM — serialization parity looks right.

The pendingAccept field threads through toBundle/toWritableMap/fromBundle consistently, mirrors the iOS conditional-emission pattern in toWritableMap(), and defaults to false everywhere (including RemoteVoipPayload.toVoipPayload() via the ctor default).

Also applies to: 77-77, 97-99, 204-205

app/lib/services/voip/MediaCallEvents.test.ts (1)

24-82: Mock bus design looks right for the described failure mode.

Hoisting the shared bus onto global so both NativeEventEmitter.addListener (production-path registration in MediaCallEvents) and DeviceEventEmitter.emit (test trigger) route through the same Map<string, Set<handler>> correctly fixes the "handlers registered but never invoked" issue on iOS paths. removeAllListeners forwarding to nativeModule?.removeListeners(1) preserves the TurboModule contract, and the remove returned from addListener properly deletes the exact handler so teardown() works. Nice, compact fake.

Comment on lines +88 to +101
@JvmStatic
fun emitPendingAcceptEvent(voipPayload: VoipPayload) {
try {
reactContextRef?.get()?.let { context ->
if (context.hasActiveReactInstance()) {
context
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(EVENT_VOIP_PENDING_ACCEPT, mapOf("callId" to voipPayload.callId, "payload" to voipPayload.toWritableMap()))
}
}
} catch (e: Exception) {
Log.e(TAG, "Failed to emit VoipPendingAccept", e)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the VoipModule.kt file to see the current state
cat -n android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt | head -120

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 5411


🏁 Script executed:

# Search for other emit() calls in the codebase to understand the pattern
rg "\.emit\(" --type kotlin -B 2 -A 2 android/app/src/main/java

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 2618


🏁 Script executed:

# Check if there are any imports related to WritableMap or Arguments in the file
rg "import.*React|import.*facebook" android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 244


🏁 Script executed:

# Check VideoConfModule.kt to see what type of payload it uses
rg -A 5 "emit\(EVENT_VIDEO_CONF_ACTION" android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.kt

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 296


🏁 Script executed:

# Search for Arguments usage in the android app to understand WritableMap creation pattern
rg "Arguments\." android/app/src/main/java --type kotlin -B 2 -A 2 | head -40

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 593


🏁 Script executed:

# Check what type actionJson is in VideoConfModule.kt
rg -B 10 "emit\(EVENT_VIDEO_CONF_ACTION, actionJson" android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.kt | grep -E "actionJson|WritableMap|Arguments"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 216


🏁 Script executed:

# Look at the full context of VideoConfModule.kt emit call
cat android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfModule.kt | grep -A 20 -B 20 "EVENT_VIDEO_CONF_ACTION"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 2759


🏁 Script executed:

# Let me check the type signature of RCTDeviceEventEmitter.emit to understand what it accepts
# First, let's see if there are any type hints or documentation in the codebase
rg "RCTDeviceEventEmitter" android/app/src/main/java --type kotlin -A 3 | head -20

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 2201


🌐 Web query:

RCTDeviceEventEmitter emit method signature React Native Android bridge WritableMap

💡 Result:

The RCTDeviceEventEmitter.emit method signature in React Native Android bridge is: void emit(String eventName, @Nullable Object data) This is defined in the Java interface DeviceEventManagerModule.RCTDeviceEventEmitter, which is a JavaScriptModule used via getJSModule from ReactContext. Usage example from official docs and source: reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, params); Where params is typically a WritableMap created with Arguments.createMap, or null. Note: This is for the legacy (bridge) architecture. In the New Architecture (TurboModules/Fabric), events use spec-defined emitters like CodegenTypes.EventEmitter with generated emit methods taking WritableMap/ReadableMap.

Citations:


🏁 Script executed:

# Check VoipPayload.toWritableMap to see the established pattern
cat android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt | grep -A 15 "toWritableMap"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 733


Use WritableMap instead of Kotlin Map for the event payload.

Line 95 passes a Kotlin Map created with mapOf(...) to emit(). While the method accepts Object, React Native's bridge expects WritableMap for structured payloads. Kotlin Map serialization can fail at runtime when JavaScript tries to access the data. The other emit calls in this file (lines 46 and 80) correctly use WritableMap directly. Build the payload with Arguments.createMap() and use putString() and putMap() for fields, matching the pattern in VoipPayload.toWritableMap().

Suggested fix
+import com.facebook.react.bridge.Arguments
 import com.facebook.react.bridge.ReactApplicationContext
 import com.facebook.react.bridge.WritableMap
 import com.facebook.react.modules.core.DeviceEventManagerModule
@@
         fun emitPendingAcceptEvent(voipPayload: VoipPayload) {
             try {
                 reactContextRef?.get()?.let { context ->
                     if (context.hasActiveReactInstance()) {
+                        val event = Arguments.createMap().apply {
+                            putString("callId", voipPayload.callId)
+                            putMap("payload", voipPayload.toWritableMap())
+                        }
                         context
                             .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
-                            .emit(EVENT_VOIP_PENDING_ACCEPT, mapOf("callId" to voipPayload.callId, "payload" to voipPayload.toWritableMap()))
+                            .emit(EVENT_VOIP_PENDING_ACCEPT, event)
                     }
                 }
             } catch (e: Exception) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt` around
lines 88 - 101, In emitPendingAcceptEvent, don't pass a Kotlin Map to
DeviceEventManagerModule.emit; build and pass a React WritableMap instead:
obtain a WritableMap via Arguments.createMap(), putString("callId",
voipPayload.callId) and putMap("payload", voipPayload.toWritableMap()), then
emit that map for EVENT_VOIP_PENDING_ACCEPT; update the emit call in
emitPendingAcceptEvent (referencing reactContextRef, EVENT_VOIP_PENDING_ACCEPT
and VoipPayload.toWritableMap) accordingly.

Comment on lines +4 to +7
export function clearPendingCallView(): void {
useCallStore.getState().resetNativeCallId();
// If we're on CallView, navigate back
Navigation.back();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard the back navigation to CallView only.

This helper always calls Navigation.back(), so a late VoipAcceptFailed can pop whatever screen the user is currently on. The inline comment already describes the intended behavior; the implementation should check that CallView is actually active before navigating back.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/clearPendingCallView.ts` around lines 4 - 7, The
function clearPendingCallView currently always calls Navigation.back() which can
pop the wrong screen; change it so after calling
useCallStore.getState().resetNativeCallId() it first verifies the active route
is the CallView (e.g. via your navigation API's current/active route getter) and
only then calls Navigation.back(); reference clearPendingCallView,
Navigation.back, and the CallView route name when implementing the guard.

Comment on lines +150 to +153
jest.mock('../../navigation/appNavigation', () => ({
__esModule: true,
default: { navigate: jest.fn(), back: jest.fn() }
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

ESLint/Prettier: use tabs for indentation.

Lines 151–152 use 4 spaces; the file (and the repo config) expects tabs. This is exactly what the failing run-eslint-and-test check is reporting (Replace ···· with ↹), so CI will stay red until it's fixed.

🧹 Fix indentation
 jest.mock('../../navigation/appNavigation', () => ({
-    __esModule: true,
-    default: { navigate: jest.fn(), back: jest.fn() }
+	__esModule: true,
+	default: { navigate: jest.fn(), back: jest.fn() }
 }));

As per coding guidelines, "Use tabs for indentation with single quotes, 130 character line width, no trailing commas, and avoid arrow function parentheses when possible".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
jest.mock('../../navigation/appNavigation', () => ({
__esModule: true,
default: { navigate: jest.fn(), back: jest.fn() }
}));
jest.mock('../../navigation/appNavigation', () => ({
__esModule: true,
default: { navigate: jest.fn(), back: jest.fn() }
}));
🧰 Tools
🪛 ESLint

[error] 151-151: Replace ···· with

(prettier/prettier)


[error] 152-152: Replace ···· with

(prettier/prettier)

🪛 GitHub Check: ESLint and Test / run-eslint-and-test

[failure] 152-152:
Replace ···· with


[failure] 151-151:
Replace ···· with

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/MediaCallEvents.test.ts` around lines 150 - 153, The
failing lint rule is caused by space indentation in the jest.mock block; update
the indentation in the jest.mock('../../navigation/appNavigation', ...) mock so
lines for "__esModule: true," and "default: { navigate: jest.fn(), back:
jest.fn() }" use tabs instead of four spaces; ensure the jest.mock call and the
properties (default, navigate, back) keep the same structure and single quotes
but replace leading spaces with tabs to satisfy the project's indentation rule.

Comment on lines +726 to +747
describe('AC7: resetMediaCallEventsStateForTesting clears all sentinels', () => {
it('after resetMediaCallEventsStateForTesting, previously queued callIds can be re-processed', () => {
setupMediaCallEvents(makeTestAdapters());
const payload = buildIncomingPayload({
callId: 'reset-sentinel',
host: 'https://workspace-b.example.com'
});

// Queue a callId
DeviceEventEmitter.emit('VoipPendingAccept', { callId: 'reset-sentinel', payload });
let { isCallIdQueued } = jest.requireActual('./MediaCallEvents');
expect(isCallIdQueued('reset-sentinel')).toBe(true);

// Reset all sentinels
resetMediaCallEventsStateForTesting();

// Same callId can now be queued again
DeviceEventEmitter.emit('VoipPendingAccept', { callId: 'reset-sentinel', payload });
({ isCallIdQueued } = jest.requireActual('./MediaCallEvents'));
expect(isCallIdQueued('reset-sentinel')).toBe(true);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

AC7 doesn't actually verify "re-processing" after reset.

resetMediaCallEventsStateForTesting() only calls clearVoipAcceptDedupeSentinels(); it does not clear queuedCallIds. So in this test:

  1. First emit queues 'reset-sentinel' (set contains it).
  2. resetMediaCallEventsStateForTesting() leaves queuedCallIds untouched.
  3. Second emit hits the dedup early-return in handleVoipPendingAccept, never reaching setNativeAcceptedCallId/setContact/Navigation.navigate.
  4. isCallIdQueued('reset-sentinel') is still true — but that's because it was never removed, not because it was re-queued.

The assertion is trivially true and doesn't prove what the test name claims. Either extend the reset helper to also clear queuedCallIds, or change the assertions to reflect the real invariant (e.g. verify setNativeAcceptedCallId was called once after reset, or assert queue dedup survives reset).

🧪 Option A — make the reset also clear the queue and assert true re-processing

In MediaCallEvents.ts:

 export function resetMediaCallEventsStateForTesting(): void {
 	clearVoipAcceptDedupeSentinels();
+	queuedCallIds.clear();
 }

In the test:

 		// Queue a callId
 		DeviceEventEmitter.emit('VoipPendingAccept', { callId: 'reset-sentinel', payload });
-		let { isCallIdQueued } = jest.requireActual('./MediaCallEvents');
+		const { isCallIdQueued } = jest.requireActual('./MediaCallEvents');
 		expect(isCallIdQueued('reset-sentinel')).toBe(true);
+		expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(1);

 		// Reset all sentinels
 		resetMediaCallEventsStateForTesting();
+		expect(isCallIdQueued('reset-sentinel')).toBe(false);

-		// Same callId can now be queued again
+		// Same callId can now be queued again and must re-drive the handler
 		DeviceEventEmitter.emit('VoipPendingAccept', { callId: 'reset-sentinel', payload });
-		({ isCallIdQueued } = jest.requireActual('./MediaCallEvents'));
 		expect(isCallIdQueued('reset-sentinel')).toBe(true);
+		expect(mockSetNativeAcceptedCallId).toHaveBeenCalledTimes(2);

Comment on lines +240 to +245
// If this callId was queued, remove from set and treat as TTL expiry
if (data.callId && queuedCallIds.has(data.callId)) {
queuedCallIds.delete(data.callId);
mediaCallLogger.debug(`${TAG} VoIP FAST ACCEPT TTL_EXPIRED`, { callId: data.callId });
clearPendingCallView();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

"TTL_EXPIRED" log fires on every failure, not only TTL expiry.

This branch runs for any VoipAcceptFailed received while the callId is queued — the native REST failure, the 10s timeout, API construction errors, etc. Logging it as TTL_EXPIRED will mislead triage when debugging non-TTL failures. Consider a neutral label (e.g. VoIP FAST ACCEPT FAILED) and only tag as TTL if you actually have TTL evidence from the payload or a timer.

📝 Proposed tweak
-				mediaCallLogger.debug(`${TAG} VoIP FAST ACCEPT TTL_EXPIRED`, { callId: data.callId });
+				mediaCallLogger.debug(`${TAG} VoIP FAST ACCEPT FAILED`, { callId: data.callId });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If this callId was queued, remove from set and treat as TTL expiry
if (data.callId && queuedCallIds.has(data.callId)) {
queuedCallIds.delete(data.callId);
mediaCallLogger.debug(`${TAG} VoIP FAST ACCEPT TTL_EXPIRED`, { callId: data.callId });
clearPendingCallView();
}
// If this callId was queued, remove from set and treat as TTL expiry
if (data.callId && queuedCallIds.has(data.callId)) {
queuedCallIds.delete(data.callId);
mediaCallLogger.debug(`${TAG} VoIP FAST ACCEPT FAILED`, { callId: data.callId });
clearPendingCallView();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/MediaCallEvents.ts` around lines 240 - 245, The log
label `TTL_EXPIRED` is misleading because any VoipAcceptFailed for a queued call
(native REST failure, timeout, constructor error, etc.) will hit this branch;
update the branch in MediaCallEvents where you check queuedCallIds and call
clearPendingCallView to log a neutral failure instead (e.g., use
mediaCallLogger.debug(`${TAG} VoIP FAST ACCEPT FAILED`, { callId: data.callId,
reason: data.reason || 'unknown' }) ) and only change the label to `TTL_EXPIRED`
when you have explicit TTL evidence (e.g., a ttl flag or timer expiry value in
the event payload `data`); keep references to queuedCallIds, mediaCallLogger,
TAG, clearPendingCallView and include the raw payload/reason in the log to aid
triage.

Comment on lines +159 to +165
setStoreState({
call: null,
nativeAcceptedCallId: 'pending-call-id',
callState: 'none',
callStartTime: null,
contact: { displayName: 'Bob Burnquist', username: 'bob.burnquist' }
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

TabletConnectingCall now renders nothing.

This story sets call: null, but TabletCallView still exits early on !call at Line 142, so the tablet connecting state never appears. Please mirror CallView’s nativeAcceptedCallId handling in TabletCallView before switching the story to this state.

Suggested fix
 const TabletCallView = () => {
 	const { colors } = useTheme();
 	const call = useCallStore(state => state.call);
-	if (!call) return null;
+	const nativeAcceptedCallId = useCallStore(state => state.nativeAcceptedCallId);
+	const isConnecting = !call && !!nativeAcceptedCallId;
+	if (!call && !isConnecting) return null;
 	return (
 		<ResponsiveLayoutContext.Provider value={{ ...responsiveLayoutProviderLargeFontValue(1), width: 700 }}>
 			<View style={[callViewStyles.contentContainer, { backgroundColor: colors.surfaceLight }]}>
-				<CallerInfo />
-				<CallButtons />
+				<CallerInfo isConnecting={isConnecting} />
+				{isConnecting ? null : <CallButtons />}
 			</View>
 		</ResponsiveLayoutContext.Provider>
 	);
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/views/CallView/CallView.stories.tsx` around lines 159 - 165,
TabletConnectingCall story sets call: null but TabletCallView currently returns
early on !call, so add the same nativeAcceptedCallId handling as in CallView: in
TabletCallView (the component that currently exits on !call) update the
early-return logic to check for nativeAcceptedCallId (e.g., if (!call &&
!nativeAcceptedCallId) return null) and when nativeAcceptedCallId is present
render the tablet "connecting" state path that CallView uses; reference the
nativeAcceptedCallId prop/state and reuse the same connecting-state rendering
branch or helper used by CallView so the story shows the connecting UI.

Comment on lines 37 to 39
- (NSArray<NSString *> *)supportedEvents {
return @[@"VoipPushTokenRegistered", @"VoipAcceptFailed", @"VoipAcceptSucceeded"];
return @[@"VoipPushTokenRegistered", @"VoipAcceptFailed", @"VoipAcceptSucceeded", @"VoipPendingAccept"];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

VoipPendingAccept is exposed but never forwarded to JS.

Adding the event name here is not enough: startObserving still subscribes only to VoipPushTokenRegistered, VoipAcceptFailed, and VoipAcceptSucceeded. As written, warm iOS VoipPendingAccept notifications will be dropped instead of reaching the JS listener.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Libraries/VoipModule.mm` around lines 37 - 39, The supportedEvents list
includes "VoipPendingAccept" but startObserving does not subscribe to that
notification, so pending-accept notifications are dropped; update startObserving
to add an observer for the "VoipPendingAccept" notification (using the same
pattern as the other observers in startObserving) and ensure its selector calls
sendEventWithName(@"VoipPendingAccept", ...) (create a new handler like
onVoipPendingAccept: if none exists). Also mirror the change in stopObserving to
remove that observer if the class removes observers explicitly.

Comment on lines +120 to +123
- (void)proceedAccept:(NSString *)callId {
dispatch_async(dispatch_get_main_queue(), ^{
[VoipService proceedAccept:callId];
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find VoipModule.mm and VoipService related files
find . -type f -name "VoipModule.mm" -o -name "*VoipService*" 2>/dev/null | head -20

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 142


🏁 Script executed:

#!/bin/bash
# Search for VoipService interface declarations
rg "interface VoipService" --type objc -B2 -A10

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 60


🏁 Script executed:

#!/bin/bash
# Search for proceedAccept method calls and declarations
rg "proceedAccept" --type objc -B2 -A2

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 60


🏁 Script executed:

#!/bin/bash
# Check VoipModule.mm content and size
wc -l ./ios/Libraries/VoipModule.mm

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 112


🏁 Script executed:

#!/bin/bash
# Read VoipModule.mm around lines 120-123
sed -n '110,130p' ./ios/Libraries/VoipModule.mm

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 649


🏁 Script executed:

#!/bin/bash
# Check VoipService.swift
cat ./ios/Libraries/VoipService.swift

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 26117


🏁 Script executed:

#!/bin/bash
# Look for bridging header files
find ./ios -name "*-Bridging-Header.h" -o -name "*Bridge*"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 254


🏁 Script executed:

#!/bin/bash
# Read the bridging header
cat ./ios/RocketChatRN-Bridging-Header.h

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 537


🏁 Script executed:

#!/bin/bash
# Read the top of VoipModule.mm to see imports and any interface declarations
head -50 ./ios/Libraries/VoipModule.mm

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1450


Add +proceedAccept: declaration to the VoipService interface.

The local VoipService interface in this file does not declare the +proceedAccept: selector, yet it is called at lines 120–123. This creates an incomplete contract and can trigger compiler warnings under strict Objective-C settings.

Suggested fix
 `@interface` VoipService : NSObject
 + (void)voipRegistration;
 + (NSDictionary * _Nullable)getInitialEvents;
 + (void)clearInitialEvents;
 + (NSString * _Nonnull)getLastVoipToken;
 + (void)stopDDPClient;
++ (void)proceedAccept:(NSString * _Nonnull)callId;
 `@end`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- (void)proceedAccept:(NSString *)callId {
dispatch_async(dispatch_get_main_queue(), ^{
[VoipService proceedAccept:callId];
});
`@interface` VoipService : NSObject
(void)voipRegistration;
(NSDictionary * _Nullable)getInitialEvents;
(void)clearInitialEvents;
(NSString * _Nonnull)getLastVoipToken;
(void)stopDDPClient;
(void)proceedAccept:(NSString * _Nonnull)callId;
`@end`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Libraries/VoipModule.mm` around lines 120 - 123, The VoipService class is
missing a declaration for the class selector +proceedAccept:, which is used by
VoipModule's -proceedAccept: method; add a declaration for
+proceedAccept:(NSString *)callId to the local VoipService interface (the
`@interface` VoipService ... block in this file) so the compiler knows the
selector signature and avoids warnings—ensure the declaration matches the call
site (class method taking an NSString *) and is placed before VoipModule's
implementation.

@diegolmello

Copy link
Copy Markdown
Member Author

PR #7239 Critical Review

Title: fix(voip): pending-accept test mocks
Branch: feat.voip-lib-new


Overview

Implements a "pending-accept" fast-path for VoIP calls: when iOS CallKit accepts a call before JS is initialized, the native layer holds it briefly, emits VoipPendingAccept, and JS later calls proceedAccept to continue. The PR also includes acceptance tests for this flow.

The code is functionally correct in its core logic. The issues below are real but non-blocking for the stated goal.


Blocking Issues

1. notifyNativeReset() is an unimplemented stub in the production reset() path

In MediaSessionInstance.ts:236:

public reset() {
    this.notifyNativeReset(); // ← no-op stub; TODO comment in source
    ...
}

notifyNativeReset() is a private method containing only:

private notifyNativeReset(): void {
    // TODO: wired in Phase 6
}

reset() is called on logout, end call, and other teardown paths. If notifyNativeReset is supposed to tell the native layer to clear pendingAcceptPayloads, this is a silent no-op. If it has no purpose yet, it should be removed from reset() to avoid confusion.


2. resetFromLogout is in the mock but not wired in production

NativeVoip.ts mock adds resetFromLogout: jest.fn(), but there is no call site in the PR that actually invokes NativeVoipModule.resetFromLogout(). The pendingAcceptPayloads dictionary on iOS will accumulate entries across logout/login cycles. This may be intentional ("Phase 6" per the TODO), but it should be tracked as a known gap.


3. PENDING_ACCEPT_TTL_MS is exported but never used in JS

In useCallStore.ts:758:

export const PENDING_ACCEPT_TTL_MS = 15_000;

The comment says "native sides (iOS/Android) must match this value," but there is no JS-side enforcement of this TTL. The 15-second window exists only in native code. If the native TTL fires but JS hasn't received any signal, the queued callId silently stays in queuedCallIds forever. This is probably fine since VoipAcceptFailed from the native side handles expiry, but the constant is misleading.


Non-Blocking Issues

4. clearPendingCallView calls Navigation.back() unconditionally

If clearPendingCallView is called when the user is not on CallView, Navigation.back() navigates to the previous screen. This is likely safe given the call flow, but worth auditing.


5. The __nativeEventBus__ mock has a subtle coupling issue

MediaCallEvents.ts creates its Emitter at module load time (line 12):

const Emitter = isIOS ? new NativeEventEmitter(NativeVoipModule) : DeviceEventEmitter;

This happens before jest mocks are applied. The Emitter instance holds a reference to the real NativeEventEmitter, not the mock. However, since setupMediaCallEvents (which adds listeners) runs after mocks are set up and tests only emit via DeviceEventEmitter, it works in practice. But the Emitter itself is not using __nativeEventBus__, so any direct interaction with it in tests would break. Document this constraint.


6. PR description accuracy

The description says:

"Removed debug console.error from the VoipPendingAccept handler in MediaCallEvents.ts"

The source file shows no console.error was removed — only mediaCallLogger.error is used. This line in the description is stale/inaccurate.


What's Solid

  • isCallIdQueued race guard in tryAnswerIfNativeAcceptedNotification correctly prevents the DDP notification path from racing with the pending-accept handoff
  • iOS deduplication in VoipService.proceedAccept via nativeAcceptHandledCallIds prevents double-processing
  • finishAccept guard (finishAcceptInvoked) prevents double-cleanup of pendingAcceptPayloads
  • Cold-start path in getInitialMediaCallEvents correctly routes pendingAccept events to handleVoipPendingAccept
  • Dedup on VoipPendingAccept via queuedCallIds.has() prevents duplicate queuing of the same callId
  • resetMediaCallEventsStateForTesting clears queuedCallIds — tests properly isolate
  • __esModule: true on appNavigation mock is correct for ESM default exports

Verdict

Approve with comments. The implementation is sound and the test fixes are legitimate. The three blocking items (notifyNativeReset stub, unwired resetFromLogout, unused PENDING_ACCEPT_TTL_MS) should be addressed or explicitly deferred with a tracked issue. The stale PR description line should be corrected before merge.

@diegolmello diegolmello deleted the fix/voip-locked-fast-accept branch April 24, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant