Skip to content

fix(voip): prevent duplicate ringtone on Android incoming call#7158

Merged
diegolmello merged 1 commit intofeat.voip-lib-newfrom
claude/mystifying-hamilton
Apr 20, 2026
Merged

fix(voip): prevent duplicate ringtone on Android incoming call#7158
diegolmello merged 1 commit intofeat.voip-lib-newfrom
claude/mystifying-hamilton

Conversation

@diegolmello
Copy link
Copy Markdown
Member

@diegolmello diegolmello commented Apr 15, 2026

Proposed changes

Android VoIP pushes currently play two concurrent ringtones on incoming calls:

  1. NotificationChannel (IMPORTANCE_HIGH + default ringtone URI + USAGE_NOTIFICATION_RINGTONE) — plays when the notification posts.
  2. IncomingCallActivity.startRingtone() — plays RingtoneManager.getDefaultUri(...) via Ringtone.play() in onCreate.

Both fire simultaneously because the full-screen-intent notification posts and launches the activity in parallel, so the user hears overlapping / phasing audio.

This PR makes the NotificationChannel the sole ring source and adds setOnlyAlertOnce(true) as a safety net against same-notificationId reposts (e.g. FCM retries).

Design rationale

  • Channel-owned ring is the Google-recommended pattern for call-style full-screen-intent notifications (matches Signal / WhatsApp).
  • Channel sound still plays even on OEMs (MIUI, etc.) that silently block full-screen-intent activity launches — so dropping the activity-side ring does not regress reliability; it actually improves it.
  • Accept/decline/timeout paths already call cancelById(notificationId) on every exit, so cancelling the notification reliably stops the channel audio.
  • Pre-Android-O (API 24/25) keeps its existing builder.setSound(ringtoneUri) path (NotificationChannel doesn't exist on pre-O) — single source preserved there as well.

Files changed

  • IncomingCallActivity.kt: remove startRingtone/stopRingtone methods, the ringtone field, all 7 call sites, and the now-unused Ringtone / RingtoneManager imports. Add a class-header KDoc noting that ring audio is owned by VoipNotification to prevent accidental reintroduction.
  • VoipNotification.kt: add setOnlyAlertOnce(true) to the incoming-call NotificationCompat.Builder.

Full analysis + acceptance criteria + design options are in ~/plans/voip-android-duplicate-ring/plan.md (local-only, not committed).

Issue(s)

https://rocketchat.atlassian.net/browse/VMUX-76

How to test or reproduce

Before this PR (reproduction):

  1. Trigger an incoming VoIP call to a device running feat.voip-lib-new.
  2. Listen carefully — two ringtones overlap (audible phasing / echo). Record audio for clearer comparison.

After this PR (verification):

  1. Smoke — locked screen: device locked → trigger VoIP push → single clean ringtone. Android 10, 12, 14.
  2. Smoke — unlocked background: app backgrounded → push arrives → heads-up + FSI → single ring during transition.
  3. Smoke — unlocked foreground: push arrives while app open → single ring via heads-up.
  4. Accept stops audio: tap accept → ring cuts within ~300 ms.
  5. Decline stops audio: tap decline → ring cuts immediately.
  6. Timeout stops audio: let remainingLifetimeMs expire → ring cuts, notification cancelled.
  7. Duplicate FCM push (same callId): re-send identical push → no re-alert (thanks to setOnlyAlertOnce(true)).
  8. Busy-call guard regression: push while already in a call → rejectBusyCall fires, no ring (pre-existing behavior).
  9. Pre-O emulator (API 24): verify ring still works via builder.setSound.

Screenshots

Not applicable — audio-only 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 — Android-only change; local Kotlin compile could not run in this worktree (metro/symlink env issue). CI will verify. Recommend running yarn lint + ./gradlew :app:compileOfficialDebugKotlin on a full checkout.
  • I have added tests that prove my fix is effective or that my feature works (if applicable) — No Kotlin unit tests exist in this repo. Requires manual on-device verification per "How to test" above.
  • I have added necessary documentation (if applicable) — Added class-header KDoc in IncomingCallActivity.kt.
  • Any dependent changes have been merged and published in downstream modules

Further comments

The change is net -27 lines (31 deletions, 4 insertions). No new logic added — all of the real work is removing redundant code and relying on the already-wired NotificationChannel audio path. No API changes, no state changes, no new dependencies.

Summary by CodeRabbit

  • Bug Fixes
    • Improved incoming call audio alert handling to prevent duplicate ringtone sounds and vibrations when call notifications update.

Android VoIP pushes were playing two concurrent ringtones: the
NotificationChannel (IMPORTANCE_HIGH + default ringtone URI) and
IncomingCallActivity's own Ringtone.play() in onCreate. Both fired
simultaneously because the full-screen-intent notification posts and
launches the activity in parallel, producing overlapping/phasing audio.

Make VoipNotification's channel the sole ring source and add
setOnlyAlertOnce(true) as a safety net against same-id reposts.
Pre-O devices continue to rely on builder.setSound.

- IncomingCallActivity: remove startRingtone/stopRingtone and all
  call sites; the activity is now visual-only. Add class KDoc
  explaining ringtone ownership to prevent reintroduction.
- VoipNotification: add setOnlyAlertOnce(true) to the incoming-call
  builder.
@diegolmello diegolmello requested a deployment to approve_e2e_testing April 15, 2026 14:33 — with GitHub Actions Waiting
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 15, 2026

Walkthrough

Consolidated VoIP incoming call ringtone management from the activity layer to the notification layer. Removed direct ringtone playback/stop logic from IncomingCallActivity and added setOnlyAlertOnce(true) to the VoipNotification builder to prevent repeated alert sounds on notification updates.

Changes

Cohort / File(s) Summary
Ringtone Management Refactoring
android/app/src/main/java/chat/rocket/reactnative/voip/IncomingCallActivity.kt
Removed all ringtone field and playback/stop methods (startRingtone, stopRingtone). Ringtone responsibility now delegated to VoipNotification's NotificationChannel to prevent double-ringing.
Notification Alert Behavior
android/app/src/main/java/chat/rocket/reactnative/voip/VoipNotification.kt
Added setOnlyAlertOnce(true) to notification builder to suppress repeated alert sounds and vibrations when the notification is updated.

Estimated code review effort

🎯 1 (Trivial) | ⏱️ ~5 minutes

Suggested labels

type: bug

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: removing duplicate ringtone playback on Android VoIP incoming calls by consolidating ring audio management.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ 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 requested a deployment to experimental_ios_build April 15, 2026 14:37 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to experimental_android_build April 15, 2026 14:37 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to official_android_build April 15, 2026 14:37 — with GitHub Actions Waiting
@diegolmello diegolmello merged commit 3c21cb8 into feat.voip-lib-new Apr 20, 2026
5 of 10 checks passed
@diegolmello diegolmello deleted the claude/mystifying-hamilton branch April 20, 2026 13:13
diegolmello added a commit that referenced this pull request Apr 22, 2026
…/Decline (#7215)

* merge feat.voip-lib

* feat(voip): enhance call handling with UUID mapping and event listeners

* Base call UI

* feat(voip): integrate Zustand for call state management and enhance CallView UI

* feat(voip): add simulateCall function for mock call handling in UI development

* refactor(CallView): update button handlers and improve UI responsiveness

* Add pause-shape-unfilled icon

* Base CallHeader

* toggleFocus

* collapse buttons

* Header components

* Hide header when no call

* Timer

* Add use memo

* Add voice call item on sidebar

* cleanup

* Temp use @rocket.chat/media-signaling from .tgz

* cleanup

* Check module and permissions to enable voip

* Refactor stop method to use optional chaining for media signal listeners

* voip push first test

* Add VoIP call handling with pending call management

- Implemented VoIP push notification handling in index.js, including storing call info for later processing.
- Added CallKeep event handlers for answering and ending calls from a cold start.
- Introduced a new CallIdUUID module to convert call IDs to deterministic UUIDs for compatibility with CallKit.
- Created a pending call store to manage incoming calls when the app is not fully initialized.
- Updated deep linking actions to include VoIP call handling.
- Enhanced MediaSessionInstance to process pending calls and manage call states effectively.

* Remove pending store and create getInitialEvents on app/index

* Attempt to make iOS calls work from cold state

* lint and format

* Patch callkeep ios

* Temp send iOS voip push token on gcm

* Temp fix require cycle

* chore: format code and fix lint issues [skip ci]

* CallIDUUID module on android and voip push

* Add setCallUUID on useCallStore to persist calls accepted on native Android

* remove callkeep from notification

* Android Incoming Call UI POC

* Refactor VoIP handling: Migrate VoIP-related classes to a new package structure, removing deprecated modules and consolidating functionality. Update imports in MainApplication and NotificationIntentHandler to reflect changes. This cleanup enhances code organization and prepares for future VoIP feature enhancements.

* Remove VoipForegroundService

* cleanup and use caller instead of callerName

* Cleanup and make iOS build again

* Refactor VoIP handling: Remove unused event emissions for call answered and declined, switch from SharedPreferences to in-memory storage for pending VoIP call data, and update method signatures for better clarity. This cleanup enhances performance and prepares for future VoIP feature improvements.

* Refactor VoIP handling: Introduce a new VoipPayload class to encapsulate call data, streamline notification processing, and enhance method signatures across the VoIP module. This update improves code clarity and prepares for future feature enhancements.

* Migrate react-native-voip-push-notifications to VoipModule

* Refactor VoIP module: Update package structure by moving VoipTurboPackage to the main package and removing the obsolete NativeVoipSpec class. Adjust imports in MainApplication and VoipModule to reflect these changes, enhancing code organization and maintainability.

* Unify emitters

* Move CallKeep listeners from MediaSessionInstance to getInitialEvents

* Clear callkeep on endcall

* Unify getInitialEvents logic

* getInitialEvents -> MediaCallEvents

* chore: format code and fix lint issues [skip ci]

* feat(Android): Add full screen incoming call (#6977)

* feat: Update call UI (#6990)

* feat: Handle audio routing, e.g., Bluetooth headset vs. internal speaker switching (#6992)

* fix: empty space when not on call (#6993)

* feat: Dialpad (#7000)

* action: organized translations

* feat: start call (#7024)

* chore: format code and fix lint issues

* feat: Pre flight (#7038)

* action: organized translations

* feat: Receive voip push notifications from backend (#7045)

* feat: Refactor media session handling and improve disconnect logic (#7065)

* feat: Control incoming call from native (#7066)

* feat: Voice message blocks (#7057)

* feat: native accept success event (#7068)

* feat(voip): call waiting, busy detection, and videoconf blocking (#7077)

* action: organized translations

* feat(voip): tap-to-hide call controls with animations (#7078)

* feat(voip): navigate to call DM from message button and header (#7082)

* feat(voip): tablet and landscape layout (#7110)

* chore: develop into feat.voip-lib-new (RN 81 + Expo 54 + reanimated 4 + true-sheet + iOS 26) (#7114)

* chore: format code and fix lint issues

* feat(voip): android landscape layout for IncomingCallActivity (#7116)

* Update agents files

* feat(voip): Support a11y (#7106)

* Fix content cutting on iOS on some edge cases

* pods

* Ignore .worktrees on jest

* chore: Merge develop into feat.voip-lib-new (#7129)

* fix(voip): show CallKit UI when call is active in background (#7128)

* chore: Update media-signaling to 0.2.0 (#7153)

* feat(voip): migrate iOS accept/reject from DDP to REST (#7124)

* Fix icons

* feat(voip): migrate Android accept/reject from DDP to REST (#7127)

* test(voip): integration tests for CallView pipeline (#7161)

* feat(voip): display video conf provider as subtitle (#7160)

* fix(voip): CallView button grid and correct landscape/dialpad layouts (#7164)

* fix(voip): prevent stale MMKV cache on Android first-install accept

MMKVKeyManager.initialize ran in MainApplication.onCreate before the JS
engine started and opened the default MMKV file via the Tencent 1.2 JAR
when it was still empty. Tencent caches instances per-ID in a singleton
registry, so that empty-state view was held for the rest of the process.
JS later wrote credentials through react-native-mmkv (MMKV Core 2.0),
which has its own separate registry. When a VoIP push arrived,
Ejson.getMMKV() got the cached empty Tencent instance and reported
"No userId found in MMKV for server". Closing and reopening the app
cleared the cache, which is why only the very first call after install
failed.

Drop the open/verify block — the encryption key is already cached from
SecureKeystore, so no MMKV handle is needed here. The first Tencent
instance is now created inside Ejson.getMMKV() after JS has written,
so it scans the file fresh.

* fix(voip): prevent duplicate ringtone on Android incoming call (#7158)

* fix(voip): set explicit snaps for NewMediaCall bottom sheet (#7165)

* Update app/lib/services/voip/MediaSessionStore.ts

Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>

* fix: make startVoipFork reactive to permissions-changed (#7151)

* fix(android): remove MediaProjectionService from merged manifest (#7190)

* fix(voip): Phone account creation (#7170)

* feat: add Enable Mobile Ringing toggle in user preferences (#7155)

* fix(voip): ship blockers for PushKit, licensing, outbound calls, push tokens (#7167)

* fix(android): Play Store mic discoverability, safer FCM logs, avatar auth via headers (#7171)

* fix(ios): serialize VoipService bridge statics (#7169)

* fix(voip): Android DDP thread safety and VoipPayload bundle parity (#7168)

* chore(voip): dead-code and hygiene sweep (#7174)

* refactor(voip): decouple navigateToCallRoom from Redux and backfill REST/connect tests (#7176)

* test(voip): tighten ringing endCall assertion and add VideoConf VoIP-lock saga coverage (#7177)

* fix(ios): harden VoIP DDP WebSocket client on receive failures and TLS (#7173)

* refactor(voip): MediaCallEvents Redux adapters and resetVoipState (#7178)

* refactor(voip): decouple peer autocomplete from Redux; simplify NewMediaCall (#7175)

* fix(ios): add NS_SWIFT_NAME to Challenge.runChallenge for Swift 6.2 compatibility

Swift 6.2 (Xcode 26.x / macos-26 runner) auto-renames the Objective-C
method runChallenge:didReceiveChallenge:completionHandler: to
run(_:didReceive:completionHandler:) when imported into Swift.

Add NS_SWIFT_NAME to explicitly pin the Swift import name, preventing
the compiler from applying its heuristics. This keeps the existing
Swift call site in DDPClient.swift working without changes.

* fix(ios): cancel old URLSession/webSocketTask before reconnecting in DDPClient.connect (#7197)

* fix(ios): add NSLock to nativeAcceptHandledCallIds and 10s REST timeout to handleNativeAccept (#7198)

* feat(android): create VoipCallService with FOREGROUND_SERVICE_MICROPHONE (#7199)

* fix(android): start VoipCallService on accept, stop on hangup/timeout, install end-call listener (#7200)

* fix(voip): enable DM nav for users with SIP extension (#7203)

* fix(android): handle null VoiceConnection in answerIncomingCall, notify JS (#7201)

* fix(voip): resolve closure capture ordering in handleNativeAccept (#7209)

* fix(android): integrate VoIP modules with SSL-pinned OkHttpClient (#7208)

* fix(push): gate id and voipToken behind server version checks, fix VideoConf caller extra (#7210)

* fix(voip): remove sensitive data from production logs (#7207)

* fix(android): remove isRunning guard + add double-tap guard on Accept/Decline

- VoipCallService: remove if (!isRunning) guard, call startForeground unconditionally
  (idempotent on Android, fixes Android 14+ foreground service requirement)
- IncomingCallActivity: add AtomicBoolean guard on handleAccept/handleDecline
  to prevent double-tap from triggering multiple service starts

---------

Co-authored-by: diegolmello <diegolmello@users.noreply.github.com>
Co-authored-by: Pierre Lehnen <55164754+pierre-lehnen-rc@users.noreply.github.com>
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