Skip to content

fix(voip): recover answer-call and start-call failure paths#7281

Merged
diegolmello merged 2 commits into
feat.voip-lib-newfrom
fix/voip-pr6918-04-media-session-error-recovery
Apr 30, 2026
Merged

fix(voip): recover answer-call and start-call failure paths#7281
diegolmello merged 2 commits into
feat.voip-lib-newfrom
fix/voip-pr6918-04-media-session-error-recovery

Conversation

@diegolmello

@diegolmello diegolmello commented Apr 30, 2026

Copy link
Copy Markdown
Member

Proposed changes

Two coupled fixes to the JS media-session lifecycle that prevent stuck call states.

Answer-call error recovery (B5): The accept() await in answerCall is now wrapped in a try/catch. When it rejects (signalling timeout, server reject, ICE failure, peer abort), the native call is terminated via terminateNativeCall, nativeAcceptedCallId is reset so the active-call guard releases immediately, and the user sees a localised "Could not answer call." alert. Previously a rejection left CallKit/Telecom stuck in "answering" state for the full 60-second timeout and blocked any new call attempt.

Start-call post-permission guard (B6): startCall re-evaluates isInActiveVoipCall() after awaiting requestVoipCallPermissions. On Android the microphone permission prompt can take several seconds; if the user accepts an incoming call from the heads-up notification while it is on screen, the guard transitions from false to true during that window. The re-evaluation throws the same VoIP_Already_In_Call error used in the original synchronous check, preventing a second concurrent call session from starting.

Optimistic roomId cleanup: startCallByRoom sets roomId optimistically before calling startCall. When the new B6 guard rejects after the permission await, the catch in startCallByRoom now resets roomId to null so a concurrent incoming call can resolve its own DM context instead of being suppressed by the stale value.

Issue(s)

Part of PR #6918 fix series.

How to test or reproduce

B5: With a real device, simulate a signalling failure (kill WebSocket while tapping Accept). Within ~1 second an alert "Could not answer call." appears; the call guard releases and a new outgoing call can be placed immediately.

B6: On Android, start an outgoing call; while the microphone permission prompt is on screen, accept an incoming call from the system notification. After granting (or denying) the permission, no second call session is started, and the incoming call's DM context resolves correctly (no stale roomId from the aborted outgoing attempt).

Screenshots

N/A

Types of changes

  • Bugfix (non-breaking change which fixes an issue)

Checklist

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

Further comments

New i18n key VoIP_Answer_Failed added to en.json; other locales follow the standard translation flow.

Summary by CodeRabbit

  • New Features

    • Added user-facing error message for failed VoIP call answering.
  • Bug Fixes

    • Ensured failed call answers clean up call state and show the new error message.
    • Prevented call-start conflicts when permissions are requested while an incoming call arrives.
    • Cleared temporary room selection if initiating a call by room fails, avoiding incorrect call bindings.

Wrap answerCall's accept() in a try/catch so signalling rejections terminate the native call, reset nativeAcceptedCallId, and surface a localised alert instead of leaving CallKit stuck. Re-evaluate isInActiveVoipCall() after the microphone permission await in startCall so a call accepted from a system notification during the prompt cannot start a second concurrent session.
@coderabbitai

coderabbitai Bot commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f4780fa-0a28-4e2e-9a26-e68d4683164c

📥 Commits

Reviewing files that changed from the base of the PR and between 914616c and 57da021.

📒 Files selected for processing (2)
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts

Walkthrough

Adds error handling for VoIP answer failures (terminating native calls, clearing native call state, and showing an error alert) and revalidates active-call state after permission prompts for outgoing calls; also adds one new i18n key.

Changes

Cohort / File(s) Summary
VoIP Translations
app/i18n/locales/en.json
Added new translation key VoIP_Answer_Failed.
VoIP Service Implementation
app/lib/services/voip/MediaSessionInstance.ts
Wraps mainCall.accept() in answerCall with try/catch to terminate native calls and reset native call state on failure; startCall re-checks active-call state after permission prompt; startCallByRoom clears optimistic roomId if call start fails.
VoIP Service Tests
app/lib/services/voip/MediaSessionInstance.test.ts
Added tests for answerCall failure path (ensure native termination, state reset, alert, and no navigation/setCall) and for startCall permission-guard behavior including optimistic roomId rollback in startCallByRoom.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant MediaSessionInstance
    participant MainCall
    participant NativeLayer
    participant AlertSystem

    User->>MediaSessionInstance: answerCall()
    MediaSessionInstance->>MainCall: accept()
    MainCall-->>MediaSessionInstance: reject/error
    MediaSessionInstance->>NativeLayer: terminateNativeCall(callId)
    NativeLayer-->>MediaSessionInstance: terminated
    MediaSessionInstance->>MediaSessionInstance: reset nativeAcceptedCallId (if matching)
    MediaSessionInstance->>AlertSystem: showErrorAlert("VoIP_Answer_Failed")
    AlertSystem-->>User: error displayed
Loading
sequenceDiagram
    participant User
    participant MediaSessionInstance
    participant PermissionSystem
    participant VoIPSession

    User->>MediaSessionInstance: startCall(...)
    MediaSessionInstance->>PermissionSystem: requestVoipCallPermissions()
    PermissionSystem-->>MediaSessionInstance: permissions result
    MediaSessionInstance->>MediaSessionInstance: re-check isInActiveVoipCall
    alt another call became active
        MediaSessionInstance-->>User: reject with VoIP_Already_In_Call
    else no active call
        MediaSessionInstance->>VoIPSession: instance.startCall(...)
        VoIPSession-->>MediaSessionInstance: call started
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

type: bug

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly summarizes the main changes: fixing VoIP answer-call and start-call failure paths to handle error recovery scenarios.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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


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

❤️ Share
Review rate limit: 3/8 reviews remaining, refill in 31 minutes and 7 seconds.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 218-221: When the guard in MediaSessionInstance that checks
isInActiveVoipCall() aborts, clear the optimistic room context set by
startCallByRoom() to avoid leaving a stale roomId; either defer assigning roomId
until after startCall() succeeds or explicitly reset the provisional roomId on
this error path (e.g., undo the assignment made by startCallByRoom() before
throwing). Locate references to startCallByRoom(), startCall(),
isInActiveVoipCall(), and the roomId field in MediaSessionInstance and ensure
the failure branch clears or unsets roomId so later DM-room resolution is not
suppressed.
🪄 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: aa6a1e12-1337-49a9-9c43-c88fc461e7eb

📥 Commits

Reviewing files that changed from the base of the PR and between 7ea0877 and 914616c.

📒 Files selected for processing (3)
  • app/i18n/locales/en.json
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: ESLint and Test / run-eslint-and-test
🧰 Additional context used
📓 Path-based instructions (5)
app/i18n/**/*.{ts,tsx,json}

📄 CodeRabbit inference engine (CLAUDE.md)

Implement i18n translations in app/i18n/ directory with i18n-js supporting 40+ locales and RTL support

Files:

  • app/i18n/locales/en.json
**/*.{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/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.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/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.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/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
app/lib/services/voip/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

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

Files:

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

Applied to files:

  • app/lib/services/voip/MediaSessionInstance.test.ts
📚 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/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
🔇 Additional comments (3)
app/i18n/locales/en.json (1)

999-999: Looks good.

This new key matches the recovery path in answerCall and keeps the localized message centralized with the rest of the VoIP strings.

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

156-166: Good recovery path.

Catching accept() here, terminating the native call, and short-circuiting before navigation/setCall is the right failure handling for this race.

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

800-884: Good coverage for both recovery paths.

These tests exercise the native-call termination on accept() rejection and the post-permission active-call recheck, which should make the regression much harder to reintroduce.

Comment thread app/lib/services/voip/MediaSessionInstance.ts
When startCallByRoom() optimistically sets roomId before calling
startCall(), and the new B6 post-permission active-call re-evaluation
throws (because an incoming call was accepted during the permission
prompt), the catch in startCallByRoom() previously only logged the
error. The stale roomId would remain in the call store and could
suppress DM resolution for the just-accepted incoming call.

Reset roomId to null on the catch path. Add a regression test that
asserts setRoomId is called with the optimistic rid first and with
null after the post-permission guard rejects.
@diegolmello diegolmello had a problem deploying to experimental_android_build April 30, 2026 19:27 — with GitHub Actions Failure
@diegolmello diegolmello had a problem deploying to official_android_build April 30, 2026 19:27 — with GitHub Actions Failure
@diegolmello diegolmello had a problem deploying to experimental_ios_build April 30, 2026 19:27 — with GitHub Actions Failure
@diegolmello diegolmello merged commit bcc3f57 into feat.voip-lib-new Apr 30, 2026
5 of 10 checks passed
@diegolmello diegolmello deleted the fix/voip-pr6918-04-media-session-error-recovery branch April 30, 2026 20:01
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