Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d3cc81d
fix(voip): gate incoming answer on microphone permission
diegolmello May 27, 2026
7998b8f
fix(test): satisfy import/first in voipCallPermissions tests
diegolmello May 27, 2026
80946af
fix(voip): harden mic-permission gate (never-reject + idempotent answer)
diegolmello Jun 1, 2026
17ec3b7
fix(voip): pre-acquire mic at login; check-only incoming answer gate
diegolmello Jun 8, 2026
74a2f93
docs(voip): bump FLOWS _Last verified for init + incoming flows
diegolmello Jun 8, 2026
fa804cd
fix(voip): reject incoming call at push layer when mic denied (Android)
diegolmello Jun 8, 2026
dd726d9
docs(voip): bump FLOWS _Last verified for §3 push-layer mic gate
diegolmello Jun 8, 2026
7dbc55b
fix(voip): reject incoming call at push layer when mic denied (iOS)
diegolmello Jun 8, 2026
b878087
action: organized translations
diegolmello Jun 8, 2026
fd2e93a
fix(voip): observe init() rejection and clear answeringCallIds on reset
diegolmello Jun 11, 2026
3ddeccd
fix(voip): suppress mic-denied incoming call locally, no server reject
diegolmello Jun 11, 2026
681dfa8
docs(voip): bump FLOWS _Last verified for §3 suppress gate
diegolmello Jun 11, 2026
d0c79d5
chore(i18n): translate Microphone_access_needed_for_voice_calls into …
diegolmello Jun 12, 2026
c1281ff
refactor(voip): dedupe incoming-push staleness check and require expl…
diegolmello Jun 12, 2026
73c396f
refactor(voip): pre-acquire microphone at the end of MediaSessionInst…
diegolmello Jun 12, 2026
ccb79eb
fix(voip): treat a microphone-request throw as denied but re-promptable
diegolmello Jun 12, 2026
853fd5b
docs(voip): remove committed ADRs and ADR references; tighten gate co…
diegolmello Jun 12, 2026
f16b18e
docs(voip): bump FLOWS §1 _Last verified for pre-acquire-at-init move
diegolmello Jun 12, 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
Expand Up @@ -6,17 +6,27 @@ package chat.rocket.reactnative.voip
*/
internal enum class VoipIncomingPushAction {
STALE,
IGNORE_NO_PERMISSION,
REJECT_BUSY,
SHOW_INCOMING
}

/**
* Pure routing decision for an incoming VoIP push.
* Precedence: stale → mic denied → busy → show. Mic-denied precedes busy and suppresses the call
* locally with no server signal, so it keeps ringing on the user's other devices.
*/
internal fun decideIncomingVoipPushAction(
isValidForIncomingHandling: Boolean,
hasActiveCall: Boolean
hasActiveCall: Boolean,
hasMicPermission: Boolean
): VoipIncomingPushAction {
if (!isValidForIncomingHandling) {
return VoipIncomingPushAction.STALE
}
if (!hasMicPermission) {
return VoipIncomingPushAction.IGNORE_NO_PERMISSION
}
return if (hasActiveCall) {
VoipIncomingPushAction.REJECT_BUSY
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,19 +486,15 @@ class VoipNotification(private val context: Context) {
}

/**
* Rejects an incoming call because the user is already on another call.
* Rejects an incoming call over REST without presenting any UI.
*
* Uses [MediaCallsAnswerRequest] over REST to send the reject signal.
* Unlike [startListeningForCallEnd] (used by the normal incoming-call path), this
* does NOT subscribe to `stream-notify-user` or install a collection-message
* handler, because no incoming-call UI was ever shown and there is nothing
* to dismiss if the caller hangs up or another device answers.
*/
@JvmStatic
fun rejectBusyCall(context: Context, payload: VoipPayload) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Rejected busy call ${payload.callId} — user already on a call")
}
private fun rejectIncomingCallSilently(context: Context, payload: VoipPayload) {
cancelTimeout(payload.callId)
MediaCallsAnswerRequest.fetch(
context = context,
Expand All @@ -510,6 +506,47 @@ class VoipNotification(private val context: Context) {
) { _ -> }
}

/**
* Rejects an incoming call because the user is already on another call.
*/
@JvmStatic
fun rejectBusyCall(context: Context, payload: VoipPayload) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Rejected busy call ${payload.callId} — user already on a call")
}
rejectIncomingCallSilently(context, payload)
}

/**
* Returns true when [payload] is fresh enough to present as an incoming call.
* Logs and returns false when [getRemainingLifetimeMs] is null (no createdAt) or the
* call has already expired.
*/
private fun isPayloadFreshForIncoming(payload: VoipPayload): Boolean {
if (payload.getRemainingLifetimeMs() == null) {
if (BuildConfig.DEBUG) {
Log.w(TAG, "Skipping incoming VoIP call without a valid createdAt timestamp - callId: ${payload.callId}")
}
return false
}
if (payload.isExpired()) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Skipping expired incoming VoIP call - callId: ${payload.callId}")
}
return false
}
return true
}

/** Log-only: no Telecom registration, no notification, no per-call DDP, no REST —
* the device stays silent and the call keeps ringing on the user's other devices. */
@JvmStatic
fun ignoreNoMicPermissionCall(payload: VoipPayload) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Ignoring incoming call ${payload.callId} — microphone permission denied")
}
}

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

@JvmStatic
Expand Down Expand Up @@ -663,23 +700,14 @@ class VoipNotification(private val context: Context) {
fun onMessageReceived(voipPayload: VoipPayload) {
when {
voipPayload.isVoipIncomingCall() -> {
val isValidForIncoming =
voipPayload.getRemainingLifetimeMs() != null && !voipPayload.isExpired()
when (decideIncomingVoipPushAction(isValidForIncoming, hasActiveCall(context))) {
VoipIncomingPushAction.STALE -> {
if (voipPayload.getRemainingLifetimeMs() == null) {
if (BuildConfig.DEBUG) {
Log.w(
TAG,
"Skipping incoming VoIP call without a valid createdAt timestamp - callId: ${voipPayload.callId}"
)
}
} else {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Skipping expired incoming VoIP call - callId: ${voipPayload.callId}")
}
}
}
val isValidForIncoming = isPayloadFreshForIncoming(voipPayload)
val hasMicPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
when (decideIncomingVoipPushAction(isValidForIncoming, hasActiveCall(context), hasMicPermission)) {
VoipIncomingPushAction.STALE -> { /* logged by isPayloadFreshForIncoming */ }
VoipIncomingPushAction.IGNORE_NO_PERMISSION -> ignoreNoMicPermissionCall(voipPayload)
VoipIncomingPushAction.REJECT_BUSY -> rejectBusyCall(context, voipPayload)
VoipIncomingPushAction.SHOW_INCOMING -> showIncomingCall(voipPayload)
}
Expand Down Expand Up @@ -724,21 +752,9 @@ class VoipNotification(private val context: Context) {
* @param voipPayload The VoIP payload containing call information
*/
fun showIncomingCall(voipPayload: VoipPayload) {
if (!isPayloadFreshForIncoming(voipPayload)) return
val callId = voipPayload.callId
val caller = voipPayload.caller
if (voipPayload.getRemainingLifetimeMs() == null) {
if (BuildConfig.DEBUG) {
Log.w(TAG, "Skipping incoming VoIP call without a valid createdAt timestamp - callId: $callId")
}
return
}

if (voipPayload.isExpired()) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Skipping expired incoming VoIP call - callId: $callId")
}
return
}

if (BuildConfig.DEBUG) {
Log.d(TAG, "Showing incoming VoIP call - callId: $callId, caller: $caller")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,67 @@ class VoipIncomingCallDispatchTest {
fun `stale push with active call does not route to reject busy`() {
assertEquals(
VoipIncomingPushAction.STALE,
decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = true)
decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = true, hasMicPermission = true)
)
}

@Test
fun `stale push without active call does not route to show incoming`() {
assertEquals(
VoipIncomingPushAction.STALE,
decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = false)
decideIncomingVoipPushAction(isValidForIncomingHandling = false, hasActiveCall = false, hasMicPermission = true)
)
}

@Test
fun `valid push with active call rejects busy`() {
assertEquals(
VoipIncomingPushAction.REJECT_BUSY,
decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = true)
decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = true, hasMicPermission = true)
)
}

@Test
fun `valid push without active call shows incoming`() {
assertEquals(
VoipIncomingPushAction.SHOW_INCOMING,
decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = false)
decideIncomingVoipPushAction(isValidForIncomingHandling = true, hasActiveCall = false, hasMicPermission = true)
)
}

@Test
fun `valid push without microphone permission is ignored`() {
assertEquals(
VoipIncomingPushAction.IGNORE_NO_PERMISSION,
decideIncomingVoipPushAction(
isValidForIncomingHandling = true,
hasActiveCall = false,
hasMicPermission = false
)
)
}

@Test
fun `microphone denied takes precedence over busy`() {
assertEquals(
VoipIncomingPushAction.IGNORE_NO_PERMISSION,
decideIncomingVoipPushAction(
isValidForIncomingHandling = true,
hasActiveCall = true,
hasMicPermission = false
)
)
}

@Test
fun `stale push without microphone permission is still stale`() {
assertEquals(
VoipIncomingPushAction.STALE,
decideIncomingVoipPushAction(
isValidForIncomingHandling = false,
hasActiveCall = false,
hasMicPermission = false
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,9 @@ jest.mock('../../lib/native/NativeVoip', () => ({
default: { stopNativeDDPClient: jest.fn() }
}));
jest.mock('../../lib/methods/voipCallPermissions', () => ({
requestVoipCallPermissions: jest.fn().mockResolvedValue(true)
requestVoipCallPermissions: jest.fn().mockResolvedValue({ granted: true, canAskAgain: true, prompted: false }),
hasVoipCallPermission: jest.fn().mockResolvedValue(true),
preAcquireVoipMicPermission: jest.fn().mockResolvedValue(undefined)
}));
jest.mock('../../lib/hooks/useIsScreenReaderEnabled', () => ({
useIsScreenReaderEnabled: jest.fn(() => false)
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/bn-IN.json
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@
"Message_was_not_read": "বার্তাটি পড়া হয়নি।",
"Message_was_read": "বার্তাটি পড়া হয়েছে।",
"messages": "বার্তা",
"Microphone_access_needed_for_voice_calls": "ভয়েস কলের জন্য মাইক্রোফোন অ্যাক্সেস প্রয়োজন",
"Missed_call": "মিস কল",
"missing_room_e2ee_description": "ঘরের জন্য এনক্রিপশন কীগুলি আপডেট করা প্রয়োজন, এর জন্য আরেকজন ঘরের সদস্যকে অনলাইনে থাকতে হবে।",
"missing_room_e2ee_title": "কয়েক মুহূর্তের মধ্যে পরীক্ষা করুন",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/cs.json
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@
"Message_was_not_read": "Zpráva nebyla přečtena.",
"Message_was_read": "Zpráva byla přečtena.",
"messages": "zprávy",
"Microphone_access_needed_for_voice_calls": "Pro hlasové hovory je nutný přístup k mikrofonu",
"Microphone_access_needed_to_record_audio": "Pro záznam zvuku je nutný přístup k mikrofonu",
"Missed_call": "Zmeškaný hovor",
"Moderator": "Moderátor",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@
"Message_was_not_read": "Message was not read",
"Message_was_read": "Message was read",
"messages": "messages",
"Microphone_access_needed_for_voice_calls": "Microphone access needed for voice calls",
Comment thread
diegolmello marked this conversation as resolved.
"Microphone_access_needed_to_record_audio": "Microphone access needed to record audio",
"Missed_call": "Missed call",
"missing_room_e2ee_description": "The encryption keys for the room need to be updated, another room member needs to be online for this to happen.",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/hi-IN.json
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@
"Message_was_not_read": "संदेश नहीं पढ़ा गया",
"Message_was_read": "संदेश पढ़ा गया था।",
"messages": "संदेश",
"Microphone_access_needed_for_voice_calls": "वॉयस कॉल के लिए माइक्रोफ़ोन का उपयोग आवश्यक है",
"Missed_call": "कॉल छूट गई है",
"missing_room_e2ee_description": "कमरे के लिए एन्क्रिप्शन कुंजियों को अपडेट किया जाना चाहिए, इसके लिए एक और कमरे के सदस्य को ऑनलाइन होना चाहिए।",
"missing_room_e2ee_title": "कुछ क्षणों में वापस जाँच करें",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,7 @@
"Message_was_not_read": "Az üzenet nem lett elolvasva.",
"Message_was_read": "Az üzenet elolvasva.",
"messages": "üzenetek",
"Microphone_access_needed_for_voice_calls": "Mikrofonhozzáférés szükséges hanghívásokhoz",
"Missed_call": "Nem fogadott hívás",
"missing_room_e2ee_description": "A szoba titkosítási kulcsait frissíteni kell, ehhez egy másik szobatagnak online kell lennie.",
"missing_room_e2ee_title": "Nézzen vissza néhány perc múlva",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/no.json
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,7 @@
"Message_was_not_read": "Meldingen ble ikke lest",
"Message_was_read": "Meldingen ble lest",
"messages": "meldinger",
"Microphone_access_needed_for_voice_calls": "Mikrofontilgang nødvendig for talesamtaler",
"Microphone_access_needed_to_record_audio": "Mikrofontilgang nødvendig for å ta opp lyd",
"Missed_call": "Ubesvart anrop",
"missing_room_e2ee_description": "Krypteringsnøklene for rommet må oppdateres, et annet rommedlem må være online for at dette skal skje.",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,7 @@
"Message_was_not_read": "Mensagem não foi lida",
"Message_was_read": "Mensagem foi lida",
"messages": "mensagens",
"Microphone_access_needed_for_voice_calls": "Acesso ao microfone necessário para chamadas de voz",
"Microphone_access_needed_to_record_audio": "Acesso ao microfone necessário para gravar áudio",
"Missed_call": "Chamada perdida",
"missing_room_e2ee_description": "As chaves de criptografia da sala precisam ser atualizadas, outro membro da sala precisa estar online para que isso aconteça.",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/ta-IN.json
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@
"Message_was_not_read": "செய்தி படிக்கப்படவில்லை",
"Message_was_read": "செய்தி வாசிக்கப்பட்டது",
"messages": "செய்திகள்",
"Microphone_access_needed_for_voice_calls": "குரல் அழைப்புகளுக்கு மைக்ரோஃபோன் அணுகல் தேவை",
"Missed_call": "கால் பிழை",
"missing_room_e2ee_description": "அறைக்கான குறியாக்க விசைகள் புதுப்பிக்கப்பட வேண்டும், இது நடக்க மற்றொரு அறை உறுப்பினர் ஆன்லைனில் இருக்க வேண்டும்.",
"missing_room_e2ee_title": "சில நிமிடங்களில் மீண்டும் சரிபார்க்கவும்",
Expand Down
1 change: 1 addition & 0 deletions app/i18n/locales/te-IN.json
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@
"Message_was_not_read": "సందేశం చదవలేదు",
"Message_was_read": "సందేశం చదవబడింది",
"messages": "సందేశాలు",
"Microphone_access_needed_for_voice_calls": "వాయిస్ కాల్స్‌కు మైక్రోఫోన్ యాక్సెస్ అవసరం",
"Missed_call": "మిస్డ్ కాల్",
"missing_room_e2ee_description": "గది కోసం ఎన్క్రిప్షన్ కీలు నవీకరించబడాలి, దీని జరగాలంటే మరొక గది సభ్యుడు ఆన్లైన్‌లో ఉండాలి.",
"missing_room_e2ee_title": "కొన్ని క్షణాల్లో తిరిగి తనిఖీ చేయండి",
Expand Down
Loading
Loading