Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5dae4b5
first attempt
diegolmello Mar 27, 2026
d324a86
fix(android): guard hasActiveCall Telecom check for READ_PHONE_STATE
diegolmello Mar 30, 2026
266922e
feat(android): add READ_PHONE_STATE runtime request helper for VoIP
diegolmello Mar 30, 2026
bd7158c
feat(voip): request READ_PHONE_STATE when starting a VoIP call
diegolmello Mar 30, 2026
309cbba
fix(android): treat ringing/dialing CallKeep connections as busy for …
diegolmello Mar 30, 2026
328b646
fix(ios): skip initial-events stash for busy VoIP push
diegolmello Mar 30, 2026
8fc00f5
feat(voip): implement videoconference blocking logic and related tests
diegolmello Mar 30, 2026
f30a57a
feat(ios): CallKit call-waiting — multi-call observer, disable hold
diegolmello Mar 30, 2026
12c6cac
feat: Enhance MediaSessionInstance tests for new call handling
diegolmello Mar 30, 2026
5ae590d
test(voip): add MediaCallEvents tests for cross-server accept pipeline
diegolmello Mar 30, 2026
beb9a7c
refactor(voip): keep MediaCallEvents native handlers private
diegolmello Mar 30, 2026
385991d
chore(voip): dedupe MediaCallEvents tests, clarify nativeAccept docs
diegolmello Mar 30, 2026
4a0e2f7
chore(voip): iOS CallKit call-waiting + drop JS callee busy-reject af…
diegolmello Mar 30, 2026
36ed84f
chore(voip): document iOS busy-call helpers; tighten videoconf guard …
diegolmello Mar 30, 2026
9e71e6c
i18n: add phone state permission strings for supported locales
diegolmello Mar 30, 2026
c7e4f74
fix: resolve ESLint errors in VoIP-related test files
diegolmello Mar 30, 2026
72ab82e
fix voipservice
diegolmello Mar 31, 2026
408cced
feat(voip): auto-hold RC call on OS hold (CallKeep didToggleHoldCallA…
diegolmello Apr 1, 2026
5828949
Fix for phone calls on iOS
diegolmello Apr 1, 2026
ab85a8f
support holding
diegolmello Apr 1, 2026
d7bffcd
fix(voip): localize phone-state OK button; gate FCM before busy check
diegolmello Apr 1, 2026
6bebf15
feat: per-call DDP registry for call waiting
diegolmello Apr 1, 2026
db0bb36
chore: format code and fix lint issues
diegolmello Apr 1, 2026
3bc1473
fix(voip): detect cellular calls in no-permission audio mode fallback
diegolmello Apr 1, 2026
126715d
fix(voip): guard toggleHold against already-resumed state on OS unhold
diegolmello Apr 1, 2026
f279495
fix(voip): thread-safe iOS accept callbacks and lightweight Android b…
diegolmello Apr 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* 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 thread
coderabbitai[bot] marked this conversation as resolved.
}

// -- Native DDP Listener (Call End Detection) --

@JvmStatic
Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
else -> Log.w(TAG, "Ignoring unsupported VoIP payload type: ${voipPayload.type}")
}
}
Expand Down
2 changes: 2 additions & 0 deletions app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,8 @@
"Permalink_copied_to_clipboard": "Permalink copied to clipboard!",
"Person_or_channel": "Person or channel",
"Phone": "Phone",
"Phone_state_permission_message": "This lets Rocket.Chat detect when you are already on a phone or VoIP call so incoming calls can be handled correctly.",
"Phone_state_permission_title": "Allow phone state access",
"Pin": "Pin",
"Pinned": "Pinned",
"Pinned_a_message": "Pinned a message:",
Expand Down
62 changes: 62 additions & 0 deletions app/lib/methods/voipPhoneStatePermission.test.ts
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

View workflow job for this annotation

GitHub Actions / format

`import()` type annotations are forbidden

Check failure on line 11 in app/lib/methods/voipPhoneStatePermission.test.ts

View workflow job for this annotation

GitHub Actions / ESLint and Test / run-eslint-and-test

`import()` type annotations are forbidden
isAndroid: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}));
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

View workflow job for this annotation

GitHub Actions / format

`import()` type annotations are forbidden

Check failure on line 29 in app/lib/methods/voipPhoneStatePermission.test.ts

View workflow job for this annotation

GitHub Actions / ESLint and Test / run-eslint-and-test

`import()` type annotations are forbidden
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

View workflow job for this annotation

GitHub Actions / format

`import()` type annotations are forbidden

Check failure on line 51 in app/lib/methods/voipPhoneStatePermission.test.ts

View workflow job for this annotation

GitHub Actions / ESLint and Test / run-eslint-and-test

`import()` type annotations are forbidden
isAndroid: true
}));
const spy = jest.spyOn(PermissionsAndroid, 'request').mockResolvedValue('granted' as never);
const { requestPhoneStatePermission } = require('./voipPhoneStatePermission');

requestPhoneStatePermission();
requestPhoneStatePermission();

expect(spy).toHaveBeenCalledTimes(1);
});
});
22 changes: 22 additions & 0 deletions app/lib/methods/voipPhoneStatePermission.ts
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')
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
};
Loading
Loading