feat(voip): reject incoming calls when user is already busy#7075
feat(voip): reject incoming calls when user is already busy#7075diegolmello wants to merge 7 commits intofeat.voip-lib-newfrom
Conversation
Check permission before TelecomManager.isInCall on API 26+, catch SecurityException, and use AudioManager MODE_IN_COMMUNICATION fallback on all API levels when Telecom is unavailable or denied. Made-with: Cursor
Add requestPhoneStatePermission() with session-scoped prompt, i18n rationale, and Jest coverage. English strings only per issue scope. Made-with: Cursor
Call requestPhoneStatePermission() at the start of MediaSessionInstance.startCall() so Android can use TelecomManager for busy detection. Videoconf is unchanged. Tests: extend MediaSessionInstance.test with startCall permission assertion. Made-with: Cursor
…VoIP Align native busy detection with iOS (non-ended calls): reject a second incoming push while the first is still ringing or dialing, not only active/hold. Made-with: Cursor
When the user is already in a call, still satisfy PushKit by reporting to CallKit then rejecting, but do not store the payload for getInitialEvents. Keeps DDP listener/timeout so the reject signal can be sent. Clear matching initial events in rejectBusyCall as a safeguard. Made-with: Cursor
Added a new function to determine if incoming videoconference calls should be blocked based on active VoIP calls or pending native accept states. Updated the videoConf saga to utilize this new logic. Additionally, created unit tests for the new functionality to ensure correct behavior in various scenarios.
WalkthroughThis change implements busy-call rejection for incoming VoIP calls on both iOS and Android platforms. When a user is already in an active call, incoming calls are automatically rejected with server-side signaling rather than being presented to the user. Changes
Sequence DiagramssequenceDiagram
participant iOS as iOS OS
participant App as App (AppDelegate)
participant VoipService as VoipService
participant Server as DDP Server
participant User as User/CallKit
iOS->>App: Incoming PushKit VoIP notification
App->>VoipService: Check hasActiveCall()
VoipService->>VoipService: Observe CallKit state
VoipService-->>App: true (user busy)
App->>VoipService: prepareIncomingCall(payload, storeEventsForJs: false)
Note over VoipService: Skip storing events<br/>Schedule timeout<br/>Start DDP listener
App->>User: reportNewIncomingCall() to CallKit
App->>VoipService: rejectBusyCall(payload)
VoipService->>VoipService: Cancel timeout<br/>Clear tracking state<br/>RNCallKeep.endCall()
VoipService->>Server: Send/queue DDP reject signal
Server-->>VoipService: Ack
Note over User: Call appears briefly<br/>then ends in CallKit
sequenceDiagram
participant Android as Android OS
participant App as App (onMessageReceived)
participant VoipNotif as VoipNotification
participant VoiceService as VoiceConnectionService
participant Server as DDP Server
participant User as User/CallKit
Android->>App: Incoming VoIP notification
App->>VoipNotif: Check hasActiveCall()
VoipNotif->>VoiceService: Query currentConnections state
alt Active call detected
VoiceService-->>VoipNotif: true (ringing/active/holding)
VoipNotif-->>App: true
App->>VoipNotif: rejectBusyCall(context, payload)
VoipNotif->>VoipNotif: Cancel timeout<br/>Start end-listener
VoipNotif->>Server: Send/queue DDP reject
else No active call
VoiceService-->>VoipNotif: Check AudioManager mode
VoipNotif-->>App: false
App->>App: showIncomingCall(payload)
Note over User: Call presented to user
end
sequenceDiagram
participant JS as JavaScript Layer
participant MediaSession as MediaSessionInstance
participant PermModule as voipPhoneStatePermission
participant Android as Android OS
participant CallStore as useCallStore
participant VoipService as VoipService
JS->>MediaSession: startCall(userId, actor)
MediaSession->>PermModule: requestPhoneStatePermission()
PermModule->>Android: PermissionsAndroid.request(READ_PHONE_STATE)
Note over PermModule: Once per session only
Android-->>PermModule: User response
PermModule-->>MediaSession: Permission requested
MediaSession->>VoipService: Create newCall handler
alt Incoming call arrives
VoipService->>MediaSession: on('newCall', callee)
MediaSession->>CallStore: getState()
CallStore-->>MediaSession: current call info
alt Different call already active
MediaSession->>MediaSession: call.reject()
MediaSession->>Android: RNCallKeep.endCall()
Note over MediaSession: Reject busy incoming
else No active call
MediaSession->>MediaSession: Register stateChange listener
Note over MediaSession: Accept incoming call
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Suggested labels
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ 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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
app/lib/methods/voipPhoneStatePermission.ts (1)
17-21: Consider handling or explicitly ignoring the returned Promise.
PermissionsAndroid.request()returns a Promise that is currently not awaited or caught. While the fire-and-forget design is intentional per the PR objectives, an unhandled rejection could trigger warnings in some environments.🔧 Optional: Explicitly suppress errors
- PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { + PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { buttonPositive: 'Ok', message: i18n.t('Phone_state_permission_message'), title: i18n.t('Phone_state_permission_title') - }); + }).catch(() => { + // Fire-and-forget: permission denial is acceptable + });🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/lib/methods/voipPhoneStatePermission.ts` around lines 17 - 21, The call to PermissionsAndroid.request(...) currently returns a Promise that is neither awaited nor handled; explicitly consume it to avoid unhandled-rejection warnings by appending a deliberate handler (for example, using the void operator or adding .catch(() => {}) to PermissionsAndroid.request) so the intent is clear and errors are suppressed; update the call site where PermissionsAndroid.request is invoked (the invocation passing i18n.t('Phone_state_permission_message') and i18n.t('Phone_state_permission_title')) to explicitly handle or ignore the returned Promise.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt`:
- Around line 537-545: The rejectBusyCall path should not call
startListeningForCallEnd() because that function tears down the singleton
ddpClient and will steal the native listener for another ringing call; remove
the startListeningForCallEnd(context, payload) call from rejectBusyCall and
instead either 1) implement a new helper (e.g.,
startPassiveCallEndWatcherWithoutTearDown or startNonDestructiveCallEndListener)
that only registers a listener for this call without tearing down ddpClient and
call that here, or 2) skip starting any listener at all and rely on existing
listeners for the active call; ensure cancelTimeout(payload.callId) remains and
keep the existing sendRejectSignal/queueRejectSignal logic in rejectBusyCall.
- Around line 510-529: The hasSystemLevelActiveCallIndicators function misses
detecting cellular calls when READ_PHONE_STATE is denied because it only checks
AudioManager.MODE_IN_COMMUNICATION; update the AudioManager fallback (in
hasSystemLevelActiveCallIndicators) to also treat AudioManager.MODE_IN_CALL as
an active call by checking audio?.mode against both MODE_IN_COMMUNICATION and
MODE_IN_CALL and return true if either matches so incoming VoIP calls won’t
interrupt cellular calls.
In `@app/lib/methods/voipPhoneStatePermission.test.ts`:
- Line 11: The ESLint error comes from using the forbidden import() type
annotation in the jest.requireActual call; remove the generic type argument from
the spread expression (replace "jest.requireActual<typeof
import('./helpers')>('./helpers')" with simply "jest.requireActual('./helpers')"
or, if you prefer explicit typing, use "jest.requireActual('./helpers') as
Record<string, unknown>" ), and apply the same change for the equivalent usages
at the other two places (the other jest.requireActual spreads at lines that
reference './helpers') so the type-import syntax is no longer used.
In `@ios/Libraries/VoipService.swift`:
- Around line 145-154: prepareIncomingCall currently always calls
startListeningForCallEnd even when storeEventsForJs is false, and that helper
tears down the shared ddpClient/observedIncomingCall singleton causing a busy
second call to steal the listener; change prepareIncomingCall so it only calls
startListeningForCallEnd when storeEventsForJs is true (i.e., keep the busy path
from re-arming/tearing down the shared listener), retaining
scheduleIncomingCallTimeout(for:) for the busy path and still calling
storeInitialEvents(payload) only when storeEventsForJs is true to avoid
impacting ddpClient/observedIncomingCall for existing calls.
---
Nitpick comments:
In `@app/lib/methods/voipPhoneStatePermission.ts`:
- Around line 17-21: The call to PermissionsAndroid.request(...) currently
returns a Promise that is neither awaited nor handled; explicitly consume it to
avoid unhandled-rejection warnings by appending a deliberate handler (for
example, using the void operator or adding .catch(() => {}) to
PermissionsAndroid.request) so the intent is clear and errors are suppressed;
update the call site where PermissionsAndroid.request is invoked (the invocation
passing i18n.t('Phone_state_permission_message') and
i18n.t('Phone_state_permission_title')) to explicitly handle or ignore the
returned Promise.
🪄 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: 380ab9b4-a908-4c35-895f-62ca099f3a25
📒 Files selected for processing (11)
android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.ktapp/i18n/locales/en.jsonapp/lib/methods/voipPhoneStatePermission.test.tsapp/lib/methods/voipPhoneStatePermission.tsapp/lib/services/voip/MediaSessionInstance.test.tsapp/lib/services/voip/MediaSessionInstance.tsapp/lib/services/voip/voipBlocksIncomingVideoconf.test.tsapp/lib/services/voip/voipBlocksIncomingVideoconf.tsapp/sagas/videoConf.tsios/Libraries/AppDelegate+Voip.swiftios/Libraries/VoipService.swift
📜 Review details
🧰 Additional context used
🪛 ESLint
app/lib/methods/voipPhoneStatePermission.test.ts
[error] 11-11: import() type annotations are forbidden.
(@typescript-eslint/consistent-type-imports)
[error] 29-29: import() type annotations are forbidden.
(@typescript-eslint/consistent-type-imports)
[error] 51-51: import() type annotations are forbidden.
(@typescript-eslint/consistent-type-imports)
🪛 GitHub Actions: Format Code with Prettier
app/lib/methods/voipPhoneStatePermission.test.ts
[error] 11-11: ESLint error: import() type annotations are forbidden. (typescript-eslint/consistent-type-imports)
🪛 GitHub Check: format
app/lib/methods/voipPhoneStatePermission.test.ts
[failure] 51-51:
import() type annotations are forbidden
[failure] 29-29:
import() type annotations are forbidden
[failure] 11-11:
import() type annotations are forbidden
🔇 Additional comments (10)
app/lib/services/voip/voipBlocksIncomingVideoconf.ts (1)
1-7: LGTM!Clean utility function that correctly checks both active VoIP call state and pending native accept. The logic aligns with the store's state shape where both fields are initialized as
nulland must be explicitly set.app/i18n/locales/en.json (1)
665-666: LGTM!Clear, user-friendly permission rationale text that explains the purpose without being overly technical. The message appropriately conveys the benefit to users.
app/lib/services/voip/voipBlocksIncomingVideoconf.test.ts (1)
1-42: LGTM!Good test coverage for all three logical branches of the function. The mock setup correctly isolates the store dependency.
app/sagas/videoConf.ts (1)
50-52: LGTM!Clean early-return guard that prioritizes VoIP calls over incoming videoconf. The placement at the function start ensures the check happens before any other processing or state mutations.
app/lib/services/voip/MediaSessionInstance.ts (2)
88-98: LGTM!Well-implemented busy guard that correctly handles both active calls and pending native accepts. The nullish coalescing chain
existingCall?.callId ?? nativeAcceptedCallId ?? nullproperly covers the time window where native accept is set but the call object isn't yet bound. Rejecting and ending CallKeep ensures clean state on both the signaling and native layers.
152-156: LGTM!Good placement of the phone state permission request at the start of outgoing calls. This aligns with the PR's session-scoped, fire-and-forget permission flow.
ios/Libraries/AppDelegate+Voip.swift (1)
43-67: LGTM!Correct implementation of iOS busy-call handling that respects Apple's PushKit requirement to always report incoming calls to CallKit. The sequence is proper:
- Check busy state before reporting
- Conditionally store events for JS (skip when busy)
- Always report to CallKit (required)
- Reject if busy after reporting
The
storeEventsForJs: !isBusyparameter prevents the busy-rejected call from surfacing in JS initial events.android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt (1)
490-504: Nice catch including ringing/dialing in own-app busy detection.This closes the second-incoming-while-ringing gap and brings Android in line with the iOS behavior.
ios/Libraries/VoipService.swift (1)
138-143: UsingCXCallObserverhere makes sense.Treating any non-ended CallKit call as busy matches the PR requirement and covers system as well as third-party calls.
app/lib/services/voip/MediaSessionInstance.test.ts (1)
52-80: Nice coverage around the new busy guard.The added harness exercises the reject/endCall path, the
nativeAcceptedCallIdexceptions, and the fire-and-forget permission request without needing the full signaling stack.Also applies to: 128-161, 368-474
| private fun hasSystemLevelActiveCallIndicators(context: Context): Boolean { | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | ||
| val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == | ||
| PackageManager.PERMISSION_GRANTED | ||
| if (granted) { | ||
| val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager | ||
| try { | ||
| if (telecom?.isInCall == true) { | ||
| return true | ||
| } | ||
| } catch (e: SecurityException) { | ||
| Log.w(TAG, "TelecomManager.isInCall not allowed", e) | ||
| } | ||
| } | ||
| } | ||
| val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager | ||
| if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION) { | ||
| return true | ||
| } | ||
| return false |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt | head -550 | tail -100Repository: RocketChat/Rocket.Chat.ReactNative
Length of output: 1927
🏁 Script executed:
rg "hasActiveCall|hasSystemLevelActiveCallIndicators" android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt -B 5 -A 15Repository: RocketChat/Rocket.Chat.ReactNative
Length of output: 2958
🌐 Web query:
Android AudioManager MODE_IN_CALL vs MODE_IN_COMMUNICATION when used
💡 Result:
-
MODE_IN_CALL: Use only for a real telephony (PSTN/cellular) call handled by the system dialer/telephony stack. The Android framework explicitly notes this mode “should only be used by the telephony application” because it can make radio-layer call audio feed into the platform mixer, and it’s restricted (only selectable by the main telephony app withMODIFY_PHONE_STATE). [1] -
MODE_IN_COMMUNICATION: Use for app-to-app communication like VoIP / audio-video chat (e.g., WebRTC, SIP). It indicates “an audio/video chat or VoIP call is established” and is the appropriate mode for third-party calling apps to get “call-like” routing/processing behavior. [1] -
Practical permission/behavior difference:
- If you try to set
MODE_IN_CALLas a normal app, the system will reject it withoutMODIFY_PHONE_STATE. [2] - While the device is already in
MODE_IN_CALLorMODE_IN_COMMUNICATION, the system also prevents other apps from changing the mode unless they’re privileged (or the current “mode owner”), to avoid apps disrupting an ongoing call. [2]
- If you try to set
Sources:
[1] AOSP AudioManager.java (mode definitions + setMode() guidance/restriction note) (android.googlesource.com)
[2] AOSP AudioService.java (setMode permission enforcement / call-active change restrictions) (android.googlesource.com)
Citations:
- 1: https://android.googlesource.com/platform/frameworks/base.git/%2B/master/media/java/android/media/AudioManager.java
- 2: https://android.googlesource.com/platform/frameworks/base/%2B/fada307ddfd776b092028ac7d3ab543117580e0f/services/core/java/com/android/server/audio/AudioService.java
Add MODE_IN_CALL check to detect cellular calls when READ_PHONE_STATE is denied.
When READ_PHONE_STATE permission is denied or unavailable, the fallback to AudioManager only checks MODE_IN_COMMUNICATION. However, system cellular calls are marked as MODE_IN_CALL, which means hasActiveCall() returns false and an incoming Rocket.Chat call can interrupt an active cellular call. Include MODE_IN_CALL in the AudioManager check to detect all active calls regardless of permission state.
Suggested fix
val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
- if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION) {
+ if (
+ audio?.mode == AudioManager.MODE_IN_COMMUNICATION ||
+ audio?.mode == AudioManager.MODE_IN_CALL
+ ) {
return true
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| private fun hasSystemLevelActiveCallIndicators(context: Context): Boolean { | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
| val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == | |
| PackageManager.PERMISSION_GRANTED | |
| if (granted) { | |
| val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager | |
| try { | |
| if (telecom?.isInCall == true) { | |
| return true | |
| } | |
| } catch (e: SecurityException) { | |
| Log.w(TAG, "TelecomManager.isInCall not allowed", e) | |
| } | |
| } | |
| } | |
| val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager | |
| if (audio?.mode == AudioManager.MODE_IN_COMMUNICATION) { | |
| return true | |
| } | |
| return false | |
| private fun hasSystemLevelActiveCallIndicators(context: Context): Boolean { | |
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
| val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) == | |
| PackageManager.PERMISSION_GRANTED | |
| if (granted) { | |
| val telecom = context.getSystemService(Context.TELECOM_SERVICE) as? TelecomManager | |
| try { | |
| if (telecom?.isInCall == true) { | |
| return true | |
| } | |
| } catch (e: SecurityException) { | |
| Log.w(TAG, "TelecomManager.isInCall not allowed", e) | |
| } | |
| } | |
| } | |
| val audio = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager | |
| if ( | |
| audio?.mode == AudioManager.MODE_IN_COMMUNICATION || | |
| audio?.mode == AudioManager.MODE_IN_CALL | |
| ) { | |
| return true | |
| } | |
| return false | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt`
around lines 510 - 529, The hasSystemLevelActiveCallIndicators function misses
detecting cellular calls when READ_PHONE_STATE is denied because it only checks
AudioManager.MODE_IN_COMMUNICATION; update the AudioManager fallback (in
hasSystemLevelActiveCallIndicators) to also treat AudioManager.MODE_IN_CALL as
an active call by checking audio?.mode against both MODE_IN_COMMUNICATION and
MODE_IN_CALL and return true if either matches so incoming VoIP calls won’t
interrupt cellular calls.
| fun rejectBusyCall(context: Context, payload: VoipPayload) { | ||
| Log.d(TAG, "Rejected busy call ${payload.callId} — user already on a call") | ||
| cancelTimeout(payload.callId) | ||
| startListeningForCallEnd(context, payload) | ||
| if (isDdpLoggedIn) { | ||
| sendRejectSignal(context, payload) | ||
| } else { | ||
| queueRejectSignal(context, payload) | ||
| } |
There was a problem hiding this comment.
Don't reuse startListeningForCallEnd() to reject the busy call.
startListeningForCallEnd() starts by tearing down the singleton ddpClient. If call A is still ringing and call B is auto-rejected as busy, B steals A's native listener, so hangup/accept updates for the first call are no longer observed until timeout.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt`
around lines 537 - 545, The rejectBusyCall path should not call
startListeningForCallEnd() because that function tears down the singleton
ddpClient and will steal the native listener for another ringing call; remove
the startListeningForCallEnd(context, payload) call from rejectBusyCall and
instead either 1) implement a new helper (e.g.,
startPassiveCallEndWatcherWithoutTearDown or startNonDestructiveCallEndListener)
that only registers a listener for this call without tearing down ddpClient and
call that here, or 2) skip starting any listener at all and rely on existing
listeners for the active call; ensure cancelTimeout(payload.callId) remains and
keep the existing sendRejectSignal/queueRejectSignal logic in rejectBusyCall.
| it('does not call PermissionsAndroid.request when not on Android', () => { | ||
| jest.resetModules(); | ||
| jest.doMock('./helpers', () => ({ | ||
| ...jest.requireActual<typeof import('./helpers')>('./helpers'), |
There was a problem hiding this comment.
Fix ESLint error: import() type annotations are forbidden.
The pipeline is failing due to @typescript-eslint/consistent-type-imports violations at lines 11, 29, and 51. The import() type annotation syntax is not allowed.
🔧 Proposed fix using `Record`
Since the spread is only used to preserve other exports, you can simplify by removing the type annotation entirely:
- jest.doMock('./helpers', () => ({
- ...jest.requireActual<typeof import('./helpers')>('./helpers'),
- isAndroid: false
- }));
+ jest.doMock('./helpers', () => ({
+ ...jest.requireActual('./helpers'),
+ isAndroid: false
+ }));Apply the same change at lines 29 and 51.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ...jest.requireActual<typeof import('./helpers')>('./helpers'), | |
| ...jest.requireActual('./helpers'), |
🧰 Tools
🪛 ESLint
[error] 11-11: import() type annotations are forbidden.
(@typescript-eslint/consistent-type-imports)
🪛 GitHub Actions: Format Code with Prettier
[error] 11-11: ESLint error: import() type annotations are forbidden. (typescript-eslint/consistent-type-imports)
🪛 GitHub Check: format
[failure] 11-11:
import() type annotations are forbidden
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@app/lib/methods/voipPhoneStatePermission.test.ts` at line 11, The ESLint
error comes from using the forbidden import() type annotation in the
jest.requireActual call; remove the generic type argument from the spread
expression (replace "jest.requireActual<typeof
import('./helpers')>('./helpers')" with simply "jest.requireActual('./helpers')"
or, if you prefer explicit typing, use "jest.requireActual('./helpers') as
Record<string, unknown>" ), and apply the same change for the equivalent usages
at the other two places (the other jest.requireActual spreads at lines that
reference './helpers') so the type-import syntax is no longer used.
| /// Prepares DDP listener and timeout for an incoming VoIP push. When `storeEventsForJs` is false | ||
| /// (e.g. user is already on a call and we will `rejectBusyCall` immediately), skip stashing payload | ||
| /// for `getInitialEvents` so JS does not treat an auto-rejected call as a real incoming ring. | ||
| public static func prepareIncomingCall(_ payload: VoipPayload, storeEventsForJs: Bool = true) { | ||
| if storeEventsForJs { | ||
| storeInitialEvents(payload) | ||
| } | ||
| scheduleIncomingCallTimeout(for: payload) | ||
| startListeningForCallEnd(payload: payload) | ||
| } |
There was a problem hiding this comment.
Don't arm the shared incoming-call listener for the busy path.
When storeEventsForJs is false, this still calls startListeningForCallEnd(payload), and that helper first tears down the existing singleton ddpClient/observedIncomingCall. If another Rocket.Chat call is already ringing, the busy second call steals that listener and the first call stops receiving native hangup/accept updates until timeout.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@ios/Libraries/VoipService.swift` around lines 145 - 154, prepareIncomingCall
currently always calls startListeningForCallEnd even when storeEventsForJs is
false, and that helper tears down the shared ddpClient/observedIncomingCall
singleton causing a busy second call to steal the listener; change
prepareIncomingCall so it only calls startListeningForCallEnd when
storeEventsForJs is true (i.e., keep the busy path from re-arming/tearing down
the shared listener), retaining scheduleIncomingCallTimeout(for:) for the busy
path and still calling storeInitialEvents(payload) only when storeEventsForJs is
true to avoid impacting ddpClient/observedIncomingCall for existing calls.
Proposed changes
This change implements defense-in-depth so a second incoming VoIP call (or distracting videoconf UI) does not interrupt the user while they are already on a call—whether that call is in Rocket.Chat VoIP, another app (phone, FaceTime, WhatsApp, etc.), or still ringing.
Native (iOS)
CXCallObserver(any non-ended call counts as busy).rejectBusyCall()when busy; skip stashing initial events for JS when busy so cold-start handling stays consistent.Native (Android)
hasActiveCall()considers the app’s own connections (including ringing/dialing), thenTelecomManager.isInCall()only whenREAD_PHONE_STATEis granted, withtry/catchforSecurityException, plusAudioManagermode fallback when Telecom is unavailable.isInCall()when permission was missing.JavaScript
MediaSessionInstancenewCall(callee): if the Zustand call store already has another activecallIdor a pending native accept, reject via media-signaling and end CallKeep.videoConfsagaonDirectCall: early return when VoIP is active or native accept is pending so videoconf does not compete with VoIP (VoIP > videoconf).Android permission UX
READ_PHONE_STATErequest with i18n rationale (voipPhoneStatePermission), triggered from VoIP entry points (e.g. starting a VoIP call); session flag avoids repeat prompts after deny; degrades silently if denied.Callers receive a normal rejected signal (same as a manual decline). No call-waiting, no “busy” presence, and no in-app message to the callee that a second call was dropped (per PRD out-of-scope).
Issue(s)
How to test or reproduce
VoIP while on VoIP
VoIP while native call / other app
Android permission
READ_PHONE_STATE.isInCall(); fallback still attempts busy detection via app connections +AudioManagerwhere applicable.JS guard / race
nativeAcceptedCallIdguard).Videoconf vs VoIP
onDirectCallpath. Expect: videoconf notification suppressed; VoIP unchanged.Regression
Relevant automated tests (if present on the branch):
MediaSessionInstance.test.ts,voipPhoneStatePermission.test.ts,voipBlocksIncomingVideoconf.test.ts, plus native behavior verified on device.Screenshots
Types of changes
Checklist
Further comments
progress.mdnotes this work onfeat.single-callbased onfeat.voip-lib-new; if the GitHub PR compares todevelop, reviewers should expect a larger VoIP footprint than this narrative alone—tighten the PR base or split commits if the team wants a PR that only contains the busy-rejection delta.Summary by CodeRabbit
New Features
Improvements