Skip to content

feat: Control incoming call from native#7066

Merged
diegolmello merged 11 commits intofeat.voip-lib-newfrom
feat.voip-registered-event
Mar 25, 2026
Merged

feat: Control incoming call from native#7066
diegolmello merged 11 commits intofeat.voip-lib-newfrom
feat.voip-registered-event

Conversation

@diegolmello
Copy link
Member

@diegolmello diegolmello commented Mar 24, 2026

Proposed changes

  • Native VoIP (iOS/Android): Stop the native DDP client after the accept attempt completes (success or failure); stash accept-failure payloads for JS; emit VoipAcceptFailed when the React instance is active; clear native accept dedupe on failure and other terminal paths (timeout, DDP hangup, CallKit ended before accept, etc.).
  • iOS: VoipService native accept over DDP (parity with Android); NotificationCenterVoipModule for VoipAcceptFailed; VoipPayload supports voipAcceptFailed.
  • Android: VoipNotification accept completion refactored (storeAcceptFailureForJs, heads-up path uses prepareMainActivityForIncomingVoip(..., storePayloadForJs = false)); VoipModule / VoipPayload support failure stash + event.
  • JS: Remove VOIP_CALL / voipCallOpen; use deepLinkingOpen with optional voipAcceptFailed; extend handleOpen with completeDeepLinkNavigation and inline handleVoipAcceptFailed (reset store, RNCallKeep.endCall, appStart(ROOT_INSIDE), reuse navigate with direct/${username} when username is set, then showToast for VoIP_Call_Issue — toast vs dialog per accessibility preference).
  • MediaCallEvents: VoipAcceptFailed listeners, guard on Android initial events so failed accepts never call answerCall, dedupe by callId; cold-start handling via getInitialEvents with voipAcceptFailed.
  • MediaSessionInstance: Remove stopNativeDDPClient on registered and related dead code.
  • i18n (en only): Single key VoIP_Call_Issue for failure copy.
  • P2: AppDelegate+Voip fileprivate log tag.

Issue(s)

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

How to test or reproduce

Incoming VoIP (happy path)

  1. Cold start after answer — Kill app → incoming VoIP → answer from system UI → app opens. Expect correct server connection and call continuation; no erroneous “call issue” toast.
  2. Background → answer — App backgrounded → answer from CallKit / Telecom / notification. Same expectations; on iOS confirm media/session still attaches (warm path).
  3. Foreground → answer — App open on workspace → incoming → answer. Call accepted and call UI as designed.
  4. Android heads-up Accept — Incoming heads-up → Accept. Accept completes without bad duplicate stash behavior.

Native accept failure → in-app recovery

  1. Failure with username — Force native DDP accept failure (e.g. network/credentials) with valid host + username. Expect system call UI ended; inside app root; navigation via direct/<username> (may create/open DM per canOpenRoom); then toast or single-string dialog if accessibility alert type is DIALOG.
  2. Failure without username — Same but no username. Expect inside root and toast/dialog only (no forced DM path).
  3. Same-server deep link — Already on host → open with voipAcceptFailed. Expect failure handler + toast, no stuck splash.
  4. Other server + loginvoipAcceptFailed after server switch + LOGIN.SUCCESS. Expect failure handler after auth, not stuck outside.
  5. getServerInfo failsvoipAcceptFailed with host that fails server info. Expect failure handler (not only silent fallback).
  6. No hostvoipAcceptFailed without host. Expect handler runs without crash; toast shown.
  7. New server, no token — Path that would hit invite-only. Expect handleVoipAcceptFailed, not invite-only stuck state.
  8. Dedupe — Failure both stashed and emitted for same callId. Expect a single user-visible failure flow (one toast / one navigation burst).

Native → JS signaling

  1. Cold start — Kill app → accept failure → reopen. Initial native payload with voipAcceptFailed consumed; deepLinkingOpen + recovery + toast.
  2. JS running — Failure while app active. VoipAcceptFailed (iOS/Android) handled; no answerCall on failure payloads on Android initial-events path.

Deep linking

  1. Normal incoming — Only DEEP_LINKING.OPEN / deepLinkingOpen with host + callId (no voipAcceptFailed). No dependency on removed VOIP_CALL.
  2. Failure flagvoipAcceptFailed: true routes through completeDeepLinkNavigationhandleVoipAcceptFailed.

Accessibility

  1. TOAST vs DIALOG — With failure flow, verify Toast mode shows in-app toast; DIALOG mode shows Alert with the same VoIP_Call_Issue string.

Native DDP / dedupe (spot-check)

  1. Accept success — Native DDP stops after successful native accept.
  2. Accept failure — DDP stopped; iOS dedupe cleared for that call id where applicable.
  3. Timeout / remote cancel before accept — System call UI ends; no obvious stuck state for a second incoming call.

Not in scope for this PR: mid-call abnormal drop (future work).

Automated

  • yarn test

Screenshots

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

Summary by CodeRabbit

  • New Features

    • Accept VoIP calls from heads‑up notifications with improved accept flow and optional direct app resume.
    • App surfaces native VoIP accept failures and routes users to the relevant call/user flow, showing a friendly toast.
  • Bug Fixes

    • Prevent duplicate accept signals and reduce duplicate/erroneous incoming-call handling across platforms.
    • More robust media-call matching and logging.
  • Chores

    • Updated media-signaling package and internal submodule pointer.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 24, 2026

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

Native VoIP accept flow was refactored across Android and iOS: accepts are deduplicated, heads‑up accepts routed via VoipNotification, native accept failures emit a VoipAcceptFailed event to JS, and JS deep‑linking and media‑signaling were updated to handle voipAcceptFailed. Device ID calls moved to sync API; media‑signaling package bumped.

Changes

Cohort / File(s) Summary
Submodule
./.cursor/skills/agent-skills
Updated Git submodule pointer to commit a4f602ffb4aeaf4199fa97b7162f9c9d1f655904.
Android: notification & intent handling
android/.../notification/NotificationIntentHandler.kt, android/.../voip/VoipNotification.kt
Moved MainActivity VoIP intent handling to VoipNotification.handleMainActivityVoipIntent; added ACTION_VOIP_ACCEPT_HEADS_UP, handleAcceptAction, rewritten accept/reject DDP param builders, identity resolution, queuing/flush rename, and updated PendingIntent creation for Android‑12+.
Android: activity/module/payload
android/.../voip/IncomingCallActivity.kt, android/.../voip/VoipModule.kt, android/.../voip/VoipPayload.kt
Stopped synchronous MainActivity launch on accept; delegate to VoipNotification.handleAcceptAction; added EVENT_VOIP_ACCEPT_FAILED and storeAcceptFailureForJs(...); added voipAcceptFailed flag to VoipPayload and propagated in serialization/deserialization.
Android: trivial formatting
android/.../MainActivity.kt
Whitespace normalization and ensured trailing newline.
iOS: CallKit & native VoIP
ios/Libraries/VoipService.swift, ios/Libraries/VoipModule.mm, ios/Libraries/VoipPayload.swift, ios/Libraries/AppDelegate+Voip.swift
Added per‑call dedupe for native accept, unified accept/reject param builder, implemented native accept handling that queues or sends DDP accept and posts VoipAcceptFailed on failure; added VoipAcceptFailed event bridging to JS; exposed voipAcceptFailed in payloads; improved logging.
JS: media signaling & device id
app/lib/services/voip/MediaSessionInstance.ts, app/lib/services/voip/MediaSessionStore.ts, app/lib/services/voip/MediaSessionInstance.test.ts
Switched to getUniqueIdSync() for device identity and tests; updated accepted‑notification matching to target local device; removed stop‑DDP in init, added logging, removed some auto‑answer behavior, and passed mobileDeviceId into MediaSignalingSession.
JS: initial events & deep‑linking
app/lib/services/voip/MediaCallEvents.ts, app/actions/deepLinking.ts, app/actions/actionsTypes.ts, app/definitions/Voip.ts, app/sagas/deepLinking.js, app/index.tsx
Removed voipCallOpen action and DEEP_LINKING.VOIP_CALL; extended deepLinkingOpen / IParams to accept callId, username, voipAcceptFailed; added handleVoipAcceptFailed flow (reset call state, end CallKit call, navigate, show VoIP_Call_Issue toast); updated initial event handling to detect voipAcceptFailed.
i18n
app/i18n/locales/en.json
Added VoIP_Call_Issue translation string.
Dependency
package.json
Bumped @rocket.chat/media-signaling from file:./packages/rocket.chat-media-signaling-0.1.1.tgzfile:./packages/rocket.chat-media-signaling-0.1.3.tgz.

Sequence Diagram

sequenceDiagram
    participant CallKit as CallKit/Telecom
    participant Native as Native VoIP Module
    participant Notif as VoipNotification / VoipService
    participant DDP as DDP Connection
    participant JS as JS (MediaSession / DeepLinking)
    participant UI as VoIP UI

    Note over CallKit,UI: User or heads‑up accept triggers native accept

    CallKit->>Native: Answer/Accept event
    Native->>Notif: handleNativeAccept(payload)
    Notif->>Notif: cancel timeouts & dedupe (per-call)
    Notif->>Notif: resolve identity (userId, deviceId)
    Notif->>DDP: send or queue "stream-notify-user" accept signal
    DDP-->>Notif: ack or queued
    Notif->>Native: onAnswer / VoiceConnection.onAnswer()
    Notif->>UI: dismiss VoIP UI (ACTION_DISMISS)
    alt DDP accept failure
        Notif->>JS: emit VoipAcceptFailed (initial events with voipAcceptFailed)
        JS->>JS: handleVoipAcceptFailed -> reset state, end CallKit call, navigate, show toast
    else DDP accept success
        Notif->>JS: emit initial events for JS recovery
        JS->>JS: normal incoming‑call flow / navigation
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 30.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: Control incoming call from native' clearly and concisely summarizes the main purpose of the PR: centralizing native-to-JS handling for incoming VoIP calls, which is the primary focus across all file changes.
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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 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/VoipNotification.kt`:
- Around line 171-178: The heads-up accept path causes duplicate JS emissions
because VoipModule.storeInitialEvents(payload) is invoked in
prepareMainActivityForIncomingVoip and again in handleAcceptAction's finish
callback; fix by ensuring storeInitialEvents is called only once—either remove
the call from prepareMainActivityForIncomingVoip when invoked from the heads-up
branch, or add a boolean flag to handleAcceptAction (e.g.,
skipStoreInitialEvents) and propagate it to the finish() callback so callers
(the heads-up branch) can request skipping the second store; update
handleAcceptAction's signature and all callers (including IncomingCallActivity
accept path) accordingly so behavior remains unchanged for non-heads-up flows.

In `@app/lib/services/voip/MediaCallEvents.ts`:
- Around line 35-44: Re-enable the RNCallKeep 'answerCall' listener in
MediaCallEvents.ts so the JS fallback still invokes
mediaSessionInstance.answerCall(callUUID) when native accept fails; restore the
RNCallKeep.addEventListener('answerCall', ({ callUUID }) => {
mediaSessionInstance.answerCall(callUUID);
NativeVoipModule.clearInitialEvents(); RNCallKeep.clearInitialEvents(); })
subscription and ensure it is added to subscriptions (so it can be cleaned up),
keeping existing duplicate-avoidance logic that prevents double-answering when
VoipService.handleNativeAccept(...) succeeds.

In `@app/lib/services/voip/MediaSessionInstance.ts`:
- Around line 74-78: Don't call NativeVoipModule.stopNativeDDPClient()
immediately in the this.instance?.on('registered', ...) handler because it can
clear queued native accepts; instead defer stopping the native DDP client until
queued accepts are flushed. Update the 'registered' callback in
MediaSessionInstance.ts to only call stopNativeDDPClient after verifying there
are no pending native accepts (e.g. by calling a new or existing
NativeVoipModule.hasQueuedAccepts()/flushQueuedAccepts() or by listening for a
NativeVoipModule 'queuedAcceptsFlushed' event), or after a safe confirmation
(timeout + check of activeCalls) so pending accept calls are not dropped. Ensure
references to NativeVoipModule.stopNativeDDPClient and the 'registered' handler
are where this logic is implemented.

In `@app/lib/services/voip/MediaSessionStore.ts`:
- Around line 56-58: The code logs a persistent device identifier
(mobileDeviceId) obtained via getUniqueIdSync in MediaSessionStore.ts which
leaks a cross-session tracking ID; remove the console.log that prints
mobileDeviceId and ensure no other code paths output this value (search for
getUniqueIdSync and mobileDeviceId in MediaSessionStore and related functions),
and if debugging is needed replace with a non-identifying marker (e.g.,
hashed/truncated or a boolean) emitted via a secure logger only when explicitly
enabled.

In `@ios/Libraries/VoipService.swift`:
- Around line 414-417: The code inserts payload.callId into
nativeAcceptHandledCallIds before doing network work, but never removes it on
failure, which prevents retries; update the accept flow (where
nativeAcceptHandledCallIds.insert(payload.callId) is used) to remove the callId
from nativeAcceptHandledCallIds on every failure path (e.g., missing client,
parameter build failure, unsuccessful callback/response) before returning or
throwing, and ensure this cleanup runs in all early-return branches in the
accept handling block (also apply the same removal to the other accept block
around lines 421-454) so the dedupe guard is only retained on successful
completion.
🪄 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: c5532036-b541-4919-8c00-f3387ddbfc37

📥 Commits

Reviewing files that changed from the base of the PR and between 306f8cc and 9fa5b37.

⛔ Files ignored due to path filters (1)
  • yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (14)
  • .cursor/skills/agent-skills
  • android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt
  • android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionStore.ts
  • ios/Libraries/AppDelegate+Voip.swift
  • ios/Libraries/VoipService.swift
  • package.json
  • packages/rocket.chat-media-signaling-0.1.1.tgz
  • packages/rocket.chat-media-signaling-0.1.3.tgz
📜 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 context used
🧠 Learnings (1)
📚 Learning: 2026-02-05T13:55:00.974Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6930
File: package.json:101-101
Timestamp: 2026-02-05T13:55:00.974Z
Learning: In this repository, the dependency on react-native-image-crop-picker should reference the RocketChat fork (RocketChat/react-native-image-crop-picker) with explicit commit pins, not the upstream ivpusic/react-native-image-crop-picker. Update package.json dependencies (and any lockfile) to point to the fork URL and a specific commit, ensuring edge-to-edge Android fixes are included. This pattern should apply to all package.json files in the repo that declare this dependency.

Applied to files:

  • package.json
🔇 Additional comments (12)
.cursor/skills/agent-skills (1)

1-1: Verify this submodule update is intentional.

This submodule pointer update appears unrelated to the VoIP native accept feature described in the PR objectives. Please confirm whether this change should be included in this PR or if it was accidentally staged.

android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt (1)

280-287: LGTM!

The refactoring to delegate accept handling to VoipNotification.handleAcceptAction with async completion via ACTION_DISMISS broadcast is well-structured. The activity correctly waits for the broadcast before finishing, and the receiver is properly registered/unregistered in lifecycle methods.

android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt (1)

24-27: LGTM!

Clean refactoring that consolidates VoIP intent handling in VoipNotification. The delegation pattern with early return maintains clear control flow.

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

53-71: LGTM!

The new constants and data structures are well-documented. The KDoc on ACTION_VOIP_ACCEPT_HEADS_UP clearly explains the Android 12+ trampoline restriction, and SUPPORTED_VOIP_FEATURES has a helpful sync reminder comment.


207-263: LGTM!

The async accept handling with completion callback is well-structured:

  • Telecom call is only answered on DDP success
  • On failure, app opens for JS recovery (good fallback)
  • stopDDPClientInternal only called on success, preserving the client for potential JS retry

265-286: LGTM!

Both helpers are well-implemented:

  • launchMainActivityForVoip correctly adds FLAG_ACTIVITY_NEW_TASK when context is not an Activity
  • answerIncomingCall uses type-safe pattern matching with appropriate logging for edge cases

353-361: LGTM!

Good rename from flushPendingRejectSignalIfNeeded to flushPendingQueuedSignalsIfNeeded to reflect that it now handles both accept and reject signals.


363-408: LGTM!

The accept signal functions follow the same pattern as their reject counterparts, with the appropriate addition of completion callbacks needed for async accept handling.


433-460: LGTM!

The signal params are correctly structured:

  • Accept includes supportedFeatures: ["audio"] per PR requirements
  • Both use consistent identity resolution
  • Channel format "${ids.userId}/media-calls" aligns with the DDP stream pattern

540-544: LGTM!

Updated call site correctly reflects the renamed function.


750-774: LGTM!

Correct handling of Android 12+ notification trampoline restrictions by using PendingIntent.getActivity directly. The version-specific FLAG_IMMUTABLE handling is also correct.


410-431: The KDoc is accurate. The code correctly uses Settings.Secure.ANDROID_ID, which matches the implementation of getUniqueIdSync() from react-native-device-info—both retrieve the same Android system value via identical API calls.

@diegolmello diegolmello changed the title feat: Control call from native feat: Control incoming call from native Mar 25, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

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

67-69: Update stale comment to match behavior.

The comment says “accepted from another device,” but current condition handles same-device contract match and answers locally.

🤖 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 67 - 69, The
comment above the conditional in MediaSessionInstance.ts is outdated: update the
comment to reflect that when signal.type === 'notification', signal.notification
=== 'accepted' and signal.signedContractId === getUniqueIdSync(), the instance
should answer the call locally (same-device contract match) rather than handling
an acceptance from another device; locate the condition checking signal.type,
signal.notification, signal.signedContractId and getUniqueIdSync() and replace
the comment to state that the call is accepted on the same device and
answerCall(signal.callId) is invoked locally.
🤖 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.ts`:
- Line 65: The console.log in MediaSessionInstance that prints the entire signal
object (e.g., "console.log('🤙 [VoIP] Processed signal:', signal)") should be
replaced to avoid leaking sensitive VoIP payloads: change logging to only emit
minimal safe fields (for example signal.type, signal.callId, signal.state) and
wrap any verbose/full-payload logging behind a dev-only guard (e.g., if
(__DEV__) { /* verbose logging */ }) so production never logs the full object;
update every similar occurrence (including the other console.log of signal) in
the class to follow this pattern.
- Around line 68-69: The fire-and-forget call to this.answerCall(signal.callId)
inside MediaSessionInstance when signal.type==='notification' can produce
unhandled promise rejections if answerCall (or underlying mainCall.accept())
throws; update the code to handle the promise—either make the enclosing handler
async and await this.answerCall(...) inside a try/catch, or append a .catch(...)
to this.answerCall(...) that logs or handles the error appropriately (include
context like signal.callId and signal.signedContractId). Ensure the chosen
handler uses the MediaSessionInstance logger/error reporting so failures are
surfaced instead of becoming unhandled.

In `@app/sagas/deepLinking.js`:
- Around line 99-128: In handleVoipAcceptFailed remove the redundant yield
put(appStart({ root: RootEnum.ROOT_INSIDE })) since navigate({ params:
navigateParams }) already triggers appStart; locate the appStart call inside the
handleVoipAcceptFailed generator and delete it, leaving the navigate invocation
to perform the final appStart, and run/adjust any tests that assumed two
dispatches to confirm behavior remains correct.

In `@ios/Libraries/VoipService.swift`:
- Around line 423-427: The native dedupe set nativeAcceptHandledCallIds is never
cleared for successful accepts; update handleNativeAccept (and related accept
paths at the other instances) to remove payload.callId from
nativeAcceptHandledCallIds once the accept is confirmed successful—either after
the send completes successfully or in the hasConnected/hasEnded callbacks that
indicate a completed connection lifecycle—so that observedIncomingCall being
cleared earlier doesn't prevent cleanup; reference nativeAcceptHandledCallIds,
handleNativeAccept, observedIncomingCall, hasConnected, and hasEnded and ensure
the removal happens in the success/connected path as well as existing
failure/timeout paths.

---

Nitpick comments:
In `@app/lib/services/voip/MediaSessionInstance.ts`:
- Around line 67-69: The comment above the conditional in
MediaSessionInstance.ts is outdated: update the comment to reflect that when
signal.type === 'notification', signal.notification === 'accepted' and
signal.signedContractId === getUniqueIdSync(), the instance should answer the
call locally (same-device contract match) rather than handling an acceptance
from another device; locate the condition checking signal.type,
signal.notification, signal.signedContractId and getUniqueIdSync() and replace
the comment to state that the call is accepted on the same device and
answerCall(signal.callId) is invoked locally.
🪄 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: 6295391a-bb94-438d-bced-ceaf79c44962

📥 Commits

Reviewing files that changed from the base of the PR and between 9fa5b37 and 860638c.

📒 Files selected for processing (14)
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
  • app/actions/actionsTypes.ts
  • app/actions/deepLinking.ts
  • app/definitions/Voip.ts
  • app/i18n/locales/en.json
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/sagas/deepLinking.js
  • ios/Libraries/AppDelegate+Voip.swift
  • ios/Libraries/VoipModule.mm
  • ios/Libraries/VoipPayload.swift
  • ios/Libraries/VoipService.swift
✅ Files skipped from review due to trivial changes (3)
  • app/actions/actionsTypes.ts
  • app/definitions/Voip.ts
  • app/i18n/locales/en.json
📜 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 context used
🧠 Learnings (2)
📚 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/sagas/deepLinking.js
📚 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
🪛 SwiftLint (0.63.2)
ios/Libraries/AppDelegate+Voip.swift

[Warning] 3-3: Prefer private over fileprivate declarations

(private_over_fileprivate)

🔇 Additional comments (15)
app/lib/services/voip/MediaSessionInstance.ts (1)

73-75: Good change: registered handler no longer eagerly stops native DDP.

This avoids the previous cold-start race where queued native accepts could be dropped before flush.

ios/Libraries/VoipService.swift (1)

431-450: This path still does not actually “rely on JS”.

All native-accept failure branches funnel into finishAccept(false), which ends the CallKit call and emits VoipAcceptFailed. With the iOS answerCall fallback removed on the JS side, that turns a user answer into a hard failure instead of a JS handoff.

#!/bin/bash
# Verify there is no remaining JS-side CallKeep answer fallback on iOS.
rg -nC2 "RNCallKeep\\.addEventListener\\(['\"]answerCall['\"]|mediaSessionInstance\\.answerCall\\(|VoipAcceptFailed" app/lib/services/voip
rg -nC2 "voipAcceptFailed|VoIP_Call_Issue|endCall" app/sagas/deepLinking.js

Also applies to: 475-500

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

64-77: This issue has been resolved. The toBundle() method now correctly serializes the voipAcceptFailed flag (lines 91–93), and fromBundle() deserializes it properly (lines 197–198). Bundle round-tripping is now complete and symmetric.

app/sagas/deepLinking.js (5)

1-3: LGTM!

The new imports for InteractionManager, RNCallKeep, I18n, showToast, and useCallStore are appropriate for the VoIP accept failure handling functionality.

Also applies to: 23-23, 30-30


50-62: LGTM!

The waitForNavigation function correctly handles both immediate resolution when navigation is ready and deferred resolution via emitter listener.


130-136: LGTM!

The completeDeepLinkNavigation wrapper cleanly routes VoIP accept failures to the dedicated handler while preserving the normal navigation flow for other cases.


191-196: LGTM!

Correctly handles the voipAcceptFailed case when no host is present, providing an early exit to the dedicated failure handler before falling back to normal navigation.


231-231: LGTM!

The integration of completeDeepLinkNavigation at all navigation completion points ensures consistent VoIP failure handling across same-server, cross-server with existing user, failed server lookup, and new server with token scenarios.

Also applies to: 239-239, 248-251, 266-266

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

53-61: LGTM!

The new ACTION_VOIP_ACCEPT_HEADS_UP constant is well-documented, explaining the Android 12+ trampoline restriction and the intent flow. The VoipMediaCallIdentity data class and SUPPORTED_VOIP_FEATURES constant provide clean encapsulation for the DDP signaling parameters.

Also applies to: 67-71


159-187: LGTM — past review concern addressed.

The duplicate storeInitialEvents issue from the previous review has been resolved. In the heads-up accept path (line 174), prepareMainActivityForIncomingVoip is called with storePayloadForJs = false, preventing the first store. The single store now occurs in handleAcceptAction's finish(ddpSuccess) callback on success (line 233).


193-211: LGTM!

prepareMainActivityForIncomingVoip cleanly handles notification/timeout cleanup, conditional payload storage, and keyguard dismissal for Android O_MR1+.


222-266: LGTM!

The handleAcceptAction function properly handles both immediate (logged-in) and queued (pre-login) DDP accept signaling with a unified finish callback. The callback correctly:

  • Stops DDP client unconditionally
  • On success: answers the telecom call and stores events for JS
  • On failure: disconnects the call and stores failure state for JS recovery
  • Sends dismiss broadcast and optionally launches MainActivity

436-463: LGTM!

The buildAcceptSignalParams and buildRejectSignalParams functions correctly construct the DDP signaling payloads with appropriate fields. Accept includes supportedFeatures, while reject omits it as expected.


753-777: LGTM!

The accept PendingIntent correctly uses PendingIntent.getActivity to work around Android 12's notification trampoline restrictions. The FLAG_IMMUTABLE flag is properly applied on API 31+ as required, with the fallback to mutable on older versions.


413-434: ANDROID_ID implementation is correct and matches getUniqueIdSync().

The code correctly uses Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID), which is the same underlying call used by react-native-device-info's getUniqueIdSync() on Android. ANDROID_ID retrieval via Settings.Secure is consistent across all supported API levels (24–35); no API-level-specific behavior differences exist for this particular call.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
app/sagas/deepLinking.js (1)

95-98: Doc comment mentions "dialog" but only toast is shown.

The comment at line 97 states "toast/dialog per a11y", but the implementation (line 124) only calls showToast. If accessibility requires a dialog in certain contexts, consider adding that logic or updating the comment to reflect the current behavior.

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

In `@app/sagas/deepLinking.js` around lines 95 - 98, The doc comment in
deepLinking.js claims "toast/dialog per a11y" but the implementation only calls
showToast (see the saga handling native VoIP accept failure where showToast is
invoked); update the comment to accurately reflect current behavior (e.g.,
"toast shown for user feedback; no dialog is displayed") or, if you intend to
support an a11y dialog, implement that conditional dialog display alongside
showToast in the same handler instead of changing the text—refer to the saga
function that resets call state, ends CallKit session, and calls showToast to
locate the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/sagas/deepLinking.js`:
- Around line 95-98: The doc comment in deepLinking.js claims "toast/dialog per
a11y" but the implementation only calls showToast (see the saga handling native
VoIP accept failure where showToast is invoked); update the comment to
accurately reflect current behavior (e.g., "toast shown for user feedback; no
dialog is displayed") or, if you intend to support an a11y dialog, implement
that conditional dialog display alongside showToast in the same handler instead
of changing the text—refer to the saga function that resets call state, ends
CallKit session, and calls showToast to locate the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 042b1cee-6e57-4c07-9662-56de3d40aa78

📥 Commits

Reviewing files that changed from the base of the PR and between 860638c and d49ddea.

📒 Files selected for processing (2)
  • app/actions/deepLinking.ts
  • app/sagas/deepLinking.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/actions/deepLinking.ts
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 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/sagas/deepLinking.js
🔇 Additional comments (6)
app/sagas/deepLinking.js (6)

1-3: LGTM!

New imports are appropriate and all are utilized by the new VoIP failure handling logic.


109-115: Duplicate appStart dispatch persists.

Line 109 dispatches appStart({ root: RootEnum.ROOT_INSIDE }), and then line 115 calls navigate(), which unconditionally dispatches the same action at the end (line 92). This causes redundant state updates.

Remove the explicit appStart at line 109, since navigate already handles it.
[raise_minor_issue, duplicate_comment]

Suggested fix
 		yield call(waitForNavigation);
 
-		yield put(appStart({ root: RootEnum.ROOT_INSIDE }));
-
 		const navigateParams = {

130-136: LGTM!

Good abstraction that centralizes the VoIP failure vs. normal navigation decision, reducing duplication across handleOpen branches.


191-194: LGTM!

Correctly handles VoIP failure when the deep link lacks a host, ensuring the user receives feedback even without full server context.


231-231: LGTM!

Consistent use of completeDeepLinkNavigation across all successful deep link resolution paths.


248-251: LGTM!

Correctly prioritizes VoIP failure handling over generic fallback when the server info lookup fails, ensuring the user still receives appropriate feedback.

@diegolmello diegolmello force-pushed the feat.voip-registered-event branch from d49ddea to d3776e9 Compare March 25, 2026 17:47
@diegolmello
Copy link
Member Author

@CodeRabbit review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
ios/Libraries/AppDelegate+Voip.swift (1)

27-37: ⚠️ Potential issue | 🟠 Major

Avoid logging full VoIP payload contents (PII/compliance risk).

At Line 27 and Line 37, dumping payloadDict / voipPayload can leak sensitive call metadata into logs. Log only minimal non-sensitive context (e.g., callId and payload keys).

Proposed redaction-focused change
-      print("[\(voipAppDelegateLogTag)] Failed to parse incoming VoIP payload: \(payloadDict)")
+      let payloadKeys = payloadDict.keys.sorted()
+      print("[\(voipAppDelegateLogTag)] Failed to parse incoming VoIP payload. keys=\(payloadKeys)")
...
-      print("[\(voipAppDelegateLogTag)] Skipping expired or invalid VoIP payload for callId: \(callId): \(voipPayload)")
+      print("[\(voipAppDelegateLogTag)] Skipping expired or invalid VoIP payload for callId: \(callId)")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Libraries/AppDelegate`+Voip.swift around lines 27 - 37, The prints
currently logging full payloadDict and voipPayload (see voipAppDelegateLogTag,
payloadDict, voipPayload) may leak PII; change those debug prints to only emit
non-sensitive context such as callId and a list of payload keys (or a redaction
placeholder) instead of dumping the whole payload object, and ensure the
isExpired() guard log uses callId (and not voipPayload) when reporting
skipped/expired payloads.
🧹 Nitpick comments (2)
ios/Libraries/AppDelegate+Voip.swift (1)

3-3: Use private instead of fileprivate for the log tag.

For file-scope constants, private is the idiomatic choice and behaviorally equivalent to fileprivate. This aligns with the SwiftLint rule private_over_fileprivate.

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

In `@ios/Libraries/AppDelegate`+Voip.swift at line 3, The constant
voipAppDelegateLogTag is declared fileprivate but should be private per Swift
style; change the declaration of voipAppDelegateLogTag from fileprivate to
private in AppDelegate+Voip (replace "fileprivate let voipAppDelegateLogTag"
with "private let voipAppDelegateLogTag") to satisfy the
private_over_fileprivate SwiftLint rule and keep the same visibility.
android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt (1)

677-678: Null check on non-nullable String parameters is redundant.

callId and caller are declared as String (not String?) in VoipPayload, so they cannot be null. The .isNullOrEmpty() check works but the null part is unnecessary.

Proposed simplification
-            if (callId.isNullOrEmpty() || caller.isNullOrEmpty()) {
+            if (callId.isEmpty() || caller.isEmpty()) {
                 Log.e(TAG, "Cannot register call with TelecomManager: callId is null or empty")
                 return
             }
🤖 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/VoipNotification.kt`
around lines 677 - 678, The null checks on callId and caller are redundant
because VoipPayload declares them as non-nullable Strings; update the validation
in VoipNotification (where the check occurs) to only test emptiness (e.g., use
callId.isEmpty() || caller.isEmpty() or isBlank() if you want to treat
whitespace as empty) and remove the isNullOrEmpty() calls to keep the check
correct and idiomatic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@ios/Libraries/AppDelegate`+Voip.swift:
- Around line 27-37: The prints currently logging full payloadDict and
voipPayload (see voipAppDelegateLogTag, payloadDict, voipPayload) may leak PII;
change those debug prints to only emit non-sensitive context such as callId and
a list of payload keys (or a redaction placeholder) instead of dumping the whole
payload object, and ensure the isExpired() guard log uses callId (and not
voipPayload) when reporting skipped/expired payloads.

---

Nitpick comments:
In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt`:
- Around line 677-678: The null checks on callId and caller are redundant
because VoipPayload declares them as non-nullable Strings; update the validation
in VoipNotification (where the check occurs) to only test emptiness (e.g., use
callId.isEmpty() || caller.isEmpty() or isBlank() if you want to treat
whitespace as empty) and remove the isNullOrEmpty() calls to keep the check
correct and idiomatic.

In `@ios/Libraries/AppDelegate`+Voip.swift:
- Line 3: The constant voipAppDelegateLogTag is declared fileprivate but should
be private per Swift style; change the declaration of voipAppDelegateLogTag from
fileprivate to private in AppDelegate+Voip (replace "fileprivate let
voipAppDelegateLogTag" with "private let voipAppDelegateLogTag") to satisfy the
private_over_fileprivate SwiftLint rule and keep the same visibility.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6d2acd91-cfe4-4a45-a8b0-a5402d537151

📥 Commits

Reviewing files that changed from the base of the PR and between d49ddea and 9e9b7f7.

📒 Files selected for processing (15)
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
  • app/actions/actionsTypes.ts
  • app/actions/deepLinking.ts
  • app/definitions/Voip.ts
  • app/i18n/locales/en.json
  • app/index.tsx
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/sagas/deepLinking.js
  • ios/Libraries/AppDelegate+Voip.swift
  • ios/Libraries/VoipModule.mm
  • ios/Libraries/VoipPayload.swift
  • ios/Libraries/VoipService.swift
✅ Files skipped from review due to trivial changes (4)
  • app/definitions/Voip.ts
  • app/index.tsx
  • app/i18n/locales/en.json
  • app/actions/actionsTypes.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipPayload.kt
  • app/actions/deepLinking.ts
  • ios/Libraries/VoipPayload.swift
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • ios/Libraries/VoipService.swift
📜 Review details
🧰 Additional context used
🧠 Learnings (2)
📚 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-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/sagas/deepLinking.js
🪛 SwiftLint (0.63.2)
ios/Libraries/AppDelegate+Voip.swift

[Warning] 3-3: Prefer private over fileprivate declarations

(private_over_fileprivate)

🔇 Additional comments (15)
app/lib/services/voip/MediaSessionInstance.ts (3)

65-77: Avoid logging full VoIP signal/call payloads in production.

These logs include entire signaling/call objects, which may expose sensitive call metadata and create high-volume logs. Log only minimal fields (e.g., type, callId, state) and gate verbose logs behind __DEV__.

Proposed fix
-		console.log('🤙 [VoIP] Processed signal:', signal);
+		if (__DEV__) {
+			console.log('🤙 [VoIP] Processed signal:', { type: signal?.type, callId: signal?.callId });
+		}

83-84: Gate verbose call data logging behind __DEV__.

Logging the full call object on every state change could expose sensitive data and create excessive log volume in production.

Proposed fix
 			call.emitter.on('stateChange', oldState => {
 				console.log(`📊 ${oldState} → ${call.state}`);
-				console.log('🤙 [VoIP] New call data:', call);
+				if (__DEV__) {
+					console.log('🤙 [VoIP] New call data:', { callId: call.callId, role: call.role, state: call.state });
+				}
 			});

68-72: LGTM! Proper error handling for answerCall.

The .catch() handler addresses the previous concern about unhandled promise rejections. The signedContractId check correctly identifies accepts from this device using the synchronous getUniqueIdSync().

ios/Libraries/VoipModule.mm (2)

37-52: LGTM! VoipAcceptFailed event support added correctly.

The new event follows the established pattern for VoipPushTokenRegistered. The observer registration in startObserving and the sendEventWrapper usage for delayed event queuing are consistent with the existing implementation.


111-119: Good fix for TurboModule listener lifecycle.

Calling [super addListener:] and [super removeListeners:] ensures RCTEventEmitter properly tracks listener state. The previous empty implementations would have prevented startObserving/stopObserving from being called, breaking event delivery to JS.

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

159-187: LGTM! Clean intent routing for VoIP accepts.

The handleMainActivityVoipIntent function properly handles the heads-up accept flow by:

  1. Checking for the specific action before processing
  2. Resetting the intent action to prevent re-processing
  3. Using storePayloadForJs=false to avoid duplicate stash (addresses past review)
  4. Returning true to signal the intent was consumed

222-266: Async DDP accept with proper failure recovery.

The handleAcceptAction design is solid:

  • Cancels timeout first to prevent races
  • Uses a finish() callback to ensure cleanup happens regardless of DDP outcome
  • Stores failure state for JS recovery when DDP fails
  • Correctly disconnects Telecom connection on failure (line 236)

753-777: LGTM! Correct PendingIntent for Android 12+ trampoline restrictions.

Using PendingIntent.getActivity with FLAG_IMMUTABLE for API 31+ is the correct approach to avoid the notification trampoline restrictions. The comment at lines 753-755 clearly explains the rationale.

app/sagas/deepLinking.js (3)

95-126: LGTM! Clean VoIP accept failure recovery flow.

The handleVoipAcceptFailed saga properly:

  1. Resets call state before accessing params (callId comes from params, not store)
  2. Guards RNCallKeep.endCall with a null check
  3. Waits for navigation readiness before proceeding
  4. Uses InteractionManager.runAfterInteractions to ensure the toast appears after UI settles
  5. Catches and logs errors without crashing the app

128-134: LGTM! Unified deep-link routing through completeDeepLinkNavigation.

This helper cleanly separates the VoIP failure path from normal navigation while ensuring both flows go through the same navigation infrastructure.


189-192: Good edge case handling for voipAcceptFailed without host.

The early returns ensure the VoIP failure flow is handled even when the deep link lacks a host or when server info retrieval fails, preventing the app from getting stuck.

Also applies to: 246-249

app/lib/services/voip/MediaCallEvents.ts (4)

17-39: LGTM! Clean deduplication for accept failure events.

The lastHandledVoipAcceptFailureCallId pattern prevents double-dispatch when both the initial-events stash and the live event fire for the same failed accept. The early returns for missing voipAcceptFailed flag and duplicate callId are correct.


95-101: VoipAcceptFailed listener correctly applies to both platforms.

This listener is intentionally outside the if (isIOS) block since both iOS (via NativeEventEmitter) and Android (via DeviceEventEmitter) emit this event. The voipAcceptFailed: true spread ensures the flag is set even if native omitted it.


122-128: LGTM! Correct early return to prevent appInit() race.

Returning true when voipAcceptFailed is detected ensures the caller (app/index.tsx:156-160) skips appInit(), preventing a race between the deep-linking saga and the normal app initialization path.


45-106: The iOS VoipAcceptFailed path does not attempt to answer the call via JS.

When native iOS accept fails before DDP completes, the VoipAcceptFailed listener (line 96) only dispatches deepLinkingOpen to show the failure UI—it does not call mediaSessionInstance.answerCall as a fallback. Unlike Android's explicit fallback mechanism (which stores the failure and allows the VoipPushInitialEvents listener to handle recovery), iOS has no corresponding answerCall attempt when the native accept fails. The call is abandoned after the failure notification is sent. Verify whether this behavior is intentional or if mediaSessionInstance.answerCall should be invoked in the VoipAcceptFailed handler to recover from native accept failures.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
ios/Libraries/VoipService.swift (1)

439-442: ⚠️ Potential issue | 🟡 Minor

Successful native accepts never release their dedupe entry.

When finishAccept(true) is called (line 441-442), the callId remains in nativeAcceptHandledCallIds. The hasEnded callback at lines 615-627 won't clear it because observedIncomingCall is set to nil at line 610 before handleNativeAccept returns, so the guard at line 604 fails.

This leaves stale entries in the set until process restart. While bounded in practice (0-1 concurrent calls), it's inconsistent with the documented lifecycle and could cause issues if the same callId is reused across app restarts without process termination.

🔧 Proposed fix: Clear dedupe on successful accept
         let finishAccept: (Bool) -> Void = { success in
             stopDDPClientInternal()
             if success {
                 storeInitialEvents(payload)
+                clearNativeAcceptDedupe(for: payload.callId)
             } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/Libraries/VoipService.swift` around lines 439 - 442, The successful
native accept path never clears the dedupe entry, leaving callId in
nativeAcceptHandledCallIds; update the finishAccept closure (the success branch
in finishAccept) to explicitly remove the current callId from
nativeAcceptHandledCallIds (or invoke the same cleanup the hasEnded callback
uses) when success is true so that
handleNativeAccept/hasEnded/observedIncomingCall lifecycle invariants are
preserved and stale entries aren't left behind.
🧹 Nitpick comments (2)
ios/Libraries/VoipService.swift (1)

603-612: Potential race: observedIncomingCall cleared before async handleNativeAccept completes.

Line 610 sets observedIncomingCall = nil synchronously, then calls handleNativeAccept which performs async DDP work. If callObserver(_:callChanged:) fires again for the same call (e.g., hasEnded shortly after hasConnected), the guard at line 604 will fail and the end event won't be processed.

This is likely acceptable given the dedupe guard, but worth noting that any cleanup logic in the hasEnded branch (lines 615-627) won't run for calls that connect and end quickly.

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

In `@ios/Libraries/VoipService.swift` around lines 603 - 612, The code clears
observedIncomingCall immediately before invoking handleNativeAccept, which can
race with later callChanged events (e.g., hasEnded) and skip cleanup; modify
handleObservedCall/handleNativeAccept so observedIncomingCall is cleared only
after the async accept work finishes: either make handleNativeAccept
return/accept a completion (or be async) and move observedIncomingCall = nil
into that completion, or have handleNativeAccept set observedIncomingCall = nil
when its async DDP work completes; update callers accordingly (symbols:
handleObservedCall(_:) , observedIncomingCall, handleNativeAccept(payload:)).
android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt (1)

69-72: Verify sync with MediaSessionStore when updating VOIP features.

The Kotlin SUPPORTED_VOIP_FEATURES mirrors the JS MediaSessionStore.ts features array (line 69: features: ['audio']). Both are currently in sync, but any feature additions on the JS side require manual updates to the Kotlin constant to maintain consistency.

🤖 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/VoipNotification.kt`
around lines 69 - 72, The Kotlin constant SUPPORTED_VOIP_FEATURES in
VoipNotification.kt is a hard-coded mirror of the JS MediaSessionStore.features
array and can drift when JS features change; update SUPPORTED_VOIP_FEATURES
whenever you add or remove features in MediaSessionStore.ts (e.g., add the same
string entries such as "audio" or any new feature names), and consider adding a
clear comment referencing MediaSessionStore.ts to prevent future desyncs (locate
SUPPORTED_VOIP_FEATURES inside the VoipNotification class and the features array
in MediaSessionStore.ts).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@ios/Libraries/VoipService.swift`:
- Around line 439-442: The successful native accept path never clears the dedupe
entry, leaving callId in nativeAcceptHandledCallIds; update the finishAccept
closure (the success branch in finishAccept) to explicitly remove the current
callId from nativeAcceptHandledCallIds (or invoke the same cleanup the hasEnded
callback uses) when success is true so that
handleNativeAccept/hasEnded/observedIncomingCall lifecycle invariants are
preserved and stale entries aren't left behind.

---

Nitpick comments:
In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt`:
- Around line 69-72: The Kotlin constant SUPPORTED_VOIP_FEATURES in
VoipNotification.kt is a hard-coded mirror of the JS MediaSessionStore.features
array and can drift when JS features change; update SUPPORTED_VOIP_FEATURES
whenever you add or remove features in MediaSessionStore.ts (e.g., add the same
string entries such as "audio" or any new feature names), and consider adding a
clear comment referencing MediaSessionStore.ts to prevent future desyncs (locate
SUPPORTED_VOIP_FEATURES inside the VoipNotification class and the features array
in MediaSessionStore.ts).

In `@ios/Libraries/VoipService.swift`:
- Around line 603-612: The code clears observedIncomingCall immediately before
invoking handleNativeAccept, which can race with later callChanged events (e.g.,
hasEnded) and skip cleanup; modify handleObservedCall/handleNativeAccept so
observedIncomingCall is cleared only after the async accept work finishes:
either make handleNativeAccept return/accept a completion (or be async) and move
observedIncomingCall = nil into that completion, or have handleNativeAccept set
observedIncomingCall = nil when its async DDP work completes; update callers
accordingly (symbols: handleObservedCall(_:) , observedIncomingCall,
handleNativeAccept(payload:)).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ce292e8a-a033-4cee-b03c-f54710c79694

📥 Commits

Reviewing files that changed from the base of the PR and between 9e9b7f7 and d0aede0.

📒 Files selected for processing (3)
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
  • app/lib/services/voip/MediaCallEvents.ts
  • ios/Libraries/VoipService.swift
📜 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 (12)
app/lib/services/voip/MediaCallEvents.ts (4)

65-70: Intentional removal of iOS answerCall listener is now documented.

The comment at lines 66-69 explains that VoipService.swift handles accepts natively via handleNativeAccept() before JS runs, and JS only reads stored initialEventsData. This addresses the previous review concern about missing JS fallback — the native path now handles all accept scenarios including failure recovery via VoipAcceptFailed event.


22-39: LGTM! Well-structured dedupe logic for accept failures.

The deduplication guard correctly prevents duplicate handling when native emits and stash replay occur for the same failed accept. Setting lastHandledVoipAcceptFailureCallId before dispatching ensures atomicity within the single-threaded JS runtime.


102-108: Minor: Redundant voipAcceptFailed: true override.

Line 105 spreads data and then forces voipAcceptFailed: true. If native already sets this flag, the override is redundant. However, this is a safe defensive pattern ensuring the helper always receives the correct flag regardless of native payload shape.


119-135: Cold-start accept failure handling looks correct.

The early return at line 134 prevents racing appInit() with the deep-linking saga. The validation at line 129 ensures both callId and host are present before dispatching, avoiding malformed deep links.

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

172-179: Heads-up accept path correctly avoids duplicate storeInitialEvents.

Line 175 passes storePayloadForJs = false to prepareMainActivityForIncomingVoip, and line 247 in handleAcceptAction's finish(true) branch stores the payload. This addresses the previous review concern about duplicate emissions.


225-262: LGTM! Well-designed async accept flow with proper timeout handling.

The AtomicBoolean guard ensures finish() executes at most once, preventing race conditions between the DDP callback and the 10-second timeout. The timeoutHandler.removeCallbacks(timeoutRunnable) at line 243 cleans up properly when DDP completes first.


427-448: Device ID resolution uses Settings.Secure.ANDROID_ID correctly.

The comment at line 428-429 correctly notes this must match JS getUniqueIdSync() from react-native-device-info. The null checks at lines 436 and 442 handle edge cases where credentials or device ID are unavailable.


767-791: Accept action uses PendingIntent.getActivity to comply with Android 12+ restrictions.

The comment at lines 767-769 explains why getActivity is used instead of getBroadcast — Android 12+ blocks starting activities from notification BroadcastReceiver trampolines. The FLAG_IMMUTABLE is correctly applied only on API 31+.


296-303: Null connection handling in answerIncomingCall is appropriate.

The when expression handles VoiceConnection, null, and other connection types gracefully with logging. This defensive approach prevents crashes if the connection state is unexpected.

ios/Libraries/VoipService.swift (3)

45-58: Excellent documentation of the dedupe guard lifecycle.

The detailed comment explaining when entries are added and removed helps maintainability. The memory bound note ("0-1 concurrent VoIP calls") is accurate for this use case.


399-428: LGTM! Unified parameter builder simplifies accept/reject logic.

The buildMediaCallAnswerParams function cleanly handles both cases with the VoipMediaCallAnswerKind enum, conditionally adding supportedFeatures only for accepts.


459-479: Accept failure notification payload construction is thorough.

The acceptFailedUserInfo dictionary includes all necessary fields for JS recovery. The optional handling of avatarUrl and createdAt at lines 469-474 is correct.

@diegolmello diegolmello force-pushed the feat.voip-registered-event branch from d0aede0 to 47dc4a5 Compare March 25, 2026 18:18
@diegolmello diegolmello had a problem deploying to official_android_build March 25, 2026 18:21 — with GitHub Actions Error
@diegolmello diegolmello had a problem deploying to experimental_android_build March 25, 2026 18:21 — with GitHub Actions Error
@diegolmello diegolmello had a problem deploying to experimental_ios_build March 25, 2026 18:21 — with GitHub Actions Error
@diegolmello diegolmello requested a deployment to approve_e2e_testing March 25, 2026 19:04 — with GitHub Actions Waiting
@diegolmello diegolmello merged commit 106cbd7 into feat.voip-lib-new Mar 25, 2026
6 of 7 checks passed
@diegolmello diegolmello deleted the feat.voip-registered-event branch March 25, 2026 19:04
@diegolmello diegolmello requested a deployment to official_android_build March 25, 2026 19:06 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to experimental_android_build March 25, 2026 19:06 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to experimental_ios_build March 25, 2026 19:06 — with GitHub Actions Waiting
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