-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(voip): reject incoming calls when user is already busy #7075
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5dae4b5
d324a86
266922e
bd7158c
309cbba
328b646
8fc00f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,17 @@ | ||
| package chat.rocket.reactnative.voip | ||
|
|
||
| import android.Manifest | ||
| import android.app.Notification | ||
| import android.app.NotificationChannel | ||
| import android.app.NotificationManager | ||
| import android.app.PendingIntent | ||
| import android.content.BroadcastReceiver | ||
| import android.content.Context | ||
| import android.content.Intent | ||
| import android.content.pm.PackageManager | ||
| import android.graphics.Bitmap | ||
| import android.media.AudioAttributes | ||
| import android.media.AudioManager | ||
| import android.media.RingtoneManager | ||
| import android.os.Build | ||
| import android.os.Bundle | ||
|
|
@@ -17,6 +20,7 @@ import android.os.Looper | |
| import android.provider.Settings | ||
| import android.util.Log | ||
| import androidx.core.app.NotificationCompat | ||
| import androidx.core.content.ContextCompat | ||
| import androidx.localbroadcastmanager.content.LocalBroadcastManager | ||
| import android.content.ComponentName | ||
| import android.net.Uri | ||
|
|
@@ -477,6 +481,70 @@ class VoipNotification(private val context: Context) { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * True when the user is already in a call: this app's Telecom connections (ringing, dialing, | ||
| * active, hold — same idea as iOS CXCallObserver "any non-ended"), any system in-call state | ||
| * (API 26+ when READ_PHONE_STATE is granted), or audio in communication mode (fallback on all | ||
| * API levels when Telecom is unavailable or denied). | ||
| */ | ||
| private fun hasActiveCall(context: Context): Boolean { | ||
| val ownBusy = VoiceConnectionService.currentConnections.values.any { connection -> | ||
| when (connection.state) { | ||
| android.telecom.Connection.STATE_RINGING, | ||
| android.telecom.Connection.STATE_DIALING, | ||
| android.telecom.Connection.STATE_ACTIVE, | ||
| android.telecom.Connection.STATE_HOLDING -> true | ||
| else -> false | ||
| } | ||
| } | ||
| if (ownBusy) { | ||
| return true | ||
| } | ||
| return hasSystemLevelActiveCallIndicators(context) | ||
| } | ||
|
|
||
| /** | ||
| * Telecom in-call check (API 26+) requires [READ_PHONE_STATE]; without it, [TelecomManager.isInCall] | ||
| * can throw [SecurityException]. Always falls back to [AudioManager.MODE_IN_COMMUNICATION] on all APIs. | ||
| */ | ||
| 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 | ||
| } | ||
|
|
||
| /** | ||
| * Rejects an incoming call because the user is already on another call. | ||
| * Sends a reject signal via DDP and cleans up without showing any UI. | ||
| */ | ||
| @JvmStatic | ||
| 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) | ||
| } | ||
|
Comment on lines
+537
to
+545
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't reuse
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // -- Native DDP Listener (Call End Detection) -- | ||
|
|
||
| @JvmStatic | ||
|
|
@@ -613,7 +681,13 @@ class VoipNotification(private val context: Context) { | |
|
|
||
| fun onMessageReceived(voipPayload: VoipPayload) { | ||
| when { | ||
| voipPayload.isVoipIncomingCall() -> showIncomingCall(voipPayload) | ||
| voipPayload.isVoipIncomingCall() -> { | ||
| if (hasActiveCall(context)) { | ||
| rejectBusyCall(context, voipPayload) | ||
| } else { | ||
| showIncomingCall(voipPayload) | ||
| } | ||
| } | ||
| else -> Log.w(TAG, "Ignoring unsupported VoIP payload type: ${voipPayload.type}") | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,62 @@ | ||||||
| import { PermissionsAndroid } from 'react-native'; | ||||||
|
|
||||||
| describe('requestPhoneStatePermission', () => { | ||||||
| afterEach(() => { | ||||||
| jest.restoreAllMocks(); | ||||||
| }); | ||||||
|
|
||||||
| it('does not call PermissionsAndroid.request when not on Android', () => { | ||||||
| jest.resetModules(); | ||||||
| jest.doMock('./helpers', () => ({ | ||||||
| ...jest.requireActual<typeof import('./helpers')>('./helpers'), | ||||||
|
Check failure on line 11 in app/lib/methods/voipPhoneStatePermission.test.ts
|
||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix ESLint error: The pipeline is failing due to 🔧 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
Suggested change
🧰 Tools🪛 ESLint[error] 11-11: ( 🪛 GitHub Actions: Format Code with Prettier[error] 11-11: ESLint error: 🪛 GitHub Check: format[failure] 11-11: 🤖 Prompt for AI Agents |
||||||
| isAndroid: false | ||||||
| })); | ||||||
| const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); | ||||||
| const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); | ||||||
|
|
||||||
| requestPhoneStatePermission(); | ||||||
|
|
||||||
| expect(spy).not.toHaveBeenCalled(); | ||||||
| }); | ||||||
|
|
||||||
| it('requests READ_PHONE_STATE on Android with i18n rationale keys', () => { | ||||||
| jest.resetModules(); | ||||||
| jest.doMock('../../i18n', () => ({ | ||||||
| __esModule: true, | ||||||
| default: { t: (key: string) => key } | ||||||
| })); | ||||||
| jest.doMock('./helpers', () => ({ | ||||||
| ...jest.requireActual<typeof import('./helpers')>('./helpers'), | ||||||
|
Check failure on line 29 in app/lib/methods/voipPhoneStatePermission.test.ts
|
||||||
| isAndroid: true | ||||||
| })); | ||||||
| const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); | ||||||
| const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); | ||||||
|
|
||||||
| requestPhoneStatePermission(); | ||||||
|
|
||||||
| expect(spy).toHaveBeenCalledWith(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { | ||||||
| buttonPositive: 'Ok', | ||||||
| message: 'Phone_state_permission_message', | ||||||
| title: 'Phone_state_permission_title' | ||||||
| }); | ||||||
| }); | ||||||
|
|
||||||
| it('does not prompt again in the same session on Android', () => { | ||||||
| jest.resetModules(); | ||||||
| jest.doMock('../../i18n', () => ({ | ||||||
| __esModule: true, | ||||||
| default: { t: (key: string) => key } | ||||||
| })); | ||||||
| jest.doMock('./helpers', () => ({ | ||||||
| ...jest.requireActual<typeof import('./helpers')>('./helpers'), | ||||||
|
Check failure on line 51 in app/lib/methods/voipPhoneStatePermission.test.ts
|
||||||
| isAndroid: true | ||||||
| })); | ||||||
| const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never); | ||||||
| const { requestPhoneStatePermission } = require('./voipPhoneStatePermission'); | ||||||
|
|
||||||
| requestPhoneStatePermission(); | ||||||
| requestPhoneStatePermission(); | ||||||
|
|
||||||
| expect(spy).toHaveBeenCalledTimes(1); | ||||||
| }); | ||||||
| }); | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { PermissionsAndroid } from 'react-native'; | ||
|
|
||
| import i18n from '../../i18n'; | ||
| import { isAndroid } from './helpers'; | ||
|
|
||
| let askedThisSession = false; | ||
|
|
||
| export const requestPhoneStatePermission = (): void => { | ||
| if (!isAndroid) { | ||
| return; | ||
| } | ||
| if (askedThisSession) { | ||
| return; | ||
| } | ||
| askedThisSession = true; | ||
|
|
||
| PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, { | ||
| buttonPositive: 'Ok', | ||
| message: i18n.t('Phone_state_permission_message'), | ||
| title: i18n.t('Phone_state_permission_title') | ||
| }); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: 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:
MODE_IN_CALLas a normal app, the system will reject it withoutMODIFY_PHONE_STATE. [2]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]Sources:
[1] AOSP
AudioManager.java(mode definitions +setMode()guidance/restriction note) (android.googlesource.com)[2] AOSP
AudioService.java(setModepermission enforcement / call-active change restrictions) (android.googlesource.com)Citations:
Add
MODE_IN_CALLcheck to detect cellular calls whenREAD_PHONE_STATEis denied.When
READ_PHONE_STATEpermission is denied or unavailable, the fallback toAudioManageronly checksMODE_IN_COMMUNICATION. However, system cellular calls are marked asMODE_IN_CALL, which meanshasActiveCall()returns false and an incoming Rocket.Chat call can interrupt an active cellular call. IncludeMODE_IN_CALLin the AudioManager check to detect all active calls regardless of permission state.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents