Skip to content

fix(voip): Android lock-screen accept — silent audio + subsequent-call blank screen#7212

Merged
diegolmello merged 14 commits intofeat.voip-lib-newfrom
voip/android-lockscreen-speaker
Apr 24, 2026
Merged

fix(voip): Android lock-screen accept — silent audio + subsequent-call blank screen#7212
diegolmello merged 14 commits intofeat.voip-lib-newfrom
voip/android-lockscreen-speaker

Conversation

@diegolmello
Copy link
Copy Markdown
Member

@diegolmello diegolmello commented Apr 22, 2026

Proposed changes

Fixes two VoIP bugs on Android when accepting an incoming call from the lock screen while the app is not running:

  1. Silent audio on first call — The Android same-workspace branch of getInitialMediaCallEvents returned true, which skipped appInit(). This prevented the login saga from running, so mediaSessionInstance.init() never fired, applyRestStateSignals() never processed the offer SDP, and audio never routed. Fix: return false on Android so the normal boot path runs (gated to Android-only — iOS retains existing behavior).

  2. Blank screen on subsequent calls — The Android VoipCallService foreground service was never stopped from JS-driven hangup paths. Its static isRunning flag blocked re-initialization on the next incoming call. Fix: new stopVoipCallService bridge method + terminateNativeCall JS helper that wraps RNCallKeep.endCall + Android service stop at every call termination site.

Additional hardening:

  • Navigator readiness guard: answerCall now awaits waitForNavigationReady() before navigating to CallView, preventing silent navigation drops during cold-start boot.
  • Stale timer extension: STALE_NATIVE_MS raised from 15s to 60s to cover worst-case cold-boot handoff on slow devices/networks.
  • Eager isRunning reset: VoipCallService.onStartCommand(ACTION_STOP) now resets isRunning before stopSelf() to prevent race conditions with rapid call cycling.

Issue(s)

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

How to test or reproduce

  1. Cold-start lock-screen accept: Kill the Rocket.Chat app on an Android device (Pixel 6 / Android 16). Initiate a VoIP call from the web client. Accept from the device lock screen. Expected: CallView renders and audio routes within 5–8 seconds.
  2. Subsequent calls: End the first call. Initiate a second VoIP call. Accept from lock screen. Expected: identical behavior — no blank screen, no force-close needed.
  3. Warm-start regression: With the app already running, accept an incoming VoIP call. Expected: unchanged behavior.
  4. iOS regression: Accept a VoIP call from iOS lock screen. Expected: unchanged behavior.
  5. Different-workspace deep-link: Accept a call hosted on a different workspace. Expected: workspace switch + call connect as before.

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)

Merge Order

This PR targets feat.voip-lib-new (PR #6918). It should be merged into feat.voip-lib-new before #6918 is merged into develop.

Further comments

Files changed:

  • android/.../voip/VoipModule.kt — new stopVoipCallService bridge method
  • android/.../voip/VoipCallService.kt — eager isRunning reset on ACTION_STOP
  • app/lib/native/NativeVoip.ts — TurboModule spec + fallback for stopVoipCallService
  • app/lib/services/voip/terminateNativeCall.ts — new helper wrapping endCall + service stop
  • app/lib/services/voip/MediaCallEvents.ts — Android same-workspace branch returns false
  • app/lib/services/voip/MediaSessionInstance.tswaitForNavigationReady guard + terminateNativeCall migration
  • app/lib/services/voip/useCallStore.tsSTALE_NATIVE_MS 15s → 60s + terminateNativeCall migration
  • app/lib/navigation/appNavigation.ts — extracted waitForNavigationReady helper
  • app/sagas/deepLinking.js — uses shared waitForNavigationReady instead of inline version
  • Tests updated accordingly

Summary by CodeRabbit

  • New Features

    • App can now request stopping the VoIP foreground call service from the JS layer.
  • Bug Fixes

    • Improved VoIP termination and cleanup to reduce stale native call remnants.
    • Call UI navigation now waits for readiness before transitioning for more reliable call flows.
    • Increased native-call stale timeout from 15s to 60s to reduce premature state clearing.
    • Centralized native call teardown to suppress platform/bridge errors.
  • Tests

    • Updated mocks and integration tests to cover navigation readiness and VoIP flows.

Remove the early `return true` (and the no-op `applyRestStateSignals` call)
from the same-workspace branch of `getInitialMediaCallEvents`. Returning
`false` lets `app/index.tsx` dispatch `appInit()`, which runs the login saga,
which calls `mediaSessionInstance.init(userId)`, which replays the buffered
signals and ultimately answers the call — fixing silent audio on cold-start
lock-screen accept.
…ch change

- Gate cold-start return-false to Android only; iOS keeps existing behavior
- Update stale timer test expectations from 15s to 60s
- Import emitter directly to avoid barrel-pulling deviceInfo
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

Walkthrough

Android VoIP service stop now clears the internal running flag before stopping; a new JS-native hook to stop the service is added. Call termination is centralized into a new helper, navigation readiness is awaited before navigation, and the stale native-call timeout is increased to 60 seconds.

Changes

Cohort / File(s) Summary
Android VoIP Service Control
android/app/src/main/java/chat/rocket/reactnative/voip/VoipCallService.kt, android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
VoipCallService sets isRunning = false on ACTION_STOP before calling stopSelf(startId); VoipModule adds a TurboModule method stopVoipCallService() that delegates to the native stop routine.
JS -> Native VoIP API
app/lib/native/NativeVoip.ts
Adds stopVoipCallService() to the TurboModule spec with a no-op default implementation and platform behavior documented.
Call Termination Centralization
app/lib/services/voip/terminateNativeCall.ts, app/lib/services/voip/MediaSessionInstance.ts, app/lib/services/voip/useCallStore.ts
Introduces terminateNativeCall(callId) which calls RNCallKeep.endCall (safe-guarded) and, on Android, invokes NativeVoipModule.stopVoipCallService(); replaces direct RNCallKeep.endCall usages with this helper.
Navigation Readiness
app/lib/navigation/appNavigation.ts, app/sagas/deepLinking.js, app/lib/services/voip/MediaSessionInstance.ts
Adds waitForNavigationReady() and updates deep-linking and call-answer flows to await navigation readiness before performing navigation.
Cold-start / Handoff Behavior
app/lib/services/voip/MediaCallEvents.ts
For non-iOS same-workspace cold starts, defers to the default startup path (returns false) so appInit handoff continues; iOS behavior remains to apply REST state signals.
Stale Native Call Timeout
app/lib/services/voip/useCallStore.ts, app/lib/services/voip/useCallStore.test.ts
Increases stale native-accepted-call window from 15s to 60s and updates tests to use the new timing.
Tests & Mocks
app/lib/services/voip/MediaSessionInstance.test.ts, app/containers/NewMediaCall/VoipCallLifecycle.integration.test.tsx
Updates mocks for ESM-style react-native-device-info default export, adds waitForNavigationReady stub to navigation mocks, and adjusts tests for readiness and timing changes.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant AppJS as App (JS)
    participant Nav as Navigation
    participant CallKeep as RNCallKeep
    participant AndroidSvc as Android VoIP Service

    User->>AppJS: trigger end/answer
    AppJS->>Nav: waitForNavigationReady()
    Nav-->>AppJS: ready
    AppJS->>CallKeep: terminateNativeCall(callId)
    CallKeep->>CallKeep: RNCallKeep.endCall(callId)
    CallKeep->>AndroidSvc: stopVoipCallService()
    AndroidSvc->>AndroidSvc: isRunning = false
    AndroidSvc-->>CallKeep: stopped
    CallKeep-->>AppJS: termination complete
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested labels

type: bug

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.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
Title check ✅ Passed The title directly and accurately reflects the main fixes: it identifies the two critical Android VoIP issues being resolved (silent audio on lock-screen accept and blank screen on subsequent calls) and specifies the platform and context.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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


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

❤️ Share

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

@diegolmello
Copy link
Copy Markdown
Member Author

Code Review

Files reviewed: 10
Verdict: APPROVE — the two bug fixes are correct and well-scoped. A few observations below, none blocking.


Fix 1 — Silent audio on cold-start lock-screen accept (MediaCallEvents.ts)

Correctness: sound.

The original code called applyRestStateSignals() and returned true (skip appInit) on Android for the same-workspace branch. That prevented the login saga from running, which in turn meant mediaSessionInstance.init() and the WebRTC offer/answer exchange never happened — explaining the silent call.

The fix returns false on Android, letting appInit run normally. iOS retains the original applyRestStateSignals() path. The platform guard is minimal and precise.

One thing worth noting: the log message on the new Android branch says "continuing appInit for cold-start handoff" but the return value false is what actually triggers appInit. Readers have to know that false means "don't skip appInit" to parse the log correctly. A comment like // false = do not skip appInit next to the return false would remove that ambiguity. Minor.


Fix 2 — Blank screen on subsequent calls (VoipCallService.kt + VoipModule.kt + terminateNativeCall.ts)

Correctness: sound.

The root cause was isRunning staying true after JS-initiated hangup, blocking re-initialization on the next call. The fix covers all three necessary sites:

  • VoipCallService.onStartCommand(ACTION_STOP) now resets isRunning = false before stopSelf(). This is the correct ordering — resetting after stopSelf would leave a window where a rapid new ACTION_START could arrive and be skipped.
  • stopVoipCallService bridge method is straightforward; the try/catch in both the Kotlin bridge and the JS helper is appropriate.
  • terminateNativeCall consolidates all call-end paths (MediaSessionInstance, useCallStore) into one helper. The migration is complete — no leftover bare RNCallKeep.endCall calls remain at hangup sites.

Minor observation: stopService(context) in the companion object sends ACTION_STOP via context.stopService(intent), but ACTION_STOP is only checked in onStartCommand. stopService() from outside the service goes through onStartCommand only if the service is currently running (the system restarts it with the stop intent). If the service was already destroyed (e.g. system killed it), stopService is a no-op, which is fine here — isRunning is already false from onDestroy. No issue.


Navigator readiness guard (MediaSessionInstance.ts)

await waitForNavigationReady() before Navigation.navigate('CallView') is the right fix for cold-start navigation drops. The implementation in appNavigation.ts correctly checks navigationRef.current as the fast-path, then falls back to the 'navigationReady' event emitted from AppContainer.tsx's onReady callback. No leak risk — the listener is cleaned up on first fire.

Minor observation: waitForNavigationReady has no timeout. On a device where NavigationContainer never calls onReady (e.g. a crash during render), the promise hangs forever. For a call flow this is a real edge case. A 10–15 second timeout with a fallback log would make this more robust. Not blocking for this PR's scope, but worth a follow-up.


waitForNavigationReady extraction (appNavigation.ts / deepLinking.js)

Clean deduplication — the inline waitForNavigation in deepLinking.js was functionally identical and is now removed in favor of the shared export. No behavior change for that saga.


Stale timer 15s → 60s (useCallStore.ts)

The extension is reasonable given cold-boot on slow devices/networks. Tests are updated consistently. No issues.


Test coverage

The mock additions to MediaSessionInstance.test.ts (react-native-device-info default export, waitForNavigationReady) are correct and sufficient for the changed code paths. The useCallStore.test.ts timer updates are mechanical but accurate.

Gap worth noting: there is no test for terminateNativeCall itself — specifically that stopVoipCallService is called on Android but not on iOS. Given that this helper is now used in four places, a small unit test covering the platform branching would be a useful addition in a follow-up.


Positive observations

  • The isRunning = false placement before stopSelf() in onStartCommand shows careful attention to ordering under concurrent conditions.
  • The fallback no-op object in NativeVoip.ts is correctly updated — this prevents crashes if the TurboModule is unavailable before bridge init.
  • The PR scope is tight: only files directly related to the two bugs are touched, with no unrelated cleanup.

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/useCallStore.ts (1)

83-96: ⚠️ Potential issue | 🟡 Minor

Update stale JSDoc comments to reflect the new 60s timer.

STALE_NATIVE_MS was increased to 60s (line 11), but the JSDoc comments at lines 84 and 95 still describe a "15s timer". Fix to avoid confusing future maintainers.

🧹 Proposed change
-	/** Sets native-accepted call id and (re)starts the 15s timer. */
+	/** Sets native-accepted call id and (re)starts the stale-native timer. */
 	setNativeAcceptedCallId: (callId: string) => void;
 	/** Clears native-accepted id and related state; cancels the timer. */
 	resetNativeCallId: () => void;
...
-	/** Clears UI/call fields but keeps nativeAcceptedCallId. Restarts the 15s timer (media init calls reset and clears the old timer first). */
+	/** Clears UI/call fields but keeps nativeAcceptedCallId. Restarts the stale-native timer (media init calls reset and clears the old timer first). */
 	reset: () => void;
🤖 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 83 - 96, Update the
outdated JSDoc that references a "15s timer" to "60s timer" for the
CallStoreActions methods so comments match the new STALE_NATIVE_MS value;
specifically change the documentation on setNativeAcceptedCallId and reset (and
any other nearby JSDoc in the same interface) to state that they start/restart
or clear the 60s timer respectively, ensuring the text mentions "60s" and not
"15s".
🧹 Nitpick comments (5)
app/lib/services/voip/MediaSessionInstance.ts (2)

138-163: answerCall may navigate to CallView after the call has already ended.

On cold start, if waitForNavigationReady() takes a long time (or indefinitely, per the no-timeout concern flagged in appNavigation.ts), and the user hangs up / the peer ends the call before navigation is ready, this will still Navigation.navigate('CallView') after setCall state has been reset by the ended handler, briefly showing an empty/stale call screen.

Consider re-checking the call state (or useCallStore.getState().call) after the await, and skipping the navigate if the call has already been torn down:

♻️ Proposed guard
 			await mainCall.accept();
 			RNCallKeep.setCurrentCallActive(callId);
 			useCallStore.getState().setCall(mainCall);
 			await waitForNavigationReady();
+			if (useCallStore.getState().call?.callId !== callId) {
+				return;
+			}
 			Navigation.navigate('CallView');
🤖 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 138 - 163, The
answerCall flow can navigate to CallView after the call was torn down while
waiting for waitForNavigationReady(); after awaiting waitForNavigationReady()
(inside answerCall) re-check the current call state via
useCallStore.getState().call (or compare useCallStore.getState().call?.callId to
callId) and only call Navigation.navigate('CallView') if the call still exists
and matches callId; if the call has been cleared/changed, skip navigation and
avoid setting stale state—apply this guard right before
Navigation.navigate('CallView') in the answerCall method.

115-135: ended listener registered here is never removed.

On every newCall emission for a non-hidden call, a new 'ended' listener is attached to call.emitter (line 131) and never removed. In parallel, useCallStore.setCall registers its own 'ended' listener with explicit cleanup. For the callee path, both run when the call ends — which works — but the listener here has no matching .off and will stick around for the lifetime of the call object. Minor per-call leak; worth adding a one-shot pattern (emitter.once(...)) or tracking the handler for removal. Also note that the stateChange listener on line 117 is intentionally empty but similarly not removed.

🤖 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 115 - 135, The
newCall handler registers persistent listeners on call.emitter
(call.emitter.on('ended') and call.emitter.on('stateChange')) that are never
removed; change these to one-shot listeners or register removable handlers and
remove them when the call ends. Specifically, update the instance?.on('newCall',
...) block so the stateChange listener is either added with once or returns a
handler reference you call off on, and replace call.emitter.on('ended', ...)
with call.emitter.once('ended', ...) or store the ended handler and remove it in
the call end flow (coordinating with useCallStore.getState().setCall if needed)
to prevent the leak. Ensure you keep the existing
terminateNativeCall(call.callId) behavior when the listener fires.
app/sagas/deepLinking.js (1)

71-71: Use yield call(...) for consistency and saga testability.

At line 93, waitForNavigationReady is correctly invoked via yield call(waitForNavigationReady), but here on line 71 the Promise is yielded directly. While redux-saga supports yielding Promises, call(...) produces a declarative effect that is easier to test and supports cancellation/mocking uniformly. Align with the other call site for consistency.

♻️ Proposed change
-				yield waitForNavigationReady();
+				yield call(waitForNavigationReady);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/sagas/deepLinking.js` at line 71, Replace the direct Promise yield with a
declarative saga call: change the usage of waitForNavigationReady in
deepLinking.js from yielding the Promise (yield waitForNavigationReady()) to
using the redux-saga effect (yield call(waitForNavigationReady)); ensure call
from 'redux-saga/effects' is imported (it's already used elsewhere in this file)
so tests and cancellation/mocking behave consistently with the other call site.
app/lib/navigation/appNavigation.ts (1)

79-90: Consider adding a timeout for defensive safety.

The waitForNavigationReady() helper has no timeout: if the navigationReady event never fires (e.g., NavigationContainer fails to mount or initialize), awaiting this promise will hang forever. While this is unlikely in normal operation, adding a safety timeout would help catch critical initialization failures.

Optional: Add a timeout that rejects or resolves after a bounded duration (e.g., 5–10 seconds) to prevent indefinite hangs in edge cases where navigation initialization fails.

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

In `@app/lib/navigation/appNavigation.ts` around lines 79 - 90,
waitForNavigationReady can hang forever if 'navigationReady' never fires; modify
the function to add a safety timeout (e.g., 5–10 seconds) that rejects the
promise with a clear Error (or resolves depending on desired behavior) so
callers don't wait indefinitely. Keep the early return when
navigationRef.current is present; when creating the new Promise, start a timer
(store its id), and in both the emitter listener (listener) and the timeout
handler ensure you clear the timer and remove the listener via
emitter.off('navigationReady', listener) before resolving/rejecting so there are
no leaks; update callers if you change rejection vs resolution behavior.
app/lib/services/voip/MediaSessionInstance.test.ts (1)

86-97: Minor: duplicated jest.fn() instances between default and named exports.

Each named mock (getUniqueId, getUniqueIdSync, hasNotch, getReadableVersion) is instantiated twice — once under default and once at the top level — producing independent spies. Tests asserting against one set won't observe calls made through the other, which can silently hide regressions if consumer code switches between default and named imports. Consider defining the mocks once and sharing them across both shapes.

♻️ Proposed refactor
-jest.mock('react-native-device-info', () => ({
-	default: {
-		getUniqueId: jest.fn(() => 'test-device-id'),
-		getUniqueIdSync: jest.fn(() => 'test-device-id'),
-		hasNotch: jest.fn(() => false),
-		getReadableVersion: jest.fn(() => '1.0.0')
-	},
-	getUniqueId: jest.fn(() => 'test-device-id'),
-	getUniqueIdSync: jest.fn(() => 'test-device-id'),
-	hasNotch: jest.fn(() => false),
-	getReadableVersion: jest.fn(() => '1.0.0')
-}));
+jest.mock('react-native-device-info', () => {
+	const api = {
+		getUniqueId: jest.fn(() => 'test-device-id'),
+		getUniqueIdSync: jest.fn(() => 'test-device-id'),
+		hasNotch: jest.fn(() => false),
+		getReadableVersion: jest.fn(() => '1.0.0')
+	};
+	return { __esModule: true, default: api, ...api };
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/lib/services/voip/MediaSessionInstance.test.ts` around lines 86 - 97, The
mock duplicates separate jest.fn instances for the device-info exports causing
independent spies; consolidate by creating shared mock functions (e.g.,
sharedGetUniqueId, sharedGetUniqueIdSync, sharedHasNotch,
sharedGetReadableVersion) and use those same mock functions for both the default
export and the named exports in the jest.mock call so tests watching
getUniqueId/getUniqueIdSync/hasNotch/getReadableVersion observe the same spy
across default and named import shapes.
🤖 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/terminateNativeCall.ts`:
- Around line 6-15: In terminateNativeCall, guard RNCallKeep.endCall so a thrown
error there doesn't prevent the Android cleanup: call RNCallKeep.endCall(callId)
inside its own try/catch (or use try/finally) and ensure
NativeVoipModule.stopVoipCallService() is always invoked when Platform.OS ===
'android' (with its own try/catch to swallow bridge errors); reference
terminateNativeCall, RNCallKeep.endCall, NativeVoipModule.stopVoipCallService
and the Platform.OS === 'android' check so both side-effects run independently
even if one throws.

---

Outside diff comments:
In `@app/lib/services/voip/useCallStore.ts`:
- Around line 83-96: Update the outdated JSDoc that references a "15s timer" to
"60s timer" for the CallStoreActions methods so comments match the new
STALE_NATIVE_MS value; specifically change the documentation on
setNativeAcceptedCallId and reset (and any other nearby JSDoc in the same
interface) to state that they start/restart or clear the 60s timer respectively,
ensuring the text mentions "60s" and not "15s".

---

Nitpick comments:
In `@app/lib/navigation/appNavigation.ts`:
- Around line 79-90: waitForNavigationReady can hang forever if
'navigationReady' never fires; modify the function to add a safety timeout
(e.g., 5–10 seconds) that rejects the promise with a clear Error (or resolves
depending on desired behavior) so callers don't wait indefinitely. Keep the
early return when navigationRef.current is present; when creating the new
Promise, start a timer (store its id), and in both the emitter listener
(listener) and the timeout handler ensure you clear the timer and remove the
listener via emitter.off('navigationReady', listener) before resolving/rejecting
so there are no leaks; update callers if you change rejection vs resolution
behavior.

In `@app/lib/services/voip/MediaSessionInstance.test.ts`:
- Around line 86-97: The mock duplicates separate jest.fn instances for the
device-info exports causing independent spies; consolidate by creating shared
mock functions (e.g., sharedGetUniqueId, sharedGetUniqueIdSync, sharedHasNotch,
sharedGetReadableVersion) and use those same mock functions for both the default
export and the named exports in the jest.mock call so tests watching
getUniqueId/getUniqueIdSync/hasNotch/getReadableVersion observe the same spy
across default and named import shapes.

In `@app/lib/services/voip/MediaSessionInstance.ts`:
- Around line 138-163: The answerCall flow can navigate to CallView after the
call was torn down while waiting for waitForNavigationReady(); after awaiting
waitForNavigationReady() (inside answerCall) re-check the current call state via
useCallStore.getState().call (or compare useCallStore.getState().call?.callId to
callId) and only call Navigation.navigate('CallView') if the call still exists
and matches callId; if the call has been cleared/changed, skip navigation and
avoid setting stale state—apply this guard right before
Navigation.navigate('CallView') in the answerCall method.
- Around line 115-135: The newCall handler registers persistent listeners on
call.emitter (call.emitter.on('ended') and call.emitter.on('stateChange')) that
are never removed; change these to one-shot listeners or register removable
handlers and remove them when the call ends. Specifically, update the
instance?.on('newCall', ...) block so the stateChange listener is either added
with once or returns a handler reference you call off on, and replace
call.emitter.on('ended', ...) with call.emitter.once('ended', ...) or store the
ended handler and remove it in the call end flow (coordinating with
useCallStore.getState().setCall if needed) to prevent the leak. Ensure you keep
the existing terminateNativeCall(call.callId) behavior when the listener fires.

In `@app/sagas/deepLinking.js`:
- Line 71: Replace the direct Promise yield with a declarative saga call: change
the usage of waitForNavigationReady in deepLinking.js from yielding the Promise
(yield waitForNavigationReady()) to using the redux-saga effect (yield
call(waitForNavigationReady)); ensure call from 'redux-saga/effects' is imported
(it's already used elsewhere in this file) so tests and cancellation/mocking
behave consistently with the other call site.
🪄 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: 8a7bd5e6-8ee5-4341-b4b8-ee282129a36b

📥 Commits

Reviewing files that changed from the base of the PR and between 10ae82a and 29ee9ae.

📒 Files selected for processing (11)
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipCallService.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • app/lib/native/NativeVoip.ts
  • app/lib/navigation/appNavigation.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/terminateNativeCall.ts
  • app/lib/services/voip/useCallStore.test.ts
  • app/lib/services/voip/useCallStore.ts
  • app/sagas/deepLinking.js
📜 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)
**/*.{js,jsx,ts,tsx,json}

📄 CodeRabbit inference engine (CLAUDE.md)

Configure Prettier with tabs, single quotes, 130 character width, no trailing commas, arrow parens avoid, and bracket same line

Files:

  • app/lib/navigation/appNavigation.ts
  • app/lib/services/voip/terminateNativeCall.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.test.ts
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use ESLint with @rocket.chat/eslint-config base configuration including React, React Native, TypeScript, and Jest plugins

Files:

  • app/lib/navigation/appNavigation.ts
  • app/lib/services/voip/terminateNativeCall.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.test.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Use TypeScript with strict mode enabled and configure baseUrl to app/ for import resolution

**/*.{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

Files:

  • app/lib/navigation/appNavigation.ts
  • app/lib/services/voip/terminateNativeCall.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.test.ts
**/*.{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/navigation/appNavigation.ts
  • app/lib/services/voip/terminateNativeCall.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.test.ts
app/lib/services/voip/**/*.{ts,tsx}

📄 CodeRabbit inference engine (CLAUDE.md)

Implement VoIP with WebRTC peer-to-peer audio calls in app/lib/services/voip/ using Zustand stores instead of Redux, with native CallKit (iOS) and Telecom (Android) integration; keep VoIP and VideoConf separate

Files:

  • app/lib/services/voip/terminateNativeCall.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.test.ts
🧠 Learnings (11)
📓 Common learnings
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP with WebRTC peer-to-peer audio calls in app/lib/services/voip/ using Zustand stores instead of Redux, with native CallKit (iOS) and Telecom (Android) integration; keep VoIP and VideoConf separate
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/stacks/**/*.{ts,tsx} : Use React Navigation 7 for navigation with stacks for InsideStack (authenticated), OutsideStack (login/register), MasterDetailStack (tablets), and ShareExtensionStack

Applied to files:

  • app/lib/navigation/appNavigation.ts
  • app/sagas/deepLinking.js
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/index.tsx : Configure Redux provider, theme, navigation, and notifications in app/index.tsx

Applied to files:

  • app/lib/navigation/appNavigation.ts
  • app/sagas/deepLinking.js
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/AppContainer.tsx : Implement root navigation container logic in app/AppContainer.tsx to switch between authentication states

Applied to files:

  • app/lib/navigation/appNavigation.ts
  • app/sagas/deepLinking.js
📚 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/lib/navigation/appNavigation.ts
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP with WebRTC peer-to-peer audio calls in app/lib/services/voip/ using Zustand stores instead of Redux, with native CallKit (iOS) and Telecom (Android) integration; keep VoIP and VideoConf separate

Applied to files:

  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipCallService.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipModule.kt
  • app/lib/services/voip/terminateNativeCall.ts
  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/useCallStore.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/MediaSessionInstance.ts
  • app/lib/services/voip/MediaSessionInstance.test.ts
  • app/lib/services/voip/useCallStore.test.ts
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to @(app/sagas/videoConf.ts|app/lib/methods/videoConf.ts) : Manage video conferencing via Redux actions/reducers/sagas in app/sagas/videoConf.ts and app/lib/methods/videoConf.ts using server-managed Jitsi integration; do not conflate with VoIP

Applied to files:

  • app/lib/native/NativeVoip.ts
  • app/lib/services/voip/MediaCallEvents.ts
  • app/sagas/deepLinking.js
  • app/lib/services/voip/MediaSessionInstance.ts
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/sagas/**/*.{ts,tsx} : Place Redux sagas in app/sagas/ directory with separate files for init, login, rooms, messages, encryption, deepLinking, and videoConf side effects

Applied to files:

  • app/sagas/deepLinking.js
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/connect.ts : Manage server connection in app/lib/services/connect.ts

Applied to files:

  • app/sagas/deepLinking.js
📚 Learning: 2026-04-07T17:49:17.538Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-07T17:49:17.538Z
Learning: Applies to app/lib/services/sdk.ts : Use Rocket.Chat JS SDK in app/lib/services/sdk.ts for WebSocket real-time subscriptions

Applied to files:

  • app/sagas/deepLinking.js
📚 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
🔇 Additional comments (5)
app/lib/services/voip/MediaCallEvents.ts (1)

268-271: LGTM!

The Android early-return correctly defers to the normal appInit() cold-start path so mediaSessionInstance.init() and applyRestStateSignals() run via the shared startup flow, fixing the silent-audio issue. setNativeAcceptedCallId is still set above the branch, preserving the accepted-call handoff, and iOS behavior is untouched.

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

66-93: LGTM — eager isRunning = false prevents the restart race.

Setting isRunning to false before stopSelf(startId) is the right fix: a subsequent startService(ACTION_START) arriving before the system actually tears the service down will now correctly re-invoke startForegroundWithNotification(...) instead of being skipped as "already running". This directly addresses the blank-screen-on-subsequent-calls symptom described in the PR.

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

152-159: LGTM.

The stopVoipCallService implementation mirrors the error-handling pattern used by the other native methods in this module and correctly routes into VoipCallService.stopService(...) which sends ACTION_STOP and — combined with the eager isRunning = false in VoipCallService.onStartCommand — lets subsequent call starts re-initialize the foreground service.

app/lib/native/NativeVoip.ts (1)

38-68: LGTM!

The new stopVoipCallService TurboModule method is well-documented with platform behavior (Android sends ACTION_STOP to VoipCallService; iOS no-op) and the JS fallback correctly returns undefined, matching the void return type and the pattern used by the other methods.

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

104-108: LGTM!

waitForNavigationReady stub resolving to undefined correctly matches the new navigation-readiness gate added in MediaSessionInstance (pre-CallView navigation) so the existing test flows proceed without hanging.

Comment thread app/lib/services/voip/terminateNativeCall.ts
…Call

Both side-effects (RNCallKeep.endCall + Android service stop) now run
independently — if endCall throws, stopVoipCallService still fires.
@diegolmello diegolmello had a problem deploying to official_android_build April 22, 2026 21:35 — with GitHub Actions Error
@diegolmello diegolmello temporarily deployed to experimental_android_build April 22, 2026 21:35 — with GitHub Actions Inactive
@diegolmello diegolmello had a problem deploying to experimental_ios_build April 22, 2026 21:35 — with GitHub Actions Error
@diegolmello diegolmello requested a deployment to approve_e2e_testing April 23, 2026 13:00 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to experimental_ios_build April 23, 2026 13:04 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to official_android_build April 23, 2026 13:04 — with GitHub Actions Waiting
@diegolmello diegolmello temporarily deployed to experimental_android_build April 23, 2026 13:04 — with GitHub Actions Inactive
@diegolmello diegolmello temporarily deployed to upload_experimental_android April 23, 2026 13:48 — with GitHub Actions Inactive
@github-actions
Copy link
Copy Markdown

Android Build Available

Rocket.Chat Experimental 4.72.0.108605

Internal App Sharing: https://play.google.com/apps/test/RQVpXLytHNc/ahAO29uNTZSae3PHDazgWFf_y-EsCHMSbFQO1MNw03cSWwmKsgPSUmj-35PUP_Cnj6KXb466WcSgocHza3_Vo32yWS

@github-actions
Copy link
Copy Markdown

Android Build Available

Rocket.Chat Experimental 4.72.0.108605

@diegolmello diegolmello merged commit 86d780c into feat.voip-lib-new Apr 24, 2026
10 of 14 checks passed
@diegolmello diegolmello deleted the voip/android-lockscreen-speaker branch April 24, 2026 12:25
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