Skip to content

feat: native accept success event#7068

Merged
diegolmello merged 5 commits into
feat.voip-lib-newfrom
feat.answer-registered
Mar 27, 2026
Merged

feat: native accept success event#7068
diegolmello merged 5 commits into
feat.voip-lib-newfrom
feat.answer-registered

Conversation

@diegolmello

@diegolmello diegolmello commented Mar 27, 2026

Copy link
Copy Markdown
Member

Proposed changes

This branch builds on feat.voip-lib-new to align native VoIP “accept succeeded” handling across iOS and Android, and to separate “native already accepted this call” from transient UI / callId state in JS.

  • Rename / unify the success event: Native code emits VoipAcceptSucceeded instead of VoipPushInitialEvents (Android VoipModule, iOS VoipModule supported events + notification observers). JS listens on one path for both platforms.
  • MediaCallEvents: On success, clears initial events, records the call via setNativeAcceptedCallId, and opens the deep link — with deduplication so warm replays do not double-handle the same callId.
  • useCallStore: Adds nativeAcceptedCallId (kept across reset()), setNativeAcceptedCallId / resetNativeCallId, and a stale-native timer so an orphan native-accepted id does not stick around forever if JS never completes setup.
  • MediaSessionInstance: notification/accepted on stream-notify-user only triggers answerCall when the signal matches nativeAcceptedCallId (and device contract), avoiding answerCall from the old Android “initial events” flow.
  • Android VoipNotification: Adjusts the accept-timeout Runnable so it is posted only after finish exists and cancellation/removal stays correct.
  • Tests for useCallStore, MediaSessionInstance gating, and MediaCallHeader when only a native-accepted id is present before answerCall completes.

Issue(s)

https://rocketchat.atlassian.net/browse/VMUX-62

How to test or reproduce

Automated

TZ=UTC yarn test --testPathPattern='app/lib/services/voip/(useCallStore|MediaSessionInstance)\.test\.(ts|tsx)|app/containers/MediaCallHeader/MediaCallHeader\.test\.tsx'

Or run the full suite:

TZ=UTC yarn test

Manual — Android

  1. Cold start: Force-stop the app. Trigger an incoming VoIP notification, Answer from the native UI. Confirm the app comes to foreground, navigates to the call/deep link, and audio/WebRTC establishes without a second erroneous accept.
  2. Warm start: App in background (not killed). Repeat answer from notification; confirm same behavior and no duplicate accept / stuck UI.
  3. Failure path: If you can simulate accept failure (or server/network failure), confirm VoipAcceptFailed still surfaces and recovery behaves as before.
  4. Timeout path: If native accept hangs, confirm after ~10s the JS fallback still runs (logs / behavior per VoipNotification).

Manual — iOS

  1. Cold / warm answer from CallKit / VoIP UI; confirm VoipAcceptSucceeded is received once JS is up, deep link opens, and media session answerCall runs only when notification/accepted matches nativeAcceptedCallId.
  2. Regression: token / registration events still work (VoipPushTokenRegistered, etc.).

UI sanity

  • While native accept succeeded but answerCall has not finished, MediaCallHeader should show the empty placeholder (see unit test) rather than a broken full header.

Screenshots

Add screenshots or screen recordings for cold/warm answer on iOS + Android if helpful.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Improvement (non-breaking change which improves a current function)
  • New feature (non-breaking change which adds functionality)
  • Documentation update (if none of the other choices apply)

Checklist

  • I have read the CONTRIBUTING doc
  • I have signed the CLA
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works (if applicable)
  • I have added necessary documentation (if applicable)
  • Any dependent changes have been merged and published in downstream modules

Further comments

  • Base branch: open this PR into feat.voip-lib-new (not develop) until that line is merged upstream.
  • Native ↔ JS contract change: any other code or docs referencing VoipPushInitialEvents for success must use VoipAcceptSucceeded instead.

Summary by CodeRabbit

  • Bug Fixes

    • Improved VoIP accept reliability, deduplication, and synchronization between device and app
    • Better cleanup of native call state and timeouts to avoid stale/abandoned calls
    • Safer accept-failure flow that resets native call identifier before teardown
  • New Features

    • Native “accept succeeded” event integrated to streamline accept-success handling and deep-linking
    • Tighter auto-answer gating to prevent unintended auto-answers and duplicate handling
  • Tests

    • Added and expanded tests covering accept flows, native-accepted state, timers, and auto-answer behavior

@coderabbitai

coderabbitai Bot commented Mar 27, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Reworks native-to-JS VoIP accept signaling: introduces and emits a new VoipAcceptSucceeded event on Android/iOS, records a sticky nativeAcceptedCallId with a 15s stale timer in the call store, and gates auto-answer to require native accept confirmation before answering incoming calls.

Changes

Cohort / File(s) Summary
Android Native VoIP
android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt, android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
Replace prior initial event name with VoipAcceptSucceeded emission; refactor accept-timeout runnable assignment/cancellation in notification accept flow.
iOS Native VoIP
ios/Libraries/VoipModule.mm, ios/Libraries/VoipService.swift
Add VoipAcceptSucceeded to supported events/observers and post VoipAcceptSucceeded on successful native DDP accept.
Call store: native accept state
app/lib/services/voip/useCallStore.ts, app/lib/services/voip/useCallStore.test.ts
Add nativeAcceptedCallId with a 15s stale timer, actions setNativeAcceptedCallId/resetNativeCallId, export cleanupCallListeners(), and update lifecycle/end/reset flows to preserve/clear native id correctly.
Media session auto-answer
app/lib/services/voip/MediaSessionInstance.ts, app/lib/services/voip/MediaSessionInstance.test.ts
Gate auto-answer: require nativeAcceptedCallId === signal.callId (plus device id match and no bound call); add guards to avoid double-accept and clear native id when appropriate; update tests.
Event routing / deep linking
app/lib/services/voip/MediaCallEvents.ts, app/sagas/deepLinking.js
Replace VoipPushInitialEvents listener with VoipAcceptSucceeded handler that sets native accepted id and triggers deep link; on accept-failure, reset native id before clearing store.
UI / integration tweaks & tests
app/containers/MediaCallHeader/MediaCallHeader.test.tsx, app/sagas/selectServer.ts
Add test for native-accepted-but-unbound UI; remove media session reset on server selection.

Sequence Diagram

sequenceDiagram
    participant Native as Native Layer (iOS/Android)
    participant Events as VoIP Event Bridge
    participant Store as useCallStore
    participant Session as MediaSessionInstance
    participant Deep as deepLinking Saga

    Native->>Events: Emit VoipAcceptSucceeded (payload)
    Events->>Store: setNativeAcceptedCallId(callId)
    activate Store
    Store->>Store: store nativeAcceptedCallId\narm 15s stale timer
    deactivate Store
    Events->>Deep: deepLinkingOpen({callId, host})
    activate Deep
    Deep->>Store: navigation / room open
    deactivate Deep

    Session->>Session: Receive stream "accepted" notification
    Session->>Store: read nativeAcceptedCallId
    alt nativeAcceptedCallId matches && no bound call && signedContractId matches
        Session->>Session: answerCall(callId)
        activate Session
        Session->>Store: setCall(call)
        Store->>Store: clear nativeAcceptedCallId\ncancel stale timer
        deactivate Session
    else mismatch or already-bound
        Session->>Session: skip auto-answer
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: native accept success event' clearly summarizes the main change: introducing a native accept success event (VoipAcceptSucceeded) across iOS and Android platforms.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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: 1

Caution

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

⚠️ Outside diff range comments (2)
app/lib/services/voip/MediaCallEvents.ts (1)

149-175: ⚠️ Potential issue | 🟠 Major

Only set nativeAcceptedCallId for explicit native accept-success.

VoipNotification.prepareMainActivityForIncomingVoip(...storePayloadForJs = true) still stashes plain incoming-call payloads on Android when MainActivity is just opened. This block still treats every Android initial event as “answered”, so Line 174 marks a non-accepted launch as nativeAcceptedCallId and weakens the new auto-answer gate. Keep the deep link, but reserve setNativeAcceptedCallId() for an explicit accept-success signal.

🤖 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 149 - 175, The code
currently assumes Android initial events mean the call was answered and calls
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId); change
this so setNativeAcceptedCallId is only invoked when there's an explicit native
accept-success (e.g., when an RNCallKeepPerformAnswerCallAction is present for
the call UUID or another explicit accept signal), not by default on Android;
update the logic in MediaCallEvents (the block that uses isIOS,
RNCallKeep.getInitialEvents(), initialEvents.callId and setNativeAcceptedCallId)
to detect a real "answer" event before setting nativeAcceptedCallId and keep the
deep link handling separate from marking the call as natively accepted.
app/lib/services/voip/MediaSessionInstance.ts (1)

64-80: ⚠️ Potential issue | 🟠 Major

Await processSignal() before triggering the native-accept auto-answer.

MediaSignalingSession.processSignal() is asynchronous and returns a Promise. The current code calls processSignal() without awaiting it, then immediately checks conditions and may call answerCall() before the session state is ready. If this happens, the new fallback path can end CallKeep and clear nativeAcceptedCallId for a valid native accept.

Suggested change
-			this.instance.processSignal(signal);
-
-			console.log('🤙 [VoIP] Processed signal:', signal);
-
-			// Answer when native already accepted and stream matches device contract + callId.
-			const storeSlice = useCallStore.getState();
-			const { call, nativeAcceptedCallId } = storeSlice;
-
-			if (
-				signal.type === 'notification' &&
-				signal.notification === 'accepted' &&
-				signal.signedContractId === getUniqueIdSync() &&
-				nativeAcceptedCallId === signal.callId &&
-				call == null
-			) {
-				this.answerCall(signal.callId).catch(error => {
-					console.error('[VoIP] Error answering call on notification/accepted:', error);
-				});
-			}
+			void Promise.resolve(this.instance.processSignal(signal))
+				.then(() => {
+					console.log('🤙 [VoIP] Processed signal:', signal);
+
+					const { call, nativeAcceptedCallId } = useCallStore.getState();
+					if (
+						signal.type === 'notification' &&
+						signal.notification === 'accepted' &&
+						signal.signedContractId === getUniqueIdSync() &&
+						nativeAcceptedCallId === signal.callId &&
+						call == null
+					) {
+						return this.answerCall(signal.callId);
+					}
+				})
+				.catch(error => {
+					console.error('[VoIP] Error processing signal:', error);
+				});
🤖 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 64 - 80, The code
calls this.instance.processSignal(signal) without awaiting it, so session state
may not be updated before the native-accept auto-answer logic runs; update the
handler to await the promise returned by MediaSignalingSession.processSignal
(i.e., await this.instance.processSignal(signal)) before reading
useCallStore.getState() and potentially calling this.answerCall(signal.callId),
ensuring any surrounding function is async or the promise is properly handled so
the conditionals that use nativeAcceptedCallId and call see the updated session
state.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/lib/services/voip/useCallStore.ts`:
- Around line 36-50: The cleanup timers currently null out callId
unconditionally which can clear an unrelated JS call; update the two timer
cleanup blocks (the one using staleNativeTimer/staleNativeScheduledId and the
other at the second cleanup path) to only reset callId when the stored callId
matches the nativeAcceptedCallId/scheduled id being cleared (use get() to
compare callId === scheduled/nativeAcceptedCallId before setting callId:null),
leaving nativeAcceptedCallId cleared as before; also add a regression test that
does setCallId('js'); setNativeAcceptedCallId('native') and verifies that
expiring the native-accept timer clears nativeAcceptedCallId but does not clear
the unrelated callId.

---

Outside diff comments:
In `@app/lib/services/voip/MediaCallEvents.ts`:
- Around line 149-175: The code currently assumes Android initial events mean
the call was answered and calls
useCallStore.getState().setNativeAcceptedCallId(initialEvents.callId); change
this so setNativeAcceptedCallId is only invoked when there's an explicit native
accept-success (e.g., when an RNCallKeepPerformAnswerCallAction is present for
the call UUID or another explicit accept signal), not by default on Android;
update the logic in MediaCallEvents (the block that uses isIOS,
RNCallKeep.getInitialEvents(), initialEvents.callId and setNativeAcceptedCallId)
to detect a real "answer" event before setting nativeAcceptedCallId and keep the
deep link handling separate from marking the call as natively accepted.

In `@app/lib/services/voip/MediaSessionInstance.ts`:
- Around line 64-80: The code calls this.instance.processSignal(signal) without
awaiting it, so session state may not be updated before the native-accept
auto-answer logic runs; update the handler to await the promise returned by
MediaSignalingSession.processSignal (i.e., await
this.instance.processSignal(signal)) before reading useCallStore.getState() and
potentially calling this.answerCall(signal.callId), ensuring any surrounding
function is async or the promise is properly handled so the conditionals that
use nativeAcceptedCallId and call see the updated session state.
🪄 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: c61b6815-89b4-4b19-b30e-9b1224ca7bf8

📥 Commits

Reviewing files that changed from the base of the PR and between e3a7a78 and 4d65864.

📒 Files selected for processing (12)
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
  • app/containers/MediaCallHeader/MediaCallHeader.test.tsx
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/sagas/deepLinking.js
  • app/sagas/selectServer.ts
  • ios/Libraries/VoipModule.mm
  • ios/Libraries/VoipService.swift
💤 Files with no reviewable changes (1)
  • app/sagas/selectServer.ts
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 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
🔇 Additional comments (3)
app/containers/MediaCallHeader/MediaCallHeader.test.tsx (1)

92-106: LGTM! Test correctly covers the native-accepted intermediate state.

The test validates the scenario where a call has been natively accepted (with callId set) but the JS-side answerCall hasn't completed yet (call: null, callState: 'none'). This aligns well with the PR objectives.

Optional: For consistency with the existing "no call" test (lines 77-90), you could add assertions for the other child elements:

 		expect(getByTestId('media-call-header-empty')).toBeTruthy();
 		expect(queryByTestId('media-call-header')).toBeNull();
+		expect(queryByTestId('media-call-header-collapse')).toBeNull();
+		expect(queryByTestId('media-call-header-content')).toBeNull();
+		expect(queryByTestId('media-call-header-end')).toBeNull();

[approve_code_changes, suggest_optional_refactor]

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

97-131: Good timer lifecycle coverage.

The cancel/rearm cases here pin the new stale-native behavior tightly.

app/lib/services/voip/useCallStore.ts (1)

270-281: Nice reset semantics.

Preserving nativeAcceptedCallId here and rearming the timeout makes the cold/warm-start handoff resilient without leaving sticky native state around forever.

Comment thread app/lib/services/voip/useCallStore.ts Outdated

@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: 1

🧹 Nitpick comments (1)
app/lib/services/voip/MediaSessionInstance.test.ts (1)

199-350: Add a negative test for signedContractId mismatch.

The new suite validates nativeAcceptedCallId gating well, but it does not assert the device-contract guard failure path (signedContractId !== getUniqueIdSync()), which is part of the production gate.

Suggested test case
+		it('does not call answerCall when signedContractId does not match device', async () => {
+			const answerSpy = jest.spyOn(mediaSessionInstance, 'answerCall').mockResolvedValue(undefined);
+			mockUseCallStoreGetState.mockReturnValue({
+				reset: mockCallStoreReset,
+				setCall: jest.fn(),
+				setCallId: jest.fn(),
+				resetNativeCallId: jest.fn(),
+				call: null,
+				callId: null,
+				nativeAcceptedCallId: 'from-signal'
+			});
+			mediaSessionInstance.init('user-1');
+			const streamHandler = getStreamNotifyHandler();
+			streamHandler({
+				msg: 'changed',
+				fields: {
+					eventName: 'uid/media-signal',
+					args: [
+						{
+							type: 'notification',
+							notification: 'accepted',
+							signedContractId: 'other-device-id',
+							callId: 'from-signal'
+						}
+					]
+				}
+			});
+			await Promise.resolve();
+			expect(answerSpy).not.toHaveBeenCalled();
+			answerSpy.mockRestore();
+		});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/MediaSessionInstance.test.ts` around lines 199 - 350,
Add a negative test that ensures mediaSessionInstance.answerCall is NOT called
when the incoming notification's signedContractId does not match the local
device id: spy on mediaSessionInstance.answerCall, mock getUniqueIdSync to
return a different id than the notification.signedContractId, set up call store
state (using mockUseCallStoreGetState) with nativeAcceptedCallId matching the
signal, init mediaSessionInstance, invoke getStreamNotifyHandler() with an
'accepted' notification whose signedContractId != getUniqueIdSync(), await the
microtask, and assert answerCall was not called; restore all spies/mocks
afterwards.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/lib/services/voip/MediaSessionInstance.test.ts`:
- Around line 101-109: The current getStreamNotifyHandler helper picks the last
registered onStreamData handler which is brittle; update getStreamNotifyHandler
to scan mockOnStreamData.mock.calls (typed as [string, (m: IDDPMessage) =>
void][]) and find the call whose first element equals the event name
"stream-notify-user", then extract and return its handler (throw if not found or
not a function) so the test resolves the correct handler by event name instead
of the last registration.

---

Nitpick comments:
In `@app/lib/services/voip/MediaSessionInstance.test.ts`:
- Around line 199-350: Add a negative test that ensures
mediaSessionInstance.answerCall is NOT called when the incoming notification's
signedContractId does not match the local device id: spy on
mediaSessionInstance.answerCall, mock getUniqueIdSync to return a different id
than the notification.signedContractId, set up call store state (using
mockUseCallStoreGetState) with nativeAcceptedCallId matching the signal, init
mediaSessionInstance, invoke getStreamNotifyHandler() with an 'accepted'
notification whose signedContractId != getUniqueIdSync(), await the microtask,
and assert answerCall was not called; restore all spies/mocks afterwards.
🪄 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: da339f73-b3f8-4eef-9101-f6ee07c12a35

📥 Commits

Reviewing files that changed from the base of the PR and between 4d65864 and 1ba1a12.

📒 Files selected for processing (1)
  • app/lib/services/voip/MediaSessionInstance.test.ts
📜 Review details
🔇 Additional comments (1)
app/lib/services/voip/MediaSessionInstance.test.ts (1)

68-99: Nice hardening of MediaSignalingSession test doubles.

Tracking createdSessions and making sessionId non-writable improves assertion quality and keeps the mock behavior closer to real session semantics.

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

@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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/lib/services/voip/useCallStore.ts`:
- Around line 44-49: The timer callback reads the mutable module variable
staleNativeScheduledId at execution time which can race with re-arming; when
scheduling the timeout, capture the current id into a local const (e.g.
scheduledId) and close over that value inside the setTimeout callback instead of
reading staleNativeScheduledId there, then use that captured scheduledId when
calling clearStaleNativeIfStillUnbound(get, scheduledId) and when nulling the
module variable to avoid acting on a re-armed id; update the code around
staleNativeTimer, staleNativeScheduledId, and clearStaleNativeIfStillUnbound to
use the captured scheduledId in the closure.
🪄 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: 7254e343-b0f3-47a9-bd34-26f6b5b53581

📥 Commits

Reviewing files that changed from the base of the PR and between 1ba1a12 and ff24c4c.

📒 Files selected for processing (3)
  • app/containers/MediaCallHeader/MediaCallHeader.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/containers/MediaCallHeader/MediaCallHeader.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
📜 Review details
🔇 Additional comments (1)
app/lib/services/voip/useCallStore.ts (1)

265-276: Preserving nativeAcceptedCallId across reset() looks right.

Keeping the sticky native id while clearing the transient call UI, then re-arming the stale timer, matches the new accept-success contract cleanly.

Comment thread app/lib/services/voip/useCallStore.ts Outdated

@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.

♻️ Duplicate comments (1)
app/lib/services/voip/useCallStore.ts (1)

44-49: ⚠️ Potential issue | 🟠 Major

Still capture scheduledId inside this timeout.

Line 46 still reads the mutable staleNativeScheduledId at fire time. If an older timeout runs after a re-arm, it can clear the newer native-accepted id instead of the one it was scheduled for. Close over scheduledId and only null the module slot when it still matches that captured value; please keep a fake-timer re-arm regression with the fix.

Suggested fix
 	staleNativeScheduledId = scheduledId;
 	staleNativeTimer = setTimeout(() => {
 		staleNativeTimer = null;
-		const scheduled = staleNativeScheduledId;
-		staleNativeScheduledId = null;
-		if (scheduled != null) {
-			clearStaleNativeIfStillUnbound(get, scheduled);
-		}
+		if (staleNativeScheduledId === scheduledId) {
+			staleNativeScheduledId = null;
+		}
+		clearStaleNativeIfStillUnbound(get, scheduledId);
 	}, STALE_NATIVE_MS);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/useCallStore.ts` around lines 44 - 49, The timeout
callback reads the mutable module variable staleNativeScheduledId at fire time
which can allow an older timer to clear a newer scheduled id; to fix, capture
the current scheduled id into a local (e.g., scheduledId) before creating the
timeout and close over that captured value inside the setTimeout callback, then
only null staleNativeScheduledId if it still equals the captured scheduledId and
call clearStaleNativeIfStillUnbound(get, capturedScheduledId) (keeping use of
get); ensure the change preserves the fake-timer re-arm regression test
behavior.
🧹 Nitpick comments (1)
app/lib/services/voip/MediaSessionInstance.test.ts (1)

200-316: Add explicit mismatch cases for the new gate.

This suite covers the happy path, null, and “call already bound”, but it still won't fail if the implementation stops checking signedContractId or nativeAcceptedCallId !== signal.callId. A negative test for each mismatch would lock down the contract this PR is introducing.

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

In `@app/lib/services/voip/MediaSessionInstance.test.ts` around lines 200 - 316,
Add explicit negative tests to lock the new gating logic: add one test that sets
mockUseCallStoreGetState to return nativeAcceptedCallId that does NOT match the
incoming signal.callId and assert mediaSessionInstance.answerCall is not called,
and another test that sets nativeAcceptedCallId to match but the incoming
signal.signedContractId to a different value and assert answerCall is not
called; locate and extend the existing "stream-notify-user
(notification/accepted gated)" suite using the same pattern as the other specs
(use getStreamNotifyHandler(), mediaSessionInstance.init('user-1'), spy on
answerCall, set mockUseCallStoreGetState values for nativeAcceptedCallId/call
and craft the signal args with mismatched signedContractId or callId) so the
tests fail if signedContractId or nativeAcceptedCallId checks are removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/lib/services/voip/useCallStore.ts`:
- Around line 44-49: The timeout callback reads the mutable module variable
staleNativeScheduledId at fire time which can allow an older timer to clear a
newer scheduled id; to fix, capture the current scheduled id into a local (e.g.,
scheduledId) before creating the timeout and close over that captured value
inside the setTimeout callback, then only null staleNativeScheduledId if it
still equals the captured scheduledId and call
clearStaleNativeIfStillUnbound(get, capturedScheduledId) (keeping use of get);
ensure the change preserves the fake-timer re-arm regression test behavior.

---

Nitpick comments:
In `@app/lib/services/voip/MediaSessionInstance.test.ts`:
- Around line 200-316: Add explicit negative tests to lock the new gating logic:
add one test that sets mockUseCallStoreGetState to return nativeAcceptedCallId
that does NOT match the incoming signal.callId and assert
mediaSessionInstance.answerCall is not called, and another test that sets
nativeAcceptedCallId to match but the incoming signal.signedContractId to a
different value and assert answerCall is not called; locate and extend the
existing "stream-notify-user (notification/accepted gated)" suite using the same
pattern as the other specs (use getStreamNotifyHandler(),
mediaSessionInstance.init('user-1'), spy on answerCall, set
mockUseCallStoreGetState values for nativeAcceptedCallId/call and craft the
signal args with mismatched signedContractId or callId) so the tests fail if
signedContractId or nativeAcceptedCallId checks are removed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 985ef5b8-6fa5-4684-97e8-be591c4abbf3

📥 Commits

Reviewing files that changed from the base of the PR and between ff24c4c and 38ab95f.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (3)
  • app/containers/MediaCallHeader/MediaCallHeader.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/containers/MediaCallHeader/MediaCallHeader.test.tsx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: ESLint and Test / run-eslint-and-test
  • GitHub Check: format

@diegolmello diegolmello force-pushed the feat.answer-registered branch from 38ab95f to 9619d5c Compare March 27, 2026 17:10
@diegolmello diegolmello had a problem deploying to official_android_build March 27, 2026 17:13 — with GitHub Actions Failure
@diegolmello diegolmello had a problem deploying to experimental_ios_build March 27, 2026 17:13 — with GitHub Actions Failure
@diegolmello diegolmello had a problem deploying to experimental_android_build March 27, 2026 17:13 — with GitHub Actions Failure

@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: 1

🧹 Nitpick comments (2)
app/lib/services/voip/useCallStore.ts (1)

248-262: Extract the native teardown cleanup into one shared path.

This store action now owns the callId ?? nativeAcceptedCallId resolution, but app/lib/services/voip/MediaSessionInstance.ts and app/sagas/deepLinking.js still inline their own RNCallKeep.endCall(...) + reset sequence. Pulling that common cleanup into a shared helper will keep the UUID fallback and native-id cleanup aligned.

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

In `@app/lib/services/voip/useCallStore.ts` around lines 248 - 262, The native
teardown (resolving callId ?? nativeAcceptedCallId, calling RNCallKeep.endCall,
and resetting native call id) should be moved into a single exported helper and
used by all callers; create a function (e.g., export function
endNativeCallCleanup(callId?: string, nativeAcceptedCallId?: string) or as a
method on the call store like endNativeCall) that performs the UUID resolution,
calls RNCallKeep.endCall(uuid) if present, and invokes resetNativeCallId(), then
update useCallStore.endCall to call that helper and replace the inline
RNCallKeep.endCall + resetNativeCallId sequences in MediaSessionInstance.ts and
app/sagas/deepLinking.js to call the new shared helper instead.
app/lib/services/voip/MediaSessionInstance.test.ts (1)

200-316: Add negative coverage for the other accept guards.

These tests cover the happy path, nativeAcceptedCallId === null, and call != null, but they never assert that mismatched signal.callId or mismatched signedContractId stay blocked. A regression to nativeAcceptedCallId != null or to ignoring the device contract would still pass this suite.

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

In `@app/lib/services/voip/MediaSessionInstance.test.ts` around lines 200 - 316,
Add two negative tests to cover the remaining guards: create tests using
mediaSessionInstance.init('user-1') and getStreamNotifyHandler() that (1) set
mockUseCallStoreGetState to return nativeAcceptedCallId = 'X' but send a signal
with callId = 'Y' and assert mediaSessionInstance.answerCall is NOT called, and
(2) set nativeAcceptedCallId to match but send a signal whose signedContractId
!= 'test-device-id' and assert answerCall is NOT called; use the same
spying/restore pattern on answerCall and the existing mock call store shape so
the new tests mirror the existing ones (referencing mediaSessionInstance,
getStreamNotifyHandler, mockUseCallStoreGetState, nativeAcceptedCallId,
signedContractId, callId).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/lib/services/voip/useCallStore.ts`:
- Around line 114-118: The setter setNativeAcceptedCallId currently persists
empty strings before starting timers, causing a sticky nativeAcceptedCallId that
survives reset; change setNativeAcceptedCallId to normalize the incoming callId
(treat '' as null/undefined or omit setting) before calling set, so that
cancelStaleNativeTimer(), set({ nativeAcceptedCallId: ... }), and
createStaleNativeTimer(get) only operate on valid IDs; reference
setNativeAcceptedCallId, cancelStaleNativeTimer, createStaleNativeTimer, reset,
and nativeAcceptedCallId when locating where to normalize the value.

---

Nitpick comments:
In `@app/lib/services/voip/MediaSessionInstance.test.ts`:
- Around line 200-316: Add two negative tests to cover the remaining guards:
create tests using mediaSessionInstance.init('user-1') and
getStreamNotifyHandler() that (1) set mockUseCallStoreGetState to return
nativeAcceptedCallId = 'X' but send a signal with callId = 'Y' and assert
mediaSessionInstance.answerCall is NOT called, and (2) set nativeAcceptedCallId
to match but send a signal whose signedContractId != 'test-device-id' and assert
answerCall is NOT called; use the same spying/restore pattern on answerCall and
the existing mock call store shape so the new tests mirror the existing ones
(referencing mediaSessionInstance, getStreamNotifyHandler,
mockUseCallStoreGetState, nativeAcceptedCallId, signedContractId, callId).

In `@app/lib/services/voip/useCallStore.ts`:
- Around line 248-262: The native teardown (resolving callId ??
nativeAcceptedCallId, calling RNCallKeep.endCall, and resetting native call id)
should be moved into a single exported helper and used by all callers; create a
function (e.g., export function endNativeCallCleanup(callId?: string,
nativeAcceptedCallId?: string) or as a method on the call store like
endNativeCall) that performs the UUID resolution, calls RNCallKeep.endCall(uuid)
if present, and invokes resetNativeCallId(), then update useCallStore.endCall to
call that helper and replace the inline RNCallKeep.endCall + resetNativeCallId
sequences in MediaSessionInstance.ts and app/sagas/deepLinking.js to call the
new shared helper instead.
🪄 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: 3c736b66-51a8-4236-bacf-27fa930d2cb9

📥 Commits

Reviewing files that changed from the base of the PR and between 38ab95f and 9619d5c.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (3)
  • app/containers/MediaCallHeader/MediaCallHeader.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/containers/MediaCallHeader/MediaCallHeader.test.tsx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: ESLint and Test / run-eslint-and-test
  • GitHub Check: format
🔇 Additional comments (2)
app/lib/services/voip/useCallStore.ts (1)

37-51: Captured scheduledId in the timeout closure is the right fix.

The callback now only clears state for the timer that actually fired, so re-arming cannot accidentally act on a newer native id.

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

102-111: Handler lookup is much less brittle now.

Resolving stream-notify-user by event name instead of “last registration wins” will keep these tests stable if other listeners are added later.

Comment thread app/lib/services/voip/useCallStore.ts
@diegolmello diegolmello merged commit 81dba28 into feat.voip-lib-new Mar 27, 2026
6 of 11 checks passed
@diegolmello diegolmello deleted the feat.answer-registered branch March 27, 2026 17:36
diegolmello added a commit that referenced this pull request Apr 22, 2026
…/Decline (#7215)

* merge feat.voip-lib

* feat(voip): enhance call handling with UUID mapping and event listeners

* Base call UI

* feat(voip): integrate Zustand for call state management and enhance CallView UI

* feat(voip): add simulateCall function for mock call handling in UI development

* refactor(CallView): update button handlers and improve UI responsiveness

* Add pause-shape-unfilled icon

* Base CallHeader

* toggleFocus

* collapse buttons

* Header components

* Hide header when no call

* Timer

* Add use memo

* Add voice call item on sidebar

* cleanup

* Temp use @rocket.chat/media-signaling from .tgz

* cleanup

* Check module and permissions to enable voip

* Refactor stop method to use optional chaining for media signal listeners

* voip push first test

* Add VoIP call handling with pending call management

- Implemented VoIP push notification handling in index.js, including storing call info for later processing.
- Added CallKeep event handlers for answering and ending calls from a cold start.
- Introduced a new CallIdUUID module to convert call IDs to deterministic UUIDs for compatibility with CallKit.
- Created a pending call store to manage incoming calls when the app is not fully initialized.
- Updated deep linking actions to include VoIP call handling.
- Enhanced MediaSessionInstance to process pending calls and manage call states effectively.

* Remove pending store and create getInitialEvents on app/index

* Attempt to make iOS calls work from cold state

* lint and format

* Patch callkeep ios

* Temp send iOS voip push token on gcm

* Temp fix require cycle

* chore: format code and fix lint issues [skip ci]

* CallIDUUID module on android and voip push

* Add setCallUUID on useCallStore to persist calls accepted on native Android

* remove callkeep from notification

* Android Incoming Call UI POC

* Refactor VoIP handling: Migrate VoIP-related classes to a new package structure, removing deprecated modules and consolidating functionality. Update imports in MainApplication and NotificationIntentHandler to reflect changes. This cleanup enhances code organization and prepares for future VoIP feature enhancements.

* Remove VoipForegroundService

* cleanup and use caller instead of callerName

* Cleanup and make iOS build again

* Refactor VoIP handling: Remove unused event emissions for call answered and declined, switch from SharedPreferences to in-memory storage for pending VoIP call data, and update method signatures for better clarity. This cleanup enhances performance and prepares for future VoIP feature improvements.

* Refactor VoIP handling: Introduce a new VoipPayload class to encapsulate call data, streamline notification processing, and enhance method signatures across the VoIP module. This update improves code clarity and prepares for future feature enhancements.

* Migrate react-native-voip-push-notifications to VoipModule

* Refactor VoIP module: Update package structure by moving VoipTurboPackage to the main package and removing the obsolete NativeVoipSpec class. Adjust imports in MainApplication and VoipModule to reflect these changes, enhancing code organization and maintainability.

* Unify emitters

* Move CallKeep listeners from MediaSessionInstance to getInitialEvents

* Clear callkeep on endcall

* Unify getInitialEvents logic

* getInitialEvents -> MediaCallEvents

* chore: format code and fix lint issues [skip ci]

* feat(Android): Add full screen incoming call (#6977)

* feat: Update call UI (#6990)

* feat: Handle audio routing, e.g., Bluetooth headset vs. internal speaker switching (#6992)

* fix: empty space when not on call (#6993)

* feat: Dialpad (#7000)

* action: organized translations

* feat: start call (#7024)

* chore: format code and fix lint issues

* feat: Pre flight (#7038)

* action: organized translations

* feat: Receive voip push notifications from backend (#7045)

* feat: Refactor media session handling and improve disconnect logic (#7065)

* feat: Control incoming call from native (#7066)

* feat: Voice message blocks (#7057)

* feat: native accept success event (#7068)

* feat(voip): call waiting, busy detection, and videoconf blocking (#7077)

* action: organized translations

* feat(voip): tap-to-hide call controls with animations (#7078)

* feat(voip): navigate to call DM from message button and header (#7082)

* feat(voip): tablet and landscape layout (#7110)

* chore: develop into feat.voip-lib-new (RN 81 + Expo 54 + reanimated 4 + true-sheet + iOS 26) (#7114)

* chore: format code and fix lint issues

* feat(voip): android landscape layout for IncomingCallActivity (#7116)

* Update agents files

* feat(voip): Support a11y (#7106)

* Fix content cutting on iOS on some edge cases

* pods

* Ignore .worktrees on jest

* chore: Merge develop into feat.voip-lib-new (#7129)

* fix(voip): show CallKit UI when call is active in background (#7128)

* chore: Update media-signaling to 0.2.0 (#7153)

* feat(voip): migrate iOS accept/reject from DDP to REST (#7124)

* Fix icons

* feat(voip): migrate Android accept/reject from DDP to REST (#7127)

* test(voip): integration tests for CallView pipeline (#7161)

* feat(voip): display video conf provider as subtitle (#7160)

* fix(voip): CallView button grid and correct landscape/dialpad layouts (#7164)

* fix(voip): prevent stale MMKV cache on Android first-install accept

MMKVKeyManager.initialize ran in MainApplication.onCreate before the JS
engine started and opened the default MMKV file via the Tencent 1.2 JAR
when it was still empty. Tencent caches instances per-ID in a singleton
registry, so that empty-state view was held for the rest of the process.
JS later wrote credentials through react-native-mmkv (MMKV Core 2.0),
which has its own separate registry. When a VoIP push arrived,
Ejson.getMMKV() got the cached empty Tencent instance and reported
"No userId found in MMKV for server". Closing and reopening the app
cleared the cache, which is why only the very first call after install
failed.

Drop the open/verify block — the encryption key is already cached from
SecureKeystore, so no MMKV handle is needed here. The first Tencent
instance is now created inside Ejson.getMMKV() after JS has written,
so it scans the file fresh.

* fix(voip): prevent duplicate ringtone on Android incoming call (#7158)

* fix(voip): set explicit snaps for NewMediaCall bottom sheet (#7165)

* Update app/lib/services/voip/MediaSessionStore.ts

Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>

* fix: make startVoipFork reactive to permissions-changed (#7151)

* fix(android): remove MediaProjectionService from merged manifest (#7190)

* fix(voip): Phone account creation (#7170)

* feat: add Enable Mobile Ringing toggle in user preferences (#7155)

* fix(voip): ship blockers for PushKit, licensing, outbound calls, push tokens (#7167)

* fix(android): Play Store mic discoverability, safer FCM logs, avatar auth via headers (#7171)

* fix(ios): serialize VoipService bridge statics (#7169)

* fix(voip): Android DDP thread safety and VoipPayload bundle parity (#7168)

* chore(voip): dead-code and hygiene sweep (#7174)

* refactor(voip): decouple navigateToCallRoom from Redux and backfill REST/connect tests (#7176)

* test(voip): tighten ringing endCall assertion and add VideoConf VoIP-lock saga coverage (#7177)

* fix(ios): harden VoIP DDP WebSocket client on receive failures and TLS (#7173)

* refactor(voip): MediaCallEvents Redux adapters and resetVoipState (#7178)

* refactor(voip): decouple peer autocomplete from Redux; simplify NewMediaCall (#7175)

* fix(ios): add NS_SWIFT_NAME to Challenge.runChallenge for Swift 6.2 compatibility

Swift 6.2 (Xcode 26.x / macos-26 runner) auto-renames the Objective-C
method runChallenge:didReceiveChallenge:completionHandler: to
run(_:didReceive:completionHandler:) when imported into Swift.

Add NS_SWIFT_NAME to explicitly pin the Swift import name, preventing
the compiler from applying its heuristics. This keeps the existing
Swift call site in DDPClient.swift working without changes.

* fix(ios): cancel old URLSession/webSocketTask before reconnecting in DDPClient.connect (#7197)

* fix(ios): add NSLock to nativeAcceptHandledCallIds and 10s REST timeout to handleNativeAccept (#7198)

* feat(android): create VoipCallService with FOREGROUND_SERVICE_MICROPHONE (#7199)

* fix(android): start VoipCallService on accept, stop on hangup/timeout, install end-call listener (#7200)

* fix(voip): enable DM nav for users with SIP extension (#7203)

* fix(android): handle null VoiceConnection in answerIncomingCall, notify JS (#7201)

* fix(voip): resolve closure capture ordering in handleNativeAccept (#7209)

* fix(android): integrate VoIP modules with SSL-pinned OkHttpClient (#7208)

* fix(push): gate id and voipToken behind server version checks, fix VideoConf caller extra (#7210)

* fix(voip): remove sensitive data from production logs (#7207)

* fix(android): remove isRunning guard + add double-tap guard on Accept/Decline

- VoipCallService: remove if (!isRunning) guard, call startForeground unconditionally
  (idempotent on Android, fixes Android 14+ foreground service requirement)
- IncomingCallActivity: add AtomicBoolean guard on handleAccept/handleDecline
  to prevent double-tap from triggering multiple service starts

---------

Co-authored-by: diegolmello <diegolmello@users.noreply.github.com>
Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>
@coderabbitai coderabbitai Bot mentioned this pull request Apr 24, 2026
3 tasks
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