Skip to content

fix(android/voip): use phoneCall FGS type and fix inverted accept/decline guard#7233

Merged
diegolmello merged 1 commit into
feat.voip-lib-newfrom
fix/voip-android-fgs-locked-call-accept
Apr 24, 2026
Merged

fix(android/voip): use phoneCall FGS type and fix inverted accept/decline guard#7233
diegolmello merged 1 commit into
feat.voip-lib-newfrom
fix/voip-android-fgs-locked-call-accept

Conversation

@diegolmello

@diegolmello diegolmello commented Apr 23, 2026

Copy link
Copy Markdown
Member

Proposed changes

Two independent bugs surfaced in user reports on Android 16 (targetSdk=36) when a VoIP call arrives on a locked device:

Bug 1 — VoipCallService microphone FGS eligibility violation (process crash).
VoipCallService.startForegroundWithNotification was calling startForeground(..., FOREGROUND_SERVICE_TYPE_MICROPHONE) unconditionally on API 29+. On Android 14+ the microphone type is a "while-in-use" FGS and requires the process to be in a foreground-eligible state at startForeground() time. Our accept paths call VoipCallService.startService as the first statement of VoipNotification.handleAcceptAction, which runs from either IncomingCallActivity (FSI, above-lock separate-task Activity) or MainActivity.onNewIntent (HUN, not yet resumed) — neither context satisfies the eligibility check on targetSdk 36. The result is a FATAL EXCEPTION: main caused by SecurityException: Starting FGS with type microphone ... requires permissions [FOREGROUND_SERVICE_MICROPHONE] and any of [RECORD_AUDIO, ...] and the app must be in the eligible state/exemptions.

Switching to FOREGROUND_SERVICE_TYPE_PHONE_CALL resolves this: the phoneCall type's eligibility is satisfied by the self-managed Telecom call already registered via telecomManager.addNewIncomingCall in VoipNotification.registerCallWithTelecomManager. FOREGROUND_SERVICE_PHONE_CALL is already declared in the manifest.

Bug 2 — Inverted acceptDeclineGuard in IncomingCallActivity (first-tap no-op on locked screen).
handleAccept / handleDecline both had if (acceptDeclineGuard.compareAndSet(false, true)) return. AtomicBoolean.compareAndSet(false, true) returns true on the first call (CAS succeeds), so the guard was returning on the FIRST tap and only letting subsequent taps through — the opposite of a single-fire guard. This is only observable on the FSI/locked-screen path (the HUN path goes through MainActivity), which matches the "accepting while locked doesn't open the app" symptom.

Inverting to if (!acceptDeclineGuard.compareAndSet(false, true)) return restores the intended "first tap proceeds, subsequent taps dropped" semantics.

Issue(s)

User-reported FATAL crash on Android 16 tapping incoming VoIP call notification; lock-screen Accept appearing to do nothing.

How to test or reproduce

  1. Build against targetSdk 36 and install on an Android 16 device.
  2. Lock the device.
  3. Trigger an incoming VoIP call.
  4. On the full-screen incoming-call UI, tap Accept — the call should accept on the first tap (Bug 2) and the app should open without crashing (Bug 1).
  5. Repeat with Decline on first tap to verify Bug 2 for the decline path too.
  6. Repeat unlocked (HUN path) — Accept should still work; this path did not exhibit Bug 2 but relies on the same VoipCallService, so Bug 1's fix applies here as well.

Screenshots

N/A — native Android behavior change.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Improvement (non-breaking change which improves a current function)
  • New feature (non-breaking change which adds functionality)
  • Documentation update (if none of the other choices apply)

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)
  • I have added necessary documentation (if applicable)
  • Any dependent changes have been merged and published in downstream modules

Further comments

CallKeep's own VoiceConnectionService already declares microphone|phoneCall in the manifest but is never actually promoted to FGS at runtime because RNCallKeep.setup() is not passed a foregroundService config from the JS side — so VoipCallService is the only FGS keeping the audio session alive. A cleaner future refactor would pass that config to CallKeep and remove VoipCallService entirely, relying on the Telecom-exempted FGS path. That is out of scope for this surgical fix.

Known separate observation (not addressed here): the registerCallWithTelecomManager catches at VoipNotification.kt:794-798 silently swallow SecurityException/Exception at Log.e severity only. If addNewIncomingCall fails, the Telecom FGS exemption is never granted and phoneCall eligibility may still fail. Worth tracking in a separate issue.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed handling of rapid accept/decline button taps on incoming calls to prevent unintended duplicate actions
    • Updated VoIP service configuration to properly support phone call operations on Android devices

…line guard

- VoipCallService: switch foregroundServiceType from `microphone` to `phoneCall`
  (manifest + ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL). On Android 14+ /
  targetSdk 36 the microphone type is a "while-in-use" FGS that requires the
  process to be in a foreground-eligible state at startForeground() time. The
  accept paths call VoipCallService.startService before MainActivity / the FSI
  Activity is in that eligible state, producing a SecurityException and a FATAL
  process crash when the user taps Accept on the call notification (especially
  from a locked device on Android 16). FOREGROUND_SERVICE_PHONE_CALL is already
  declared; self-managed Telecom (addNewIncomingCall) covers the phoneCall type.

- IncomingCallActivity.handleAccept / handleDecline: invert the compareAndSet
  guard. `compareAndSet(false, true)` returns true when the CAS succeeds on the
  first tap; the previous `if (... ) return` therefore returned on the FIRST
  tap and only allowed subsequent taps through, silently dropping the first
  Accept/Decline on the locked-screen FSI path.
@coderabbitai

coderabbitai Bot commented Apr 23, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 992a6a23-f413-4499-8c62-f21dabddcc1f

📥 Commits

Reviewing files that changed from the base of the PR and between b232ea1 and 7021949.

📒 Files selected for processing (3)
  • android/app/src/main/AndroidManifest.xml
  • android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipCallService.kt
📜 Recent 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
🧠 Learnings (3)
📓 Common learnings
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP features in app/lib/services/voip/ directory using Zustand stores for WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration
📚 Learning: 2026-04-22T22:57:58.545Z
Learnt from: CR
Repo: RocketChat/Rocket.Chat.ReactNative PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-04-22T22:57:58.545Z
Learning: Applies to app/lib/services/voip/**/*.{ts,tsx} : Implement VoIP features in app/lib/services/voip/ directory using Zustand stores for WebRTC peer-to-peer audio calls with native CallKit (iOS) and Telecom (Android) integration

Applied to files:

  • android/app/src/main/java/chat/rocket/reactnative/voip/VoipCallService.kt
  • android/app/src/main/AndroidManifest.xml
📚 Learning: 2026-04-22T21:35:29.021Z
Learnt from: diegolmello
Repo: RocketChat/Rocket.Chat.ReactNative PR: 7211
File: android/app/src/main/res/values-hu/strings_incoming_call.xml:0-0
Timestamp: 2026-04-22T21:35:29.021Z
Learning: In the Rocket.Chat React Native codebase, Hungarian (values-hu) Android string resources for call action buttons use informal imperative verb forms (e.g., "Elutasít" for reject, "Elfogad" for accept) rather than noun forms or formal imperatives. Formal imperatives like "Elutasítson"/"Elfogadjon" are avoided because they are too long for compact call UI button labels.

Applied to files:

  • android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
🔇 Additional comments (3)
android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt (1)

262-283: Guard inversion is correct and matches the convention used elsewhere.

AtomicBoolean.compareAndSet(false, true) returns true on the successful first transition, so if (!acceptDeclineGuard.compareAndSet(false, true)) return correctly lets the first tap through and drops subsequent taps / the onBackPressed → handleDecline path. This is the same pattern already used by finished in VoipNotification.handleAcceptAction and by connectResultDelivered in DDPClient.tryDeliverConnectOutcome, so the fix restores consistency with the rest of the VoIP code.

Note (pre-existing, not introduced here): once Accept succeeds the guard stays set, so a later back press makes handleDecline no-op and the activity only closes via the async ACTION_DISMISS broadcast or the handleAcceptAction timeout. That's the intended behavior per the comment at line 270, just worth keeping in mind.

android/app/src/main/AndroidManifest.xml (1)

150-155: LGTM — manifest and runtime FGS type are now aligned.

android:foregroundServiceType="phoneCall" matches the FOREGROUND_SERVICE_TYPE_PHONE_CALL passed at runtime, and the required FOREGROUND_SERVICE_PHONE_CALL + MANAGE_OWN_CALLS permissions are already declared above. No other services reference the old microphone type for this component.

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

93-98: Fix looks correct — switch to FOREGROUND_SERVICE_TYPE_PHONE_CALL is appropriate.

FOREGROUND_SERVICE_TYPE_PHONE_CALL is available from API 29 so the >= Q guard is valid, and the manifest already declares both FOREGROUND_SERVICE_PHONE_CALL (line 18) and MANAGE_OWN_CALLS (line 24), which are the runtime permissions required when starting a phoneCall FGS on Android 14+ without the default dialer role.

The ordering on accept paths is correct: registerCallWithTelecomManager() (which invokes telecomManager.addNewIncomingCall(...)) is called in showIncomingCall() before the notification is presented. When the user accepts, handleAcceptAction() then calls VoipCallService.startService()startForeground(..., FOREGROUND_SERVICE_TYPE_PHONE_CALL). The self-managed Connection is already registered with Telecom at that point, so the eligibility check will succeed and the FGS promotion will not throw on SDK 36+.


Walkthrough

Changes the VoIP service's foreground service type from microphone to phone call across Android manifest and code configuration. Additionally fixes a concurrency issue in the accept/decline button handlers by inverting the guard logic to prevent duplicate actions on concurrent taps.

Changes

Cohort / File(s) Summary
VoIP Service Foreground Type Configuration
android/app/src/main/AndroidManifest.xml, android/app/src/main/java/chat/rocket/reactnative/voip/VoipCallService.kt
Updates foreground service type from FOREGROUND_SERVICE_TYPE_MICROPHONE to FOREGROUND_SERVICE_TYPE_PHONE_CALL in both manifest declaration and runtime service initialization.
Concurrency Guard Fix
android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
Inverts the result of acceptDeclineGuard.compareAndSet() in accept/decline button handlers to ensure only one action proceeds during concurrent taps.

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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: switching to phoneCall foreground service type and fixing the inverted accept/decline guard logic in the VoIP module.
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.

✏️ 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 diegolmello temporarily deployed to experimental_android_build April 23, 2026 21:03 — with GitHub Actions Inactive
@diegolmello diegolmello had a problem deploying to experimental_ios_build April 23, 2026 21:03 — with GitHub Actions Failure
@diegolmello diegolmello temporarily deployed to official_android_build April 23, 2026 21:03 — with GitHub Actions Inactive
@diegolmello diegolmello temporarily deployed to upload_experimental_android April 23, 2026 21:43 — with GitHub Actions Inactive
@diegolmello diegolmello temporarily deployed to upload_official_android April 23, 2026 21:44 — with GitHub Actions Inactive
@github-actions

Copy link
Copy Markdown

Android Build Available

Rocket.Chat Experimental 4.72.0.108624

Internal App Sharing: https://play.google.com/apps/test/RQVpXLytHNc/ahAO29uNTr-FIEywnCuX-OgZ3TIr69mYdO9M_n_DxvM4TyAN1S2KbsPjUnk8kklP5EWTPE_jPSKpa695hrsUfvx4Ai

@github-actions

Copy link
Copy Markdown

@diegolmello diegolmello changed the base branch from feat.voip-lib-new to voip/android-lockscreen-speaker April 24, 2026 11:56
@diegolmello diegolmello changed the base branch from voip/android-lockscreen-speaker to feat.voip-lib-new April 24, 2026 11:56
@diegolmello diegolmello merged commit 2a12c08 into feat.voip-lib-new Apr 24, 2026
13 of 18 checks passed
@diegolmello diegolmello deleted the fix/voip-android-fgs-locked-call-accept branch April 24, 2026 12:24
diegolmello added a commit that referenced this pull request May 27, 2026
…urvives backgrounding

Outgoing calls went through `MediaSessionInstance.startCall` directly to the
media-signaling SDK and never touched `VoipNotification`, so no foreground
service was started. With no FGS in the active set, `RECORD_AUDIO`
(while-in-use) is revoked ~5s after the user backgrounds the app, dropping
the caller's audio while the peer's still plays. Reported in NATIVE-1178.

The incoming-accept path already starts `VoipCallService` from native after
Telecom is active. This mirrors that for outgoing by exposing
`startVoipCallService(callId)` on the TurboModule and invoking it Android-only
from the `newCall` 'caller' branch, while the initiating activity is still
visible (so `microphone` FGS-start eligibility is satisfied).

FGS type widens from `phoneCall` to `microphone|phoneCall`:
- `phoneCall` keeps the incoming-accept entry point startable from
  IncomingCallActivity / MainActivity.onNewIntent via the active self-managed
  Telecom call (PR #7233 rationale).
- `microphone` is what actually authorises background mic capture — `phoneCall`
  alone is documented to keep the call alive via ConnectionService but does not
  exempt `RECORD_AUDIO` from the while-in-use clock.

Both bits are safe to start in either path because both paths reach
`startForeground()` from a while-in-use eligible state: outgoing from a visible
activity, incoming after `connection.onAnswer()` puts Telecom into STATE_ACTIVE.
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