Skip to content

feat(voip): introduce CallLifecycle.end and CallNavRouter#7274

Merged
diegolmello merged 9 commits into
voip-refactor/04-voip-native-attach-and-cold-startfrom
voip-refactor/05-call-lifecycle-end-and-nav-router
Apr 30, 2026
Merged

feat(voip): introduce CallLifecycle.end and CallNavRouter#7274
diegolmello merged 9 commits into
voip-refactor/04-voip-native-attach-and-cold-startfrom
voip-refactor/05-call-lifecycle-end-and-nav-router

Conversation

@diegolmello
Copy link
Copy Markdown
Member

@diegolmello diegolmello commented Apr 29, 2026

Proposed changes

Introduces CallLifecycle and CallNavRouter, the two new orchestration modules for slice 05 of the VoIP native-call-seam refactor.

CallLifecycle owns the end-of-call teardown sequence with a documented, tested order and idempotency guarantee:

  1. mediaCall.reject() if ringing, else mediaCall.hangup() (wrapped in try/catch — a throw does not abort subsequent steps)
  2. voipNative.call.end(callUuid)
  3. voipNative.call.markActive('')
  4. voipNative.call.markAvailable(callUuid)
  5. useCallStore.reset()
  6. voipNative.call.stopAudio() (after reset — subscribers see clean state when callEnded emits)
  7. emit callEnded { callId, reason }

Steps 2-6 are also wrapped in a local safe(label, fn) helper so a throw in any teardown step is logged but does not abort the rest of the sequence; callEnded is guaranteed to emit.

Concurrent callers share the in-flight Promise (re-entry guard). The teardown body is deferred to a microtask so the in-flight promise is captured BEFORE step 1 runs — synchronous re-entry from mediaCall.hangup()'s 'ended' emit hits the guard. callId uses callId ?? nativeAcceptedCallId (Pre-bind-safe).

CallNavRouter is a lightweight subscriber mounted at AppContainer. It subscribes to CallLifecycle events only after NavigationContainer is ready (via the existing emitter.emit('navigationReady') hook), and calls Navigation.back() when callEnded fires and the current route is CallView. unmount() cleans up both the callEnded listener AND the deferred navigationReady listener.

Six end-call paths consolidated to lifecycle.end(reason):

  • MediaSessionInstance.endCalllifecycle.end('local')
  • useCallStore.endCalllifecycle.end('local')
  • useCallStore.setCall handleEndedlifecycle.end('remote') (removes inline Navigation.back)
  • MediaSessionInstance.init per-call 'ended' listener → lifecycle.end('remote')
  • deepLinking.js handleVoipAcceptFailedlifecycle.end('error') (yielded via redux-saga call so teardown completes before navigation continues)
  • waitForNavigationReady removed from MediaSessionInstance.answerCall

OS-originated end-call events (endCall from CallKit/Telecom) now dispatch callLifecycle.end('remote') directly rather than routing through mediaSessionInstance.endCall(), ensuring the callEnded event carries the correct 'remote' reason instead of 'local'.

All synchronous callers attach .catch(error => mediaCallLogger.error(...)) to the returned Promise<void> so a teardown rejection is logged instead of becoming an unhandled promise rejection.

stopAudio ownership moved from useCallStore.reset to CallLifecycle step 6.

Production blockers fixed in audit pass

  • Re-entry guard structurally broken: mediaCall.hangup() synchronously emits 'ended' (@rocket.chat/media-signaling Call.js line 703) → useCallStore.handleEnded synchronously re-calls callLifecycle.end('remote') BEFORE the outer end() finishes assigning _endPromise. Fix: defer _runTeardown to a microtask via Promise.resolve().then(...) so _endPromise is captured first.
  • handleAcceptFailedEvent dropped the callId: the success path stashes nativeAcceptedCallId, but the failure path didn't — so the downstream saga's lifecycle.end('error') had no callUuid to issue to CallKit/Telecom. Fix: mirror the success path and set nativeAcceptedCallId before opening the failure deep link.
  • Step 1 missing try/catch: a throw from mediaCall.reject() or mediaCall.hangup() aborted the remaining six teardown steps, leaking listeners and native state. Fix: wrap step 1 in try/catch with logger.warn; subsequent steps still run.

CodeRabbit feedback addressed (commit 3227728)

  • CallNavRouter.unmount() listener leak: the deferred navigationReady listener registered when nav was not yet ready was never cleaned up. If unmount() ran before navigationReady emitted, the listener stayed alive and could later subscribe callEnded while the router was "unmounted". Fix: track the listener's off-handle in a module-scoped _unsubscribeNavigationReady, clear it in both unmount() and inside onceNavigationReady. Regression test added.
  • deepLinking.js saga did not yield the teardown Promise: the bare callLifecycle.end('error') call returned Promise<void> not awaited by the saga, so resetVoipState() and waitForNavigationReady() could proceed before teardown completed. Fix: yield call([callLifecycle, callLifecycle.end], 'error').
  • Unhandled promise from callLifecycle.end() at five sync sites: each site now attaches a .catch(error => mediaCallLogger.error(...)). Sites updated: MediaCallEvents.ts:97, useCallStore.ts (handleEnded, endCall), MediaSessionInstance.ts:142 (newCall ended), MediaSessionInstance.ts:213 (endCall).
  • Teardown steps 2-6 still aborted on throw: only step 1 was guarded. Fix: introduced a local safe(label, fn) helper inside _runTeardown that try/catches each step and logs warn. callEnded now always emits even if native.call.end / markActive / markAvailable / useCallStore.reset / native.call.stopAudio throws. Five per-step regression tests added.
  • Lint nits: imports moved above jest.mock(...) in both test files (import/first); const { recorded } = native; instead of native.recorded (prefer-destructuring); eslint-disable-next-line no-await-in-loop for the reasons-sequencing test loop.

Issue(s)

.scratch/voip-native-call-seam/issues/05-call-lifecycle-end-and-nav-router.md

How to test or reproduce

  • End a call from the in-app button (useCallStore.endCall) — store clears, native teardown runs, navigation returns to previous screen
  • End a call from the OS native UI (CallKit/Telecom endCall event) — same teardown fires, callEnded reason is 'remote'
  • Remote hangup (media 'ended' event from WebRTC) — same teardown, navigation back
  • VoIP accept failure (deep link voipAcceptFailed) — same teardown path, with the native CallKit/Telecom session correctly torn down via the stashed nativeAcceptedCallId
  • Concurrent local + remote hangup — one observable teardown sequence, both promises resolve

Screenshots

N/A (no visible UI changes — orchestration layer only)

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)

Checklist

  • I have read the CONTRIBUTING doc
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works

Further comments

Test plan

Unit tests (all pass — 1331 across 151 suites in scoped run):

  • CallLifecycle.test.ts: teardown ordering, hangup vs reject, concurrent end() idempotency, callEnded exactly-once, Pre-bind callId ?? nativeAcceptedCallId, reason threading, re-entry guard against synchronous 'ended' emission, step-1 throw isolation (hangup and reject paths), steps 2-6 throw isolation (5 new tests, one per step)
  • CallNavRouter.test.ts: callEnded→goBack on CallView, no-op on other routes, subscription only after navigationReady, mount() idempotency, unmount/remount, deferred navigationReady listener cleanup on unmount-before-ready
  • MediaSessionInstance.test.ts: endCall delegates to lifecycle.end('local')
  • useCallStore.test.ts: reset() no longer owns stopAudio
  • MediaCallEvents.test.ts: OS endCall event calls callLifecycle.end('remote'); failed-accept path stashes nativeAcceptedCallId
  • VoipCallLifecycle.integration.test.tsx: passing — InMemoryVoipNative.recorded assertions, microtask flush after teardown trigger

Strict-once assertions: regression tests for the re-entry guard use toHaveBeenCalledTimes(1) and .toHaveLength(1) rather than toContainEqual, so duplicate emissions cannot silently pass.

Manual verification (iOS/Android):

  • End call from in-app button → app returns to rooms list
  • End call from OS UI (CallKit/Telecom) → same, callEnded.reason === 'remote'
  • Remote hangup → same
  • VoIP accept failure → toast + rooms list, native session torn down

Stacks on

This PR stacks on voip-refactor/04-voip-native-attach-and-cold-start (PR #7272).

Summary by CodeRabbit

  • Bug Fixes

    • More reliable VoIP teardown sequencing and idempotent termination to avoid duplicate native actions and navigation events.
    • Ensured teardown continues despite individual step failures and re-entry during teardown.
  • Improvements

    • Centralized call lifecycle for consistent handling of local/remote/rejected/error end paths.
    • Improved navigation readiness handling and call state cleanup.
  • Tests

    • Expanded integration and unit tests covering teardown ordering, concurrency, and failure cases.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 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

Centralizes VoIP teardown into a new CallLifecycle singleton, adds CallNavRouter to defer post-call navigation until navigation is ready, and updates callers/tests to delegate teardown to CallLifecycle and to mount CallNavRouter once on app startup.

Changes

Cohort / File(s) Summary
CallLifecycle core & tests
app/lib/services/voip/CallLifecycle.ts, app/lib/services/voip/CallLifecycle.test.ts
New singleton implementing concurrency-safe, idempotent teardown sequence (hangup/reject, native.end, markActive/markAvailable, store.reset, stopAudio) and typed events; comprehensive tests for ordering, idempotency, error-isolation, and native-seam fallback.
Navigation router & App mount
app/lib/services/voip/CallNavRouter.ts, app/lib/services/voip/CallNavRouter.test.ts, app/AppContainer.tsx
New CallNavRouter that subscribes to CallLifecycle.callEnded once navigation is ready and calls Navigation.back() only when current route is CallView. AppContainer now mounts CallNavRouter once on initial render. Tests cover readiness guards, idempotent mount/unmount, and route filtering.
Store and session integration
app/lib/services/voip/useCallStore.ts, app/lib/services/voip/useCallStore.test.ts, app/lib/services/voip/MediaSessionInstance.ts, app/lib/services/voip/MediaSessionInstance.test.ts
Teardown logic moved from store/session to CallLifecycle. useCallStore.reset() no longer stops audio; MediaSessionInstance delegates local end to callLifecycle.end('local') and remote-ended paths call callLifecycle.end('remote'). Tests updated to reflect delegation and stop-audio ownership.
Event dispatch & accept-failed handling
app/lib/services/voip/MediaCallEvents.ts, app/lib/services/voip/MediaCallEvents.test.ts
Media call events now route remote endings to callLifecycle.end('remote'). acceptFailed stores incoming native callId (nativeAcceptedCallId) for later lifecycle resolution. Tests updated to mock CallLifecycle and validate new routing.
Deep-linking saga
app/sagas/deepLinking.js
handleVoipAcceptFailed now awaits callLifecycle.end('error') for teardown before continuing reset/navigation flow instead of directly ending native call via callId.
Misc tests & seams
app/lib/services/voip/* tests (various updated files)
Multiple tests adjusted to await microtasks, assert store reset semantics, and reflect new native command ordering and ownership (stopAudio moved into CallLifecycle).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant App as AppContainer
    participant Media as MediaSessionInstance
    participant CallLC as CallLifecycle
    participant Native as VoipNative
    participant Store as useCallStore
    participant NavRouter as CallNavRouter
    participant Nav as Navigation

    User->>Media: trigger endCall()
    App->>NavRouter: mount() (once)
    Media->>CallLC: end('local')
    CallLC->>Store: getState() -> callId / nativeAcceptedCallId
    CallLC->>Media: hangup() / reject() (if media present)
    CallLC->>Native: call.end(effectiveCallId)
    CallLC->>Native: call.markActive('')
    CallLC->>Native: call.markAvailable(effectiveCallId ?? '')
    CallLC->>Store: reset()
    CallLC->>Native: call.stopAudio()
    CallLC->>CallLC: emit callEnded{callId, reason}
    CallLC->>NavRouter: callEnded event
    NavRouter->>Nav: getCurrentRoute()
    alt currentRoute.name == "CallView"
        NavRouter->>Nav: back()
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main changes: introducing two new orchestration modules (CallLifecycle and CallNavRouter) for VoIP call teardown and navigation handling. The title directly reflects the primary features added in this changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

Warning

Review ran into problems

🔥 Problems

Errors were encountered while retrieving linked issues.

Errors (1)
  • STEP-1: Request failed with status code 401

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
Copy Markdown
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: 3

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

211-214: 💤 Low value

Consider removing the unused _callId parameter or documenting the reason for its retention.

The _callId parameter is now entirely ignored since callLifecycle.end() reads the call ID from useCallStore. If this signature must remain for API compatibility, a brief comment explaining that constraint would help future maintainers. Otherwise, removing it would reduce surface area.

🤖 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 211 - 214, The
endCall method in MediaSessionInstance currently accepts an unused parameter
_callId; either remove the _callId parameter from the method signature or, if it
must remain for external API compatibility, add a concise comment on
MediaSessionInstance.endCall explaining that the call ID is intentionally
ignored because callLifecycle.end('local') reads the active call ID from
useCallStore; ensure the change updates any external typings or callers if you
remove the parameter to keep signatures consistent.
app/lib/services/voip/CallLifecycle.test.ts (2)

99-101: 💤 Low value

Empty afterEach block can be removed.

The afterEach hook contains only a comment and no actual cleanup logic.

♻️ Suggested fix
-	afterEach(() => {
-		// Clean up any listeners
-	});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/CallLifecycle.test.ts` around lines 99 - 101, Remove
the empty test teardown hook: delete the no-op afterEach block in
CallLifecycle.test.ts (the afterEach() that only contains a comment) or replace
it with real cleanup logic if teardown is required; this keeps tests clean and
avoids dead code.

350-358: 💤 Low value

Brittle constructor access pattern — consider exporting CallLifecycle class directly.

Using callLifecycle.constructor to create a fresh instance is fragile and relies on implementation details. If the class is minified or the export structure changes, this test will break.

♻️ Alternative approach

Export the CallLifecycle class directly from the module:

// In CallLifecycle.ts
-export const callLifecycle = new CallLifecycle();
+export class CallLifecycle { ... }
+export const callLifecycle = new CallLifecycle();

Then in the test:

-const freshLifecycle = new (callLifecycle.constructor as new () => typeof callLifecycle)();
+import { CallLifecycle } from './CallLifecycle';
+const freshLifecycle = new CallLifecycle();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/CallLifecycle.test.ts` around lines 350 - 358, The test
uses a brittle pattern new (callLifecycle.constructor as new () => typeof
callLifecycle)() to create a fresh instance; instead export the CallLifecycle
class from the module and update the test to instantiate it directly (e.g., new
CallLifecycle()) so it no longer depends on callLifecycle.constructor; ensure
the test still calls the same internal teardown helper (_runTeardown('local'))
on the fresh instance and update imports to reference the exported
CallLifecycle.
app/lib/services/voip/CallLifecycle.ts (1)

117-158: 💤 Low value

The async keyword is unnecessary since _runTeardown contains no await expressions.

The eslint-disable require-await comment acknowledges this. If future changes don't require async, consider removing both the comment and the async keyword to reduce noise.

♻️ Alternative
-	// eslint-disable-next-line require-await
-	private async _runTeardown(reason: CallEndReason): Promise<void> {
+	private _runTeardown(reason: CallEndReason): void {

Note: This would require updating line 111 to wrap the result:

-this._endPromise = this._runTeardown(reason).finally(() => {
+this._endPromise = Promise.resolve(this._runTeardown(reason)).finally(() => {

However, keeping it async is fine if you anticipate adding awaited calls in future slices.

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

In `@app/lib/services/voip/CallLifecycle.ts` around lines 117 - 158, The
_runTeardown method is marked async and has an eslint-disable for require-await
but contains no awaits; remove the unnecessary async keyword and the
eslint-disable comment, change the signature from "private async
_runTeardown(reason: CallEndReason): Promise<void>" to a synchronous signature
(e.g. "private _runTeardown(reason: CallEndReason): void"), and then update any
callers that expect a Promise-returning _runTeardown to handle a synchronous
return (wrap the call with Promise.resolve(...) or make the caller async and
await Promise.resolve if needed) so call semantics remain correct.
🤖 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/CallNavRouter.test.ts`:
- Around line 27-30: The test file has imports (callLifecycle, CallNavRouter,
emitter, Navigation) placed after executable code which triggers the ESLint
import/first rule; open CallNavRouter.test.ts and move these import statements
(callLifecycle, CallNavRouter, emitter, Navigation) to the very top of the
module, before any executable statements (e.g., variable declarations, mocks, or
test setups) so all imports run first and the lint error is resolved.

In `@app/lib/services/voip/CallNavRouter.ts`:
- Around line 45-62: unmount() currently doesn't remove the deferred
navigationReady listener (onceNavigationReady), so if the router is unmounted
before nav is ready that listener can later re-subscribe callEnded; fix by
storing the listener reference in a module-scoped variable (e.g.
_pendingNavigationReadyListener) when you create onceNavigationReady in the code
that calls emitter.on('navigationReady', onceNavigationReady) and then in
unmount() call emitter.off('navigationReady', _pendingNavigationReadyListener),
null out that variable and then proceed to clear _unsubscribeCallEnded and set
_mounted = false; ensure the same function reference created for emitter.on is
used for emitter.off.

In `@app/sagas/deepLinking.js`:
- Around line 87-91: callLifecycle.end('error') returns a Promise and must be
awaited inside the saga to preserve ordering; replace the direct call with a
yielded redux-saga call effect (e.g. yield call([callLifecycle,
callLifecycle.end], 'error')) before invoking resetVoipState(), and ensure call
is imported from 'redux-saga/effects' in this module so the teardown completes
before resetVoipState() and subsequent navigation.

---

Nitpick comments:
In `@app/lib/services/voip/CallLifecycle.test.ts`:
- Around line 99-101: Remove the empty test teardown hook: delete the no-op
afterEach block in CallLifecycle.test.ts (the afterEach() that only contains a
comment) or replace it with real cleanup logic if teardown is required; this
keeps tests clean and avoids dead code.
- Around line 350-358: The test uses a brittle pattern new
(callLifecycle.constructor as new () => typeof callLifecycle)() to create a
fresh instance; instead export the CallLifecycle class from the module and
update the test to instantiate it directly (e.g., new CallLifecycle()) so it no
longer depends on callLifecycle.constructor; ensure the test still calls the
same internal teardown helper (_runTeardown('local')) on the fresh instance and
update imports to reference the exported CallLifecycle.

In `@app/lib/services/voip/CallLifecycle.ts`:
- Around line 117-158: The _runTeardown method is marked async and has an
eslint-disable for require-await but contains no awaits; remove the unnecessary
async keyword and the eslint-disable comment, change the signature from "private
async _runTeardown(reason: CallEndReason): Promise<void>" to a synchronous
signature (e.g. "private _runTeardown(reason: CallEndReason): void"), and then
update any callers that expect a Promise-returning _runTeardown to handle a
synchronous return (wrap the call with Promise.resolve(...) or make the caller
async and await Promise.resolve if needed) so call semantics remain correct.

In `@app/lib/services/voip/MediaSessionInstance.ts`:
- Around line 211-214: The endCall method in MediaSessionInstance currently
accepts an unused parameter _callId; either remove the _callId parameter from
the method signature or, if it must remain for external API compatibility, add a
concise comment on MediaSessionInstance.endCall explaining that the call ID is
intentionally ignored because callLifecycle.end('local') reads the active call
ID from useCallStore; ensure the change updates any external typings or callers
if you remove the parameter to keep signatures consistent.
🪄 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: b3405a22-23c9-4789-807e-4e1d1e2afc1f

📥 Commits

Reviewing files that changed from the base of the PR and between 77d518a and 287c46b.

📒 Files selected for processing (11)
  • app/AppContainer.tsx
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/lib/services/voip/CallNavRouter.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
📜 Review details
🧰 Additional context used
📓 Path-based instructions (6)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

Files:

  • app/AppContainer.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

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

Files:

  • app/AppContainer.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/AppContainer.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
app/AppContainer.tsx

📄 CodeRabbit inference engine (CLAUDE.md)

Use AppContainer.tsx as the root navigation container that switches between authentication states

Files:

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

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.test.ts
app/containers/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Create reusable UI components in app/containers/ directory

Files:

  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
🧠 Learnings (10)
📓 Common learnings
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP features in app/lib/services/voip/ directory using Zustand stores for WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/AppContainer.tsx : Use AppContainer.tsx as the root navigation container that switches between authentication states

Applied to files:

  • app/AppContainer.tsx
  • app/lib/services/voip/useCallStore.ts
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/index.tsx : Set up Redux provider, theme, navigation, and notifications in app/index.tsx

Applied to files:

  • app/AppContainer.tsx
  • app/sagas/deepLinking.js
  • app/lib/services/voip/useCallStore.ts
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/containers/**/*.{ts,tsx} : Create reusable UI components in app/containers/ directory

Applied to files:

  • app/AppContainer.tsx
📚 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/AppContainer.tsx
  • app/sagas/deepLinking.js
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP features in app/lib/services/voip/ directory using Zustand stores for WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration

Applied to files:

  • app/AppContainer.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/theme.tsx : Define theming context in app/theme.tsx

Applied to files:

  • app/AppContainer.tsx
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to {app/sagas/videoConf.ts,app/lib/methods/videoConf.ts} : Implement video conferencing in app/sagas/videoConf.ts and app/lib/methods/videoConf.ts using Redux actions, reducers, and sagas for server-managed Jitsi integration

Applied to files:

  • app/sagas/deepLinking.js
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/lib/store/**/*.{ts,tsx} : Configure Redux middleware (saga, app state, internet state) in app/lib/store/ directory

Applied to files:

  • app/sagas/deepLinking.js
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/sagas/**/*.{ts,tsx} : Implement side effects and Redux-Saga middleware in app/sagas/ directory

Applied to files:

  • app/sagas/deepLinking.js
🪛 ESLint
app/lib/services/voip/CallNavRouter.test.ts

[error] 27-27: Import in body of module; reorder to top.

(import/first)


[error] 28-28: Import in body of module; reorder to top.

(import/first)


[error] 29-29: Import in body of module; reorder to top.

(import/first)


[error] 30-30: Import in body of module; reorder to top.

(import/first)

app/lib/services/voip/CallLifecycle.test.ts

[error] 43-43: Import in body of module; reorder to top.

(import/first)


[error] 45-45: Import in body of module; reorder to top.

(import/first)


[error] 46-46: Import in body of module; reorder to top.

(import/first)


[error] 47-47: Import in body of module; reorder to top.

(import/first)


[error] 48-48: Import in body of module; reorder to top.

(import/first)


[error] 114-114: Use object destructuring.

(prefer-destructuring)


[error] 252-252: Unexpected await inside a loop.

(no-await-in-loop)

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

805-815: Nice contract-focused test update.

This keeps MediaSessionInstance.endCall coverage focused on delegation and avoids duplicating teardown-order assertions already covered in CallLifecycle tests.

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

330-337: Good ownership-boundary assertion.

The updated expectation correctly enforces that reset() no longer owns stopAudio, preventing regressions in the new teardown orchestration split.

app/lib/services/voip/CallLifecycle.test.ts (4)

12-48: Import ordering is correct for Jest mocking.

The ESLint import/first errors are false positives. Jest's jest.mock() calls must appear before imports to properly intercept module loading. This is the standard Jest pattern.


243-257: Sequential await in loop is intentional for isolated reason testing.

The ESLint no-await-in-loop warning is a false positive here. Each iteration requires the previous callLifecycle.end() to complete before resetting state for the next reason. Parallel execution would cause race conditions.


103-212: LGTM — Comprehensive teardown ordering tests.

The tests thoroughly verify the documented teardown sequence (steps 1-6), including edge cases like skipping step 1 when no active call exists. The use of InMemoryVoipNative.recorded for command verification is clean.


308-337: LGTM — Solid idempotency coverage.

Testing that concurrent end() calls share the same in-flight promise (expect(p1).toBe(p2)) is a good pattern for verifying the re-entry guard.

app/lib/services/voip/MediaSessionInstance.ts (2)

18-24: LGTM!

The imports for callLifecycle and Navigation are correctly added to support the new centralized teardown orchestration. The delegation pattern aligns well with the PR's goal of consolidating end-call paths.


140-143: LGTM!

Routing the remote call-end event through callLifecycle.end('remote') centralizes teardown logic and ensures consistent sequencing across all end-call scenarios.

app/lib/services/voip/CallLifecycle.ts (5)

1-16: LGTM — Excellent documentation.

The header comment clearly documents the teardown sequence with step numbers that align with both the implementation and the test assertions. This makes the contract easy to verify and maintain.


48-70: LGTM — Clean typed event emitter implementation.

The custom emitter provides proper type safety for event payloads while keeping a minimal API surface. The unsubscribe pattern (returning a cleanup function from on()) is idiomatic.


106-115: LGTM — Well-implemented idempotency guard.

The in-flight promise pattern ensures concurrent end() calls share a single teardown sequence. Using .finally() to clear the guard handles both success and error cases correctly.


137-146: The concern about passing an empty string to markAvailable is based on a misunderstanding of the implementation.

The actual markAvailable implementation in VoipNative.ts (lines 122–125) ignores the callUuid parameter entirely. It always calls RNCallKeep.setCurrentCallActive('') and RNCallKeep.setAvailable(true) regardless of whether the parameter is an empty string or a valid ID. Passing effectiveCallId ?? '' has no unintended effect—the behavior is identical whether a valid ID or empty string is passed.

			> Likely an incorrect or invalid review comment.

128-135: The state property is already part of IClientMediaCall—remove the unnecessary cast.

The state property is accessed directly without casting in useCallStore.ts (lines 152, 163, 173) on the same IClientMediaCall type. The as any cast at line 130 is inconsistent and unnecessary. Change to:

if (mediaCall.state === 'ringing') {
			> Likely an incorrect or invalid review comment.

Comment thread app/lib/services/voip/CallNavRouter.test.ts Outdated
Comment thread app/lib/services/voip/CallNavRouter.ts
Comment thread app/sagas/deepLinking.js
Copy link
Copy Markdown
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: 2

🤖 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/MediaCallEvents.ts`:
- Line 92: callLifecycle.end('remote') returns a Promise and must not be left
unhandled; update the synchronous event dispatcher call to either await the
callLifecycle.end('remote') inside an async handler or explicitly attach a
.catch(...) to it so any rejection is logged/handled (e.g.,
callLifecycle.end('remote').catch(err => /* log/handle */)). Apply the same fix
to the other occurrences referenced (useCallStore.ts at the calls around the
handlers and MediaSessionInstance.ts where callLifecycle.end is invoked) so all
callLifecycle.end invocations are properly awaited or have a rejection handler
attached.

In `@app/lib/services/voip/VoipNative.ts`:
- Around line 127-129: The setSpeaker async method should wrap the call to
InCallManager.setForceSpeakerphoneOn(on) in a try/catch to satisfy the async
error-handling guideline; update the setSpeaker implementation inside VoipNative
(the setSpeaker function) to await the call within a try block and log or
rethrow the caught error in the catch so the Promise<void> explicitly handles
failures.
🪄 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: 70de25e1-6a60-4068-b491-ccdd434e3eb0

📥 Commits

Reviewing files that changed from the base of the PR and between 287c46b and acc3e8d.

📒 Files selected for processing (4)
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/VoipNative.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📜 Review details
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/VoipNative.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

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

Files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/VoipNative.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/VoipNative.ts
app/lib/services/voip/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

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

Applied to files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/VoipNative.ts
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to {app/sagas/videoConf.ts,app/lib/methods/videoConf.ts} : Implement video conferencing in app/sagas/videoConf.ts and app/lib/methods/videoConf.ts using Redux actions, reducers, and sagas for server-managed Jitsi integration

Applied to files:

  • app/lib/services/voip/MediaCallEvents.ts
📚 Learning: 2026-03-30T15:49:30.957Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6875
File: app/containers/RoomItem/Actions.tsx:12-12
Timestamp: 2026-03-30T15:49:30.957Z
Learning: In RocketChat/Rocket.Chat.ReactNative, `react-native-worklets` version 0.6.1 does NOT export a built-in Jest mock (e.g., no `react-native-worklets/lib/module/mock`). The correct Jest mock approach for this version is to add a manual mock in `jest.setup.js`: `jest.mock('react-native-worklets', () => ({ scheduleOnRN: jest.fn((fn, ...args) => fn(...args)) }))`.

Applied to files:

  • app/lib/services/voip/MediaCallEvents.test.ts
🔇 Additional comments (3)
app/lib/services/voip/MediaCallEvents.ts (1)

4-4: Good move to centralize teardown through CallLifecycle.

This import change aligns with the single teardown orchestrator design and keeps event handling focused.

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

61-67: Nice test realignment with lifecycle-based remote termination.

The new mock plus assertion directly validates the intended delegation path (endCallcallLifecycle.end('remote')).

Also applies to: 361-365

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

57-60: Good contract-preserving cleanup in the adapters.

These changes keep VoipNativePort compatibility intact while simplifying internals (Promise return shape retained, and unused callUuid made explicit).

Also applies to: 73-85, 123-126

Comment thread app/lib/services/voip/MediaCallEvents.ts Outdated
Comment thread app/lib/services/voip/VoipNative.ts
Copy link
Copy Markdown
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: 3

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

97-97: ⚠️ Potential issue | 🟠 Major

Unhandled Promise from callLifecycle.end('remote') in synchronous dispatcher.

On Line 97, the Promise is fire-and-forget without rejection handling; teardown failures can bubble as unhandled async errors. Please keep this call explicit with void ...catch(...) (or await in an async boundary).

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

In `@app/lib/services/voip/MediaCallEvents.ts` at line 97, The
callLifecycle.end('remote') invocation is currently fire-and-forget and can
produce an unhandled rejected Promise; update the synchronous dispatcher to
either await the Promise (make the calling function async and use await
callLifecycle.end('remote')) or explicitly handle it as a fire-and-forget by
prefixing with void and attaching a .catch(...) to log/handle errors (e.g., void
callLifecycle.end('remote').catch(err => {
processLogger.error('callLifecycle.end failed', err); })). Ensure you modify the
invocation of callLifecycle.end in MediaCallEvents.ts so all rejections are
handled.
🧹 Nitpick comments (1)
app/lib/services/voip/CallLifecycle.ts (1)

27-27: ⚡ Quick win

Prefer an enum for CallEndReason to align with project TS conventions.

The current string union works, but this constant-set is a good enum candidate and matches the repository TypeScript guideline.

As per coding guidelines, "Use enums for sets of related constants rather than magic strings or numbers".

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

In `@app/lib/services/voip/CallLifecycle.ts` at line 27, Replace the string-union
export CallEndReason with a TypeScript enum (e.g., export enum CallEndReason {
Local = 'local', Remote = 'remote', Rejected = 'rejected', Error = 'error',
Cleanup = 'cleanup' }) in CallLifecycle.ts and update all references/usages to
use the enum members (CallEndReason.Local, etc.) instead of string literals;
ensure any type annotations expecting the union are updated to the enum type and
any comparisons or assignments use the enum constants so imports/usages remain
type-safe and aligned with project TS conventions.
🤖 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/CallLifecycle.test.ts`:
- Line 114: Change the non-destructured assignment to use object destructuring
(replace "const recorded = native.recorded" with "const { recorded } = native"
in CallLifecycle.test.ts) to satisfy prefer-destructuring, and replace the
sequential awaits inside the test loop (the no-await-in-loop violation around
the loop at line ~252) by collecting the inner async operations into an array of
Promises and awaiting them together with await Promise.all(promises) (or
otherwise restructure the test to run those independent async steps in parallel)
to remove the no-await-in-loop lint error.
- Around line 43-48: The ESLint import/first error is caused by jest.mock(...)
calls appearing before the import block; move all jest.mock(...) lines so they
come after the top-level import statements (the imports for IClientMediaCall,
callLifecycle, CallEndReason, InMemoryVoipNative, and useCallStore) so that
every import is declared before any other executable statements in
CallLifecycle.test.ts.

In `@app/lib/services/voip/CallLifecycle.ts`:
- Around line 158-178: The teardown sequence in CallLifecycle can abort if any
of native.call.end, native.call.markActive, native.call.markAvailable,
useCallStore.getState().reset, or native.call.stopAudio throws, preventing
this.emitter.emit('callEnded') from running; wrap each teardown step in its own
try/catch (or execute them inside a single try with per-step inner catches) so
failures are logged but do not stop subsequent steps, ensuring
native.call.end(...), native.call.markActive(''),
native.call.markAvailable(...), useCallStore.getState().reset(),
native.call.stopAudio() all get attempted and that
this.emitter.emit('callEnded', { callId: effectiveCallId, reason }) always runs
in a finally-like guarantee.

---

Duplicate comments:
In `@app/lib/services/voip/MediaCallEvents.ts`:
- Line 97: The callLifecycle.end('remote') invocation is currently
fire-and-forget and can produce an unhandled rejected Promise; update the
synchronous dispatcher to either await the Promise (make the calling function
async and use await callLifecycle.end('remote')) or explicitly handle it as a
fire-and-forget by prefixing with void and attaching a .catch(...) to log/handle
errors (e.g., void callLifecycle.end('remote').catch(err => {
processLogger.error('callLifecycle.end failed', err); })). Ensure you modify the
invocation of callLifecycle.end in MediaCallEvents.ts so all rejections are
handled.

---

Nitpick comments:
In `@app/lib/services/voip/CallLifecycle.ts`:
- Line 27: Replace the string-union export CallEndReason with a TypeScript enum
(e.g., export enum CallEndReason { Local = 'local', Remote = 'remote', Rejected
= 'rejected', Error = 'error', Cleanup = 'cleanup' }) in CallLifecycle.ts and
update all references/usages to use the enum members (CallEndReason.Local, etc.)
instead of string literals; ensure any type annotations expecting the union are
updated to the enum type and any comparisons or assignments use the enum
constants so imports/usages remain type-safe and aligned with project TS
conventions.
🪄 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: b2235a4b-6679-4bb7-970b-55ac5d1e15d6

📥 Commits

Reviewing files that changed from the base of the PR and between acc3e8d and 66a3a96.

📒 Files selected for processing (5)
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

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

Files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
app/lib/services/voip/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
app/containers/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Create reusable UI components in app/containers/ directory

Files:

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

Applied to files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📚 Learning: 2026-04-07T17:49:25.836Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-07T17:49:25.836Z
Learning: Applies to **/*.{js,ts,jsx,tsx} : Use explicit error handling with try/catch blocks for async operations

Applied to files:

  • app/lib/services/voip/MediaCallEvents.ts
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to {app/sagas/videoConf.ts,app/lib/methods/videoConf.ts} : Implement video conferencing in app/sagas/videoConf.ts and app/lib/methods/videoConf.ts using Redux actions, reducers, and sagas for server-managed Jitsi integration

Applied to files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📚 Learning: 2026-03-30T15:49:30.957Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6875
File: app/containers/RoomItem/Actions.tsx:12-12
Timestamp: 2026-03-30T15:49:30.957Z
Learning: In RocketChat/Rocket.Chat.ReactNative, `react-native-worklets` version 0.6.1 does NOT export a built-in Jest mock (e.g., no `react-native-worklets/lib/module/mock`). The correct Jest mock approach for this version is to add a manual mock in `jest.setup.js`: `jest.mock('react-native-worklets', () => ({ scheduleOnRN: jest.fn((fn, ...args) => fn(...args)) }))`.

Applied to files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/index.tsx : Set up Redux provider, theme, navigation, and notifications in app/index.tsx

Applied to files:

  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📚 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/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
🪛 ESLint
app/lib/services/voip/CallLifecycle.test.ts

[error] 43-43: Import in body of module; reorder to top.

(import/first)


[error] 45-45: Import in body of module; reorder to top.

(import/first)


[error] 46-46: Import in body of module; reorder to top.

(import/first)


[error] 47-47: Import in body of module; reorder to top.

(import/first)


[error] 48-48: Import in body of module; reorder to top.

(import/first)


[error] 114-114: Use object destructuring.

(prefer-destructuring)


[error] 252-252: Unexpected await inside a loop.

(no-await-in-loop)

🔇 Additional comments (3)
app/lib/services/voip/MediaCallEvents.test.ts (1)

265-277: Good regression coverage for lifecycle delegation paths.

These additions protect both the acceptFailed callId pre-bind path and OS-originated end-call routing through callLifecycle.end('remote').

Also applies to: 375-380

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

71-75: Pre-binding nativeAcceptedCallId here is the right fix.

Stashing the call id before deep-link handoff correctly protects the downstream callId ?? nativeAcceptedCallId teardown path.

app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx (1)

451-463: Nice integration-contract realignment to CallLifecycle/VoipNative seams.

The updated assertions validate the new ownership boundaries (teardown via lifecycle + native command recording) without over-coupling to navigation internals.

Also applies to: 644-676, 825-845

Comment thread app/lib/services/voip/CallLifecycle.test.ts Outdated
Comment thread app/lib/services/voip/CallLifecycle.test.ts Outdated
Comment thread app/lib/services/voip/CallLifecycle.ts
Add CallLifecycle (ordered, idempotent end-of-call teardown) and
CallNavRouter (post-call navigation subscriber).

Teardown order enforced by CallLifecycle._runTeardown:
  1. mediaCall.reject() if ringing, else hangup()
  2. voipNative.call.end(callUuid)
  3. voipNative.call.markActive('')
  4. voipNative.call.markAvailable(callUuid)
  5. useCallStore.reset()
  6. voipNative.call.stopAudio()   ← after reset so callEnded sees clean state
  7. emit callEnded { callId, reason }

Concurrent callers share the in-flight Promise (re-entry guard).
callId uses callId ?? nativeAcceptedCallId (Pre-bind-safe).
'cleanup' reason reserved for slice 08 Pre-bind FSM cleanupAt elapse.

Migrations (six end-call paths now route through lifecycle.end):
- MediaSessionInstance.endCall → lifecycle.end('local')
- useCallStore.endCall → lifecycle.end('local')
- useCallStore.setCall handleEnded → lifecycle.end('remote') (removes inline Navigation.back)
- MediaSessionInstance.init per-call 'ended' listener → lifecycle.end('remote')
- deepLinking.js handleVoipAcceptFailed → lifecycle.end('error')
- CallNavRouter: on callEnded, Navigation.back() if current route is CallView

stopAudio ownership moved from useCallStore.reset to CallLifecycle step 6;
waitForNavigationReady removed from MediaSessionInstance.answerCall.
… call sites

New test files:
- CallLifecycle.test.ts: teardown ordering (steps 2-4,6), hangup vs reject,
  idempotency under concurrent end(), callEnded exactly-once emission,
  callId ?? nativeAcceptedCallId resolution, reason payload threading.
- CallNavRouter.test.ts: event-in to nav-out, subscription only after
  navigationReady, goBack when CallView route, no-op on other routes,
  mount() idempotency, unmount/remount cycle.

Updated tests:
- MediaSessionInstance.test.ts: endCall now verifies lifecycle.end('local')
  delegate instead of direct voipNative commands.
- useCallStore.test.ts: reset() no longer calls stopAudio (owned by
  CallLifecycle step 6); updated assertion documents ownership change.
- VoipCallLifecycle.integration.test.tsx: adapt B1-B3 to new teardown
  routing; 'ended' event path asserts store cleared instead of
  Navigation.back (now owned by CallNavRouter). 3 pre-existing failures
  (A1, C4, E1) remain unchanged from before this slice.
The CallKit/Telecom endCall listener fires when the OS terminates the call,
not when the user presses the in-app end button. The previous code delegated
to mediaSessionInstance.endCall(), which always tagged the reason as 'local'.

Now dispatches callLifecycle.end('remote') directly so the callEnded event
carries the correct reason. MediaSessionInstance.endCall remains the 'local'
(in-app button) path called from CallView.
…dCall test

A1 (answerCall markActive), C4 (setSpeaker on/off), and E1 (stateChange markActive)
assertions were reverted by the previous agent to direct RNCallKeep/InCallManager
mock checks, which slice-04 no longer hits. Restore the InMemoryVoipNative.recorded
seam assertions matching the slice-04 base.

Also remove the re-added `import InCallManager` that was no longer needed.

In MediaCallEvents.test.ts: add a jest.mock for CallLifecycle and update the
endCall test to assert callLifecycle.end('remote') is called rather than the
now-removed mediaSessionInstance.endCall delegation.
Blocker 1: mediaCall.hangup() emits 'ended' synchronously
(@rocket.chat/media-signaling Call.js line 703). The 'ended' listener in
useCallStore re-calls callLifecycle.end('remote') BEFORE the outer
end() finishes assigning _endPromise — bypassing the re-entry guard and
triggering double teardown (callEnded twice, end command twice, infinite
recursion in tests). Defer _runTeardown to a microtask so _endPromise
is captured first; the synchronous re-entrant call now hits the guard
and shares the in-flight promise.

Blocker 3: wrap step 1 (mediaCall.reject/hangup) in try/catch with a
logger.warn so a throw does not abort subsequent teardown steps
(markActive, markAvailable, store reset, stopAudio, callEnded), which
would leak listeners and native CallKit/Telecom state.

Tests: regression for synchronous re-entry asserts callEnded fires
exactly once (toHaveBeenCalledTimes/toHaveLength) and the end command
issues exactly once. Step-1 throw isolation tests assert the remaining
six steps still run when reject/hangup throws. Integration tests
updated to flush the new teardown microtask via await act().
Blocker 2: handleAcceptFailedEvent only forwarded the deep-link payload
without setting nativeAcceptedCallId. The downstream
deepLinking saga (handleVoipAcceptFailed) calls
callLifecycle.end('error'), which resolves the native callId via
`callId ?? nativeAcceptedCallId`. With neither set, end() has no
callUuid and the CallKit/Telecom session is never torn down.

Mirror the success path: call setNativeAcceptedCallId(payload.callId)
before opening the failure deep link so the saga's lifecycle.end()
can issue voipNative.call.end with the right id.

Adds a unit test asserting the failed-accept path stashes the callId.
@diegolmello diegolmello force-pushed the voip-refactor/05-call-lifecycle-end-and-nav-router branch from 66a3a96 to a0e1444 Compare April 30, 2026 17:00
Copy link
Copy Markdown
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: 1

Caution

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

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

156-163: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reintroduce a navigation-ready gate before entering CallView.

This path still runs during cold-start/native-accept flows, but CallNavRouter only handles post-call back navigation. Removing the readiness wait here can drop the initial CallView navigation entirely and leave an accepted call active without UI.

🤖 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 156 - 163,
Reintroduce a navigation-ready gate before navigating to CallView: after
mainCall.accept() and before Navigation.navigate('CallView') (around the block
that calls voipNative.call.markActive, useCallStore.getState().setCall,
setDirection and resolveRoomIdFromContact), await the existing
waitForNavigationReady (or equivalent helper used by CallNavRouter) and only
call Navigation.navigate('CallView') once it resolves; keep the
resolveRoomIdFromContact error handling but ensure the navigation await is done
first so cold-start/native-accept flows never skip initial UI.
♻️ Duplicate comments (4)
app/lib/services/voip/CallNavRouter.test.ts (1)

26-30: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

This test still fails import/first.

Lines 27-30 are imports after executable module code, matching the ESLint errors in the static analysis. Convert these late imports to require(...) after the mocks, or move the mocks below top-level imports.

Suggested fix
-// Import after mocks are set up.
-import { callLifecycle } from './CallLifecycle';
-import { CallNavRouter } from './CallNavRouter';
-import { emitter } from '../../methods/helpers';
-import Navigation from '../../navigation/appNavigation';
+// Import after mocks are set up.
+const { callLifecycle } = require('./CallLifecycle');
+const { CallNavRouter } = require('./CallNavRouter');
+const { emitter } = require('../../methods/helpers');
+const Navigation = require('../../navigation/appNavigation').default;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/CallNavRouter.test.ts` around lines 26 - 30, The test
currently performs executable setup before importing modules (CallLifecycle,
CallNavRouter, emitter, Navigation), triggering import/first ESLint errors; fix
by moving these top-level imports to the very top of CallNavRouter.test.ts
(above any mock/setup code) OR convert the late imports (CallLifecycle,
CallNavRouter, emitter, Navigation) into require(...) calls placed immediately
after the mocks so that all static imports remain first; update references to
use the required bindings and ensure no executable statements precede the static
import statements.
app/lib/services/voip/MediaCallEvents.ts (1)

95-98: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle the teardown Promise in this synchronous dispatcher.

dispatchVoipNativeEvent() is sync, but callLifecycle.end('remote') is async. If teardown rejects here, it escapes as an unhandled Promise rejection from the native end-call path.

Suggested fix
-				callLifecycle.end('remote');
+				void callLifecycle.end('remote').catch(error => {
+					mediaCallLogger.error(`${TAG} callLifecycle.end failed:`, error);
+				});
 				return false;

As per coding guidelines "Use explicit error handling with try/catch blocks for async operations".

🤖 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 95 - 98,
dispatchVoipNativeEvent currently calls the async callLifecycle.end('remote')
synchronously which can produce unhandled rejections; change the call to
explicitly handle the Promise with try/catch or a .catch handler so any
rejection is logged and swallowed. Specifically, in MediaCallEvents' 'endCall'
branch where mediaCallLogger.log(`${TAG} End call event listener:`, e.callUuid)
and callLifecycle.end('remote') are called, wrap the async teardown in an
awaited try/catch (or attach .catch(...)) and use mediaCallLogger.error to
record the error before returning false so the dispatcher stays synchronous and
no unhandled Promise escapes.
app/sagas/deepLinking.js (1)

89-93: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Await the lifecycle teardown before resetVoipState().

This generator calls callLifecycle.end('error') as fire-and-forget, so Line 93 can run before the deferred teardown resolves callId ?? nativeAcceptedCallId. On the accept-failure path, that can clear the only native UUID before CallKit/Telecom teardown runs, and the outer try/catch also won't see a rejected teardown promise.

Suggested fix
-		callLifecycle.end('error');
+		yield call([callLifecycle, callLifecycle.end], 'error');
 		resetVoipState();

As per coding guidelines "Use explicit error handling with try/catch blocks for async operations".

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

In `@app/sagas/deepLinking.js` around lines 89 - 93, The
callLifecycle.end('error') call is being invoked fire-and-forget causing
resetVoipState() to run before teardown; change this to await the teardown and
handle errors explicitly: call await callLifecycle.end('error') inside a
try/catch immediately before calling resetVoipState(), log or rethrow the caught
error as appropriate so the promise rejection is observed and the native
callId/nativeAcceptedCallId is not cleared before CallKit/Telecom teardown
completes (refer to callLifecycle.end and resetVoipState to locate the change).
app/lib/services/voip/useCallStore.ts (1)

202-205: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Both callLifecycle.end(...) calls need explicit rejection handling.

These are synchronous store callbacks, so both branches currently fire async teardown and drop the returned Promise. If CallLifecycle.end() rejects, the failure is unhandled and the caller cannot observe that cleanup broke. A small shared helper that does void ...catch(logger) would cover both sites.

As per coding guidelines "Use explicit error handling with try/catch blocks for async operations".

Also applies to: 274-277

🤖 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 202 - 205, handleEnded
currently calls callLifecycle.end('remote') without handling its returned
Promise; do the same fix at the other site noted (the async teardown call around
lines 274-277). Add a small local helper (e.g. safeEnd = (reason: string) =>
void callLifecycle.end(reason).catch(logger.error)) and replace direct calls to
callLifecycle.end(...) in handleEnded and the other callback with safeEnd(...),
ensuring you reference the existing logger (processLogger / logger) used in this
module so rejections are explicitly caught and logged.
🤖 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`:
- Around line 140-143: The 'ended' emitter handler should ignore stale
post-teardown emissions: change the current call.emitter.on('ended', ...) to
either use a guarded handler or once-removal that checks the session's active
teardown state (e.g., verify this._endPromise is present) before invoking
callLifecycle.end('remote'); also capture the relevant call/context in a local
variable if needed and remove the listener after firing to avoid mapping later
emissions from an old call to callLifecycle.end('remote').

---

Outside diff comments:
In `@app/lib/services/voip/MediaSessionInstance.ts`:
- Around line 156-163: Reintroduce a navigation-ready gate before navigating to
CallView: after mainCall.accept() and before Navigation.navigate('CallView')
(around the block that calls voipNative.call.markActive,
useCallStore.getState().setCall, setDirection and resolveRoomIdFromContact),
await the existing waitForNavigationReady (or equivalent helper used by
CallNavRouter) and only call Navigation.navigate('CallView') once it resolves;
keep the resolveRoomIdFromContact error handling but ensure the navigation await
is done first so cold-start/native-accept flows never skip initial UI.

---

Duplicate comments:
In `@app/lib/services/voip/CallNavRouter.test.ts`:
- Around line 26-30: The test currently performs executable setup before
importing modules (CallLifecycle, CallNavRouter, emitter, Navigation),
triggering import/first ESLint errors; fix by moving these top-level imports to
the very top of CallNavRouter.test.ts (above any mock/setup code) OR convert the
late imports (CallLifecycle, CallNavRouter, emitter, Navigation) into
require(...) calls placed immediately after the mocks so that all static imports
remain first; update references to use the required bindings and ensure no
executable statements precede the static import statements.

In `@app/lib/services/voip/MediaCallEvents.ts`:
- Around line 95-98: dispatchVoipNativeEvent currently calls the async
callLifecycle.end('remote') synchronously which can produce unhandled
rejections; change the call to explicitly handle the Promise with try/catch or a
.catch handler so any rejection is logged and swallowed. Specifically, in
MediaCallEvents' 'endCall' branch where mediaCallLogger.log(`${TAG} End call
event listener:`, e.callUuid) and callLifecycle.end('remote') are called, wrap
the async teardown in an awaited try/catch (or attach .catch(...)) and use
mediaCallLogger.error to record the error before returning false so the
dispatcher stays synchronous and no unhandled Promise escapes.

In `@app/lib/services/voip/useCallStore.ts`:
- Around line 202-205: handleEnded currently calls callLifecycle.end('remote')
without handling its returned Promise; do the same fix at the other site noted
(the async teardown call around lines 274-277). Add a small local helper (e.g.
safeEnd = (reason: string) => void
callLifecycle.end(reason).catch(logger.error)) and replace direct calls to
callLifecycle.end(...) in handleEnded and the other callback with safeEnd(...),
ensuring you reference the existing logger (processLogger / logger) used in this
module so rejections are explicitly caught and logged.

In `@app/sagas/deepLinking.js`:
- Around line 89-93: The callLifecycle.end('error') call is being invoked
fire-and-forget causing resetVoipState() to run before teardown; change this to
await the teardown and handle errors explicitly: call await
callLifecycle.end('error') inside a try/catch immediately before calling
resetVoipState(), log or rethrow the caught error as appropriate so the promise
rejection is observed and the native callId/nativeAcceptedCallId is not cleared
before CallKit/Telecom teardown completes (refer to callLifecycle.end and
resetVoipState to locate the change).
🪄 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: f837037f-0b3e-4d18-b58f-5b087dea04bf

📥 Commits

Reviewing files that changed from the base of the PR and between 66a3a96 and a0e1444.

📒 Files selected for processing (13)
  • app/AppContainer.tsx
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/sagas/deepLinking.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/AppContainer.tsx
  • app/lib/services/voip/CallNavRouter.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallNavRouter.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

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

Files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallNavRouter.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallNavRouter.test.ts
app/lib/services/voip/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallNavRouter.test.ts
app/containers/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Create reusable UI components in app/containers/ directory

Files:

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

Applied to files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallNavRouter.test.ts
📚 Learning: 2026-03-30T15:49:30.957Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6875
File: app/containers/RoomItem/Actions.tsx:12-12
Timestamp: 2026-03-30T15:49:30.957Z
Learning: In RocketChat/Rocket.Chat.ReactNative, `react-native-worklets` version 0.6.1 does NOT export a built-in Jest mock (e.g., no `react-native-worklets/lib/module/mock`). The correct Jest mock approach for this version is to add a manual mock in `jest.setup.js`: `jest.mock('react-native-worklets', () => ({ scheduleOnRN: jest.fn((fn, ...args) => fn(...args)) }))`.

Applied to files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
📚 Learning: 2026-04-30T16:41:37.607Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7279
File: app/lib/services/voip/playCallEndedSound.ts:39-46
Timestamp: 2026-04-30T16:41:37.607Z
Learning: In `app/lib/services/voip/playCallEndedSound.ts`, the `isPlaying` lock is intentionally released only via `didJustFinish` (natural completion) plus a 5-second watchdog timer as a stuck-lock safety net. Treating `status.isPlaying === false && status.isLoaded === true` as a terminal state is explicitly avoided because it would falsely trigger on transient audio pauses (focus loss, iOS session interruption, Bluetooth handoff), prematurely releasing the lock and risking doubled playback when audio resumes.

Applied to files:

  • app/lib/services/voip/MediaCallEvents.test.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/useCallStore.ts
📚 Learning: 2026-04-07T17:49:25.836Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-07T17:49:25.836Z
Learning: Applies to **/*.{js,ts,jsx,tsx} : Use explicit error handling with try/catch blocks for async operations

Applied to files:

  • app/lib/services/voip/MediaCallEvents.ts
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to {app/sagas/videoConf.ts,app/lib/methods/videoConf.ts} : Implement video conferencing in app/sagas/videoConf.ts and app/lib/methods/videoConf.ts using Redux actions, reducers, and sagas for server-managed Jitsi integration

Applied to files:

  • app/lib/services/voip/MediaCallEvents.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
📚 Learning: 2026-04-07T17:49:25.836Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-07T17:49:25.836Z
Learning: Applies to **/*.{js,ts,jsx,tsx} : Prefer async/await over .then() chains for handling asynchronous operations

Applied to files:

  • app/lib/services/voip/CallLifecycle.test.ts
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/index.tsx : Set up Redux provider, theme, navigation, and notifications in app/index.tsx

Applied to files:

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

Applied to files:

  • app/lib/services/voip/useCallStore.ts
🪛 ESLint
app/lib/services/voip/CallLifecycle.test.ts

[error] 43-43: Import in body of module; reorder to top.

(import/first)


[error] 45-45: Import in body of module; reorder to top.

(import/first)


[error] 46-46: Import in body of module; reorder to top.

(import/first)


[error] 47-47: Import in body of module; reorder to top.

(import/first)


[error] 48-48: Import in body of module; reorder to top.

(import/first)


[error] 114-114: Use object destructuring.

(prefer-destructuring)


[error] 252-252: Unexpected await inside a loop.

(no-await-in-loop)

app/lib/services/voip/CallNavRouter.test.ts

[error] 27-27: Import in body of module; reorder to top.

(import/first)


[error] 28-28: Import in body of module; reorder to top.

(import/first)


[error] 29-29: Import in body of module; reorder to top.

(import/first)


[error] 30-30: Import in body of module; reorder to top.

(import/first)

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

158-178: Still need per-step isolation after step 1.

mediaCall.reject() / hangup() is protected, but native.call.end(), markActive(), markAvailable(), useCallStore.getState().reset(), and stopAudio() can still throw and abort the remainder of teardown. That would skip callEnded emission and leave navigation/cleanup subscribers waiting forever.

Comment thread app/lib/services/voip/MediaSessionInstance.ts
- CallNavRouter: track and clean up the deferred navigationReady listener
  in unmount(); prevents leak that could re-subscribe callEnded after
  unmount when nav was not yet ready at mount time. Adds regression test.

- deepLinking saga: yield call(callLifecycle.end, 'error') instead of a
  bare invocation so teardown completes before resetVoipState() and
  navigation proceed (avoids race with in-flight teardown).

- MediaCallEvents / useCallStore / MediaSessionInstance: attach .catch()
  to all callLifecycle.end() invocations from synchronous callers so a
  rejection during teardown is logged via mediaCallLogger instead of
  becoming an unhandled promise rejection.

- CallLifecycle: wrap teardown steps 2-6 (native.call.end / markActive /
  markAvailable, useCallStore.reset, native.call.stopAudio) in a local
  safe(label, fn) helper so a throw in any step is logged but does not
  abort the rest of the sequence; callEnded MUST always emit. Adds
  per-step regression tests.

- Test files: reorder imports above jest.mock() to satisfy import/first;
  use destructuring for native.recorded; eslint-disable no-await-in-loop
  in the reason payload sequencing test.
Copy link
Copy Markdown
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: 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/CallLifecycle.ts`:
- Around line 67-72: The emit method in CallLifecycle.ts currently lets a
throwing listener abort the loop and cause end() to reject; wrap each listener
invocation inside a try/catch in emit<K extends keyof EventMap>(event: K,
payload: EventMap[K]) so one subscriber failure doesn't stop notifying the rest
or cause teardown to fail—catch and log the error (using the class logger or
console.error) and continue to the next listener, ensuring end() always
completes even if some callEnded listeners throw.
🪄 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: 2887ff31-595b-46ab-b8e4-e47f200538b2

📥 Commits

Reviewing files that changed from the base of the PR and between a0e1444 and 3227728.

📒 Files selected for processing (8)
  • app/lib/services/voip/CallLifecycle.test.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallNavRouter.test.ts
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/useCallStore.ts
  • app/sagas/deepLinking.js
🚧 Files skipped from review as they are similar to previous changes (3)
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/CallNavRouter.test.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,ts,jsx,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

Files:

  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallLifecycle.test.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallLifecycle.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

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

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

Files:

  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallLifecycle.test.ts
app/lib/services/voip/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

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

Applied to files:

  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallLifecycle.test.ts
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to {app/sagas/videoConf.ts,app/lib/methods/videoConf.ts} : Implement video conferencing in app/sagas/videoConf.ts and app/lib/methods/videoConf.ts using Redux actions, reducers, and sagas for server-managed Jitsi integration

Applied to files:

  • app/sagas/deepLinking.js
  • app/lib/services/voip/useCallStore.ts
📚 Learning: 2026-04-30T17:07:51.020Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7274
File: app/lib/services/voip/MediaCallEvents.ts:0-0
Timestamp: 2026-04-30T17:07:51.020Z
Learning: In the RocketChat/Rocket.Chat.ReactNative codebase, the ESLint configuration enforces `no-void: error`, so floating promises must be handled with `.catch(...)` rather than the `void` prefix. Do not suggest `void somePromise()` as a fix for unhandled promises in this project.

Applied to files:

  • app/sagas/deepLinking.js
📚 Learning: 2026-04-30T16:41:37.607Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7279
File: app/lib/services/voip/playCallEndedSound.ts:39-46
Timestamp: 2026-04-30T16:41:37.607Z
Learning: In `app/lib/services/voip/playCallEndedSound.ts`, the `isPlaying` lock is intentionally released only via `didJustFinish` (natural completion) plus a 5-second watchdog timer as a stuck-lock safety net. Treating `status.isPlaying === false && status.isLoaded === true` as a terminal state is explicitly avoided because it would falsely trigger on transient audio pauses (focus loss, iOS session interruption, Bluetooth handoff), prematurely releasing the lock and risking doubled playback when audio resumes.

Applied to files:

  • app/sagas/deepLinking.js
  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallLifecycle.test.ts
📚 Learning: 2026-04-30T17:07:51.020Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7274
File: app/lib/services/voip/MediaCallEvents.ts:0-0
Timestamp: 2026-04-30T17:07:51.020Z
Learning: In this Rocket.Chat React Native codebase, the ESLint rule `no-void: error` is enforced. When you see a promise returned from an async call that is not awaited (a “floating promise”), do not silence it with the `void somePromise()` pattern. Instead, handle the promise explicitly by attaching `.catch(...)` (or otherwise awaiting/handling the error) so unhandled-rejection risks are addressed in a way that satisfies the existing ESLint configuration.

Applied to files:

  • app/lib/services/voip/CallNavRouter.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/CallLifecycle.ts
  • app/lib/services/voip/CallLifecycle.test.ts
📚 Learning: 2026-03-30T15:49:30.957Z
Learnt from: Rohit3523
Repo: RocketChat/Rocket.Chat.ReactNative PR: 6875
File: app/containers/RoomItem/Actions.tsx:12-12
Timestamp: 2026-03-30T15:49:30.957Z
Learning: In RocketChat/Rocket.Chat.ReactNative, `react-native-worklets` version 0.6.1 does NOT export a built-in Jest mock (e.g., no `react-native-worklets/lib/module/mock`). The correct Jest mock approach for this version is to add a manual mock in `jest.setup.js`: `jest.mock('react-native-worklets', () => ({ scheduleOnRN: jest.fn((fn, ...args) => fn(...args)) }))`.

Applied to files:

  • app/lib/services/voip/CallLifecycle.test.ts
📚 Learning: 2026-04-07T17:49:25.836Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-04-07T17:49:25.836Z
Learning: Applies to **/*.{js,ts,jsx,tsx} : Prefer async/await over .then() chains for handling asynchronous operations

Applied to files:

  • app/lib/services/voip/CallLifecycle.test.ts
🔇 Additional comments (1)
app/lib/services/voip/useCallStore.ts (1)

287-299: Verify no remaining direct reset() paths depend on audio shutdown.

After auditing all direct reset() call sites: the architectural change is sound and properly guarded.

All production paths calling reset() are either (1) within CallLifecycle.end() where step 6 explicitly stops audio, or (2) during session teardown (app disconnect or startup permission check) when no active audio session exists. Test invocations are cleanup fixtures. No call site can leave in-call audio orphaned.

Comment thread app/lib/services/voip/CallLifecycle.ts
The closure-captured `call` in the `newCall` 'ended' subscription could
fire after teardown completed (delayed server signal, late-arriving
event), triggering a spurious second `callLifecycle.end('remote')` and
duplicate `callEnded` event with the wrong reason. Short-circuit when
the active call/callId in the store no longer matches the captured one.

codex: address PR review feedback (#7274)
@diegolmello
Copy link
Copy Markdown
Member Author

[codex] Slice 05 walkthrough — CallLifecycle + CallNavRouter

TL;DR

Slice 05 collapses end-of-call teardown and post-call navigation into two focused modules. Before: teardown logic was scattered across MediaSessionInstance.endCall, useCallStore.endCall / handleEnded, the per-call 'ended' listener, the OS endCall event handler in MediaCallEvents, the handleVoipAcceptFailed saga, and inline Navigation.back() calls in useCallStore. Each path did a slightly different subset of work, in a slightly different order, with no idempotency. After: every end-call path funnels into callLifecycle.end(reason) which runs a single ordered teardown; navigation lives in CallNavRouter, mounted once in AppContainer and reacting to the callEnded event.

CallLifecycle.end() — the heart of the PR

Source: app/lib/services/voip/CallLifecycle.ts. Read this first.

Step 0 — re-entry guard (microtask deferral). end() returns _endPromise if one is in-flight. The body is wrapped in Promise.resolve().then(() => this._runTeardown(reason)) so the assignment to _endPromise happens before _runTeardown runs. This matters: mediaCall.hangup() in step 1 synchronously emits 'ended' (see @rocket.chat/media-signaling Call.js line 703); the useCallStore handleEnded listener synchronously re-calls callLifecycle.end('remote'). Without the microtask deferral, that re-entry would arrive before _endPromise was assigned and would start a second teardown. With it, the re-entrant call sees the same in-flight promise and is absorbed.

Steps 1–7 (in order):

  1. mediaCall.reject() if state === 'ringing', else mediaCall.hangup() — read from useCallStore.getState().call (MediaSessionInstance still owns the active call; lifecycle only reads it)
  2. voipNative.call.end(effectiveCallId) — tear down CallKit / Telecom session
  3. voipNative.call.markActive('') — clear "active" indicator
  4. voipNative.call.markAvailable(effectiveCallId ?? '') — release the device for new calls
  5. useCallStore.getState().reset() — clear JS call state
  6. voipNative.call.stopAudio() — explicitly after reset, so subscribers reading the store on callEnded see clean state. stopAudio ownership was moved here from useCallStore.reset().
  7. emitter.emit('callEnded', { callId: effectiveCallId, reason })

effectiveCallId = callId ?? nativeAcceptedCallId — Pre-bind-safe (an OS-accepted call may not yet be bound to a JS call object).

safe(label, fn) wrapper. Each step is wrapped in safe, a local try/catch + logger.warn helper. A throw in any step (e.g. native.call.end rejects, useCallStore.reset blows up) is logged but does not abort the rest. Without this, a single failure would leak listeners and leave native state stale. callEnded is guaranteed to fire.

Mini-emitter idempotency. CallLifecycleEmitter.emit snapshot-iterates [...set] and try/catches per-listener. So a throwing callEnded subscriber cannot (a) skip downstream listeners or (b) propagate up and reject the awaited _endPromise. That last property matters specifically for the saga path — yield call(callLifecycle.end, 'error') must not reject just because some unrelated listener threw.

CallNavRouter — mount-once, unmount-clean

Source: app/lib/services/voip/CallNavRouter.ts. Mounted once from AppContainer after NavigationContainer renders.

  • Subscription to callEnded is gated on navigationReady because Navigation.getCurrentRoute() returns nothing until the container is ready. If the ref is already populated (hot-reload), it subscribes immediately; otherwise it waits on the emitter.on('navigationReady', ...) event from AppContainer.onReady.
  • On callEnded: if getCurrentRoute().name === 'CallView', calls Navigation.back(). Other routes are left alone.
  • Two unsubscribe handles — _unsubscribeCallEnded and _unsubscribeNavigationReady — both cleared in unmount(). The latter exists because mitt lacks once(); the manual once-shim leaks unless unmount() can find and detach a not-yet-fired navigationReady listener.

All callLifecycle.end() call sites

Site File Reason Context
OS endCall (CallKit / Telecom) MediaCallEvents.ts:97 'remote' sync; .catch() logs
MediaCall 'ended' (store handler) useCallStore.ts:208 (handleEnded) 'remote' sync; .catch() logs
In-app hangup button useCallStore.ts:282 (endCall) 'local' sync; .catch() logs
Per-call 'ended' listener MediaSessionInstance.ts:150 (init newCall) 'remote' sync; stale-call guard: bails if activeCall.callId !== call.callId && activeCallId !== call.callId
MediaSessionInstance.endCall MediaSessionInstance.ts:223 'local' sync; one-line delegate
handleVoipAcceptFailed saga app/sagas/deepLinking.js:94 'error' awaited via yield call([callLifecycle, callLifecycle.end], 'error') so resetVoipState() and waitForNavigationReady() cannot race teardown

The acceptFailed event handler (MediaCallEvents.handleAcceptFailedEvent) stashes nativeAcceptedCallId before opening the failure deep link. That's how the saga's later end('error') can resolve effectiveCallId and actually tear down the native session — without it, the failure path was leaking the CallKit / Telecom call.

Invariants the tests lock in

  • Concurrent end() callers share one in-flight promise (_endPromise re-entry guard).
  • Synchronous re-entry from inside teardown (microtask deferral) hits that same guard.
  • A throw in any step 1–6 is logged and the remaining steps still run; callEnded always emits.
  • A throwing callEnded listener does not reject the awaited end() promise and does not skip subsequent listeners.
  • A stale 'ended' emission on a captured call whose id no longer matches the active store state is ignored (no double teardown, no spurious callEnded).
  • acceptFailed stashes nativeAcceptedCallId so the saga's end('error') can resolve the right id.
  • CallNavRouter does not subscribe callEnded before navigationReady and does not retain a navigationReady listener after unmount().

Strict-once tests assert toHaveBeenCalledTimes(1) and events.toHaveLength(1) rather than toContainEqual, so duplicate emissions cannot pass silently.

Not in this slice (scope guard)

  • Slice 06answerIncoming / beginOutgoing lifecycle methods (the start-of-call counterparts of end).
  • Slice 07 — toggle methods (mute / hold / speaker) routed through the lifecycle / native seam.
  • Slice 08 — Pre-bind FSM (cleanupAt-driven cleanup; the 'cleanup' reason in CallEndReason is reserved for it but has no producer yet).

@diegolmello diegolmello merged commit 6465723 into voip-refactor/04-voip-native-attach-and-cold-start Apr 30, 2026
3 checks passed
@diegolmello diegolmello deleted the voip-refactor/05-call-lifecycle-end-and-nav-router branch April 30, 2026 17:26
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