Skip to content

feat: multi call handling#2163

Merged
greenfrvr merged 14 commits intofeat/callkit-telecom-integrationfrom
multi-call-handling
Mar 20, 2026
Merged

feat: multi call handling#2163
greenfrvr merged 14 commits intofeat/callkit-telecom-integrationfrom
multi-call-handling

Conversation

@greenfrvr
Copy link
Contributor

@greenfrvr greenfrvr commented Mar 16, 2026

💡 Overview

This PR contains multi call support for Android Telecom integration.
Main goal is to be able to manage notification for several simultaneous calls. E.g. if user is in active call they might receive new incoming call. For that new call new notification should be displayed and user should be able to reject/accept new incoming call.

Call repository refactor will allow to implement several simultaneous calls in future when "on hold" functionality will be presented.

screen-20260317-105012-1773740976267.mov

Summary by CodeRabbit

  • Bug Fixes

    • Improved notification handling and management for multiple concurrent calls
    • Fixed call acceptance to automatically leave other active calls before joining a new call
    • Enhanced cleanup of call state when registration times out or calls end
  • Refactor

    • Refactored internal call state tracking to provide better support for simultaneous calls

@greenfrvr greenfrvr self-assigned this Mar 16, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 16, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7380b9ef-f802-4167-bca0-d16badf927b9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The changes refactor the calling SDK to support multiple simultaneous calls, replacing single-call state tracking with per-call management using maps and callId-based lookups. Repositories, services, and notification handling are updated to operate on a per-call basis. The isServiceStarted API is removed across all platforms.

Changes

Cohort / File(s) Summary
Call State Repository Refactoring
CallRepository.kt, LegacyCallRepository.kt, TelecomCallRepository.kt
Replaced single currentCall tracking with per-call Map<String, Call.Registered>. Added per-call accessors (getCall, hasAnyCalls, hasRingingCall, hasActiveCall). Updated listener signature to include callId. Introduced addCall/removeCall and updateCallById methods. TelecomCallRepository adds per-call action tracking via CallActionFlags and observes per-call state changes.
Call Model and Service State
Call.kt, CallService.kt
Added isPending field to Call.Registered. CallService reworked to handle per-call notifications, state changes, and foreground service promotion. Introduced repromoteForegroundIfNeeded(callId) and refactored onCallStateChanged signature to include callId. Replaced direct currentCall access with callRepository.getCall(callId).
Multi-Call Notification Management
CallNotificationManager.kt
Introduced per-call notification tracking with CallNotificationState and notificationsState map. Added getOrCreateNotificationId(callId) for unique notification IDs per call. Updated createNotification, updateCallNotification, and new methods postNotification, cancelNotification, getForegroundCallId, cancelAllNotifications to operate per-call. Refactored optimisticState and snapshot logic to be per-call.
Call Registration and Cleanup
CallRegistrationStore.kt
Added callId cleanup to the timeout path of trackCallRegistration to remove timed-out calls from trackedCallIds.
Intent and Request Code Uniqueness
NotificationIntentFactory.kt
Introduced requestCodeFor(callId, base) helper to compute unique PendingIntent request codes by combining base offset with callId hash. Updated all PendingIntent creations to use per-call request codes. Changed base constants from 1002/1003 to 2001/3001.
Module API Changes
CallingxModuleImpl.kt, CallingxModule.kt (newarch and oldarch), Callingx.mm, NativeCallingx.ts
Removed isServiceStarted() API across all platform implementations (Android modules, iOS Objective-C, TypeScript spec). Changed CALL_END_ACTION cleanup to only unbind service if no registered calls remain.
Push Notification Handling
utils.ts
Updated accept action in processCallFromPush to leave other active calls (with state JOINED and different cid) before joining the new call.

Sequence Diagram

sequenceDiagram
    participant Client
    participant CallService
    participant CallRepository
    participant NotificationManager
    participant TelecomRepository

    Note over Client,TelecomRepository: Multi-Call Registration Flow (New)
    
    Client->>CallService: registerCall(callId1, callInfo1)
    CallService->>CallRepository: addCall(callId1, call)
    CallRepository->>CallRepository: updateCallById(callId1, pendingCall)
    
    Client->>CallService: registerCall(callId2, callInfo2)
    CallService->>CallRepository: addCall(callId2, call)
    CallRepository->>CallRepository: updateCallById(callId2, pendingCall)
    
    TelecomRepository->>CallRepository: updateCallById(callId1, activeCall)
    CallRepository->>NotificationManager: updateCallNotification(callId1, activeCall)
    NotificationManager->>NotificationManager: getOrCreateNotificationId(callId1)
    NotificationManager->>NotificationManager: postNotification(callId1, notification)
    CallService->>CallService: startForegroundSafely(notificationId1, notification)
    
    TelecomRepository->>CallRepository: updateCallById(callId2, activeCall)
    CallRepository->>NotificationManager: updateCallNotification(callId2, activeCall)
    NotificationManager->>NotificationManager: getOrCreateNotificationId(callId2)
    NotificationManager->>NotificationManager: postNotification(callId2, notification)
    
    Note over NotificationManager: Both calls tracked independently
    
    Client->>CallService: endCall(callId1)
    CallService->>NotificationManager: cancelNotification(callId1)
    NotificationManager->>NotificationManager: repromoteForegroundIfNeeded()
    CallRepository->>CallRepository: removeCall(callId1)
    
    Note over Client,TelecomRepository: callId2 remains active
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 From one call alone, now a chorus rings,
Per-callId tracking spreads its wings!
Maps replace singles, notifications align,
Multiple calls at once? Now truly fine! ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.49% 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 clearly and concisely describes the main objective of the PR: multi-call handling support across multiple components and repositories.
Description check ✅ Passed The PR description includes required sections (Overview) and provides clear context about the multi-call support implementation, goals, and references a video demo.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch multi-call-handling
📝 Coding Plan
  • Generate coding plan for human review comments

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.

Tip

You can customize the high-level summary generated by CodeRabbit.

Configure the reviews.high_level_summary_instructions setting to provide custom instructions for generating the high-level summary.

@changeset-bot
Copy link

changeset-bot bot commented Mar 16, 2026

⚠️ No Changeset found

Latest commit: 3fd7fc4

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@greenfrvr greenfrvr changed the title Multi call handling feat: multi call handling Mar 16, 2026
@greenfrvr greenfrvr requested a review from santhoshvai March 17, 2026 09:31
@greenfrvr greenfrvr marked this pull request as ready for review March 17, 2026 09:31
@greenfrvr
Copy link
Contributor Author

@coderabbitai review

1 similar comment
@greenfrvr
Copy link
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 17, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

1 similar comment
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 17, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt (1)

466-483: Clarify: Early return guards against duplicate registration requests.

The check on lines 467-468 prevents re-registering a call that's already tracked (including pending calls). This is correct for handling duplicate intents (e.g., push notification retries), but consider adding a brief comment explaining this guards against duplicate registration attempts.

📝 Optional documentation
-        // If this specific call is already registered, just notify
+        // If this call is already tracked (pending or registered), avoid duplicate registration.
+        // This guards against duplicate push notifications or repeated intents.
         val existingCall = callRepository.getCall(callInfo.callId)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt`
around lines 466 - 483, Add a short clarifying comment above the duplicate-check
block that explains this early return guards against duplicate registration
attempts (e.g., retrying incoming intents or push notifications) so
pending/already-tracked calls are not re-registered; annotate the block that
uses callRepository.getCall(callInfo.callId) and returns after sending the
appropriate broadcast via sendBroadcastEvent
(CallingxModuleImpl.CALL_REGISTERED_INCOMING_ACTION / CALL_REGISTERED_ACTION)
referencing existingCall and callInfo.callId to make intent handling rationale
clear.
packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt (1)

165-175: Minor: Simplify chronometer timing lookup.

Line 172 re-reads notificationsState[callId]?.activeWhen immediately after setting it on line 170. Since we know the value is now, this could be simplified:

♻️ Optional simplification
             if (!state.hasBecameActive) {
                 debugLog(TAG, "[notifications] createNotification: Setting when to current time for $callId")
                 val now = System.currentTimeMillis()
                 notificationsState[callId] = state.copy(activeWhen = now, hasBecameActive = true)
+                builder.setWhen(now)
+            } else {
+                builder.setWhen(state.activeWhen ?: System.currentTimeMillis())
             }
-            builder.setWhen(notificationsState[callId]?.activeWhen ?: System.currentTimeMillis())
             builder.setUsesChronometer(true)
             builder.setShowWhen(true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt`
around lines 165 - 175, In the call activation branch inside
CallNotificationManager (the createNotification logic where call.isActive &&
optimisticState == OptimisticState.NONE), avoid re-reading
notificationsState[callId]?.activeWhen immediately after setting it; when you
set notificationsState[callId] = state.copy(activeWhen = now, hasBecameActive =
true) use the local now value for builder.setWhen(now) and then call
builder.setUsesChronometer(true)/setShowWhen(true) — this removes the redundant
lookup and guarantees the exact timestamp is used.
packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt (2)

34-45: Only dispatch changed call entries from the collector.

This currently replays every registered call on any map update, so a mute/state change on one call retriggers downstream notification/foreground work for all other calls too. Since previousCalls is already tracked, only emit onCallStateChanged for added/changed callIds plus removals.

♻️ Minimal diff
                 calls.collect { currentCalls ->
-                    // Notify about changes per call
                     for ((callId, call) in currentCalls) {
-                        _listener?.onCallStateChanged(callId, call)
+                        if (previousCalls[callId] != call) {
+                            _listener?.onCallStateChanged(callId, call)
+                        }
                     }
                     for ((callId, _) in previousCalls) {
                         if (!currentCalls.containsKey(callId)) {
                             _listener?.onCallStateChanged(callId, Call.None)
                         }
                     }
                     previousCalls = currentCalls
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt`
around lines 34 - 45, The collector in calls.collect currently notifies
_listener.onCallStateChanged for every entry in currentCalls on any update,
causing unrelated calls to be retriggered; change the loop to compare
currentCalls against previousCalls and only call _listener.onCallStateChanged
for callIds that are new or whose Call value differs, and separately call
_listener.onCallStateChanged(callId, Call.None) only for callIds present in
previousCalls but missing in currentCalls; update the logic around previousCalls
to still assign previousCalls = currentCalls (or a shallow copy) after
processing so future diffs work correctly.

46-48: Re-throw coroutine cancellation instead of logging it as a failure.

Both collectors catch Exception, so the normal observeCallsJob?.cancel() / scope.cancel() paths are logged as errors. Handle CancellationException separately and only log real failures.

♻️ Minimal diff
-            } catch (e: Exception) {
+            } catch (e: kotlinx.coroutines.CancellationException) {
+                throw e
+            } catch (e: Exception) {
                 Log.e(TAG, "[repository] setListener: Error collecting call state", e)
             }
@@
-            } catch (e: Exception) {
+            } catch (e: kotlinx.coroutines.CancellationException) {
+                throw e
+            } catch (e: Exception) {
                 Log.e(TAG, "[repository] registerCall: Error consuming actions for $callId", e)
             }

Also applies to: 103-105

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt`
around lines 46 - 48, The catch blocks in LegacyCallRepository (inside
setListener and the other collector around the observeCallsJob logic) currently
catch Exception and log CancellationException as an error; update these to
handle coroutine cancellation properly by adding a specific catch for
CancellationException that rethrows (or simply rethrowing if caught) before the
general catch (e: Exception) so only real failures are logged in Log.e; locate
the catch blocks in setListener and the other collector (the ones currently
doing Log.e(TAG, "[repository] setListener: Error collecting call state", e) and
the similar Log.e at lines ~103-105) and change the error handling to rethrow
CancellationException then log other Exceptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationIntentFactory.kt`:
- Around line 17-19: The current requestCodeFor(callId: String, base: Int)
simply adds base to callId.hashCode(), which risks collisions and PendingIntent
aliasing; replace it with a deterministic composite hash of both base and callId
(for example generate bytes from the string "$base:$callId" and run a stable
hash like SHA-256 or UUID.nameUUIDFromBytes and then convert a slice to a
non-negative Int) so different (base, callId) pairs produce distinct request
codes; update all callers (e.g., NotificationIntentFactory.requestCodeFor and
the places in createNotificationIntent / createActionPendingIntent that use it)
to use the new implementation so PendingIntents are uniquely keyed.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt`:
- Around line 80-105: registerCall creates a Channel called actionSource stored
on Call.Registered and launches a collector (scope.launch ->
actionSource.consumeAsFlow().collect) but removeCall only deletes the call from
the map and leaves that channel/collector alive; modify removeCall to retrieve
the Call.Registered for the given callId and close its actionSource (or cancel a
stored per-call Job) immediately after removing it from _calls so the collector
spawned by processActionLegacy is terminated; ensure any close/cancel is safe
(check channel is not already closed) and that release() still cancels remaining
scope as a fallback.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/TelecomCallRepository.kt`:
- Around line 31-34: The CallActionFlags holder currently exposes mutable
Boolean vars which are not thread-safe even when stored in a ConcurrentHashMap;
replace isSelfAnswered and isSelfDisconnected with thread-safe atomics (e.g.,
java.util.concurrent.atomic.AtomicBoolean) declared as vals inside
CallActionFlags, update all write sites (the action-side locations that set
these flags) to call set(true/false), and update all read/reset sites in the
callback paths to use get() and set(false) accordingly so reads/writes are
atomic and race-free; also ensure CallActionFlags instances remain in the
ConcurrentHashMap as before and adjust imports/usages to compile.
- Around line 78-91: The release() method must acquire the same
registrationMutex used by registerCall() to prevent races where registerCall()
proceeds concurrently and adds an orphaned Telecom call; update release() to
lock registrationMutex around the disconnect loop, clearing _calls, actionFlags,
cancelling observeCallsJob, nulling _listener, and cancelling scope so release
is atomic with respect to registerCall()/callsManager.addCall(); additionally,
in the registerCall()/call-registration path (the region around lines 106-158)
ensure it checks a repository-released flag (set while holding registrationMutex
in release()) or re-checks under the same registrationMutex before calling
callsManager.addCall()/creating per-call CallControlScope to avoid creating
calls after release.
- Around line 132-153: The created action channel (actionSource =
Channel<CallAction>()) is rendezvous/unbuffered, which contradicts the comment
and can cause send() to suspend if actions are sent before the consumer starts;
change the channel instantiation to a buffered channel (e.g., actionSource =
Channel<CallAction>(Channel.UNLIMITED) or Channel.BUFFERED) where the
Call.Registered is created so actions are queued until the call scope starts
processing them (the change should be made where actionSource is assigned before
addCall and relates to Call.Registered, addCall, and actionFlags[callId]).

In `@packages/react-native-sdk/src/utils/push/internal/utils.ts`:
- Around line 134-139: The loop over activeCalls currently awaits
activeCall.leave(...) which, if it throws, prevents the subsequent
callFromPush.join() from running; modify the loop in the activeCalls iteration
(the block using activeCall.leave) to wrap each await activeCall.leave({ reason:
'cancel' }) in its own try-catch, log the error via logger.warn/error including
activeCall.cid, and continue to the next call so that failures to leave one call
do not block calling callFromPush.join() afterwards.

---

Nitpick comments:
In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt`:
- Around line 466-483: Add a short clarifying comment above the duplicate-check
block that explains this early return guards against duplicate registration
attempts (e.g., retrying incoming intents or push notifications) so
pending/already-tracked calls are not re-registered; annotate the block that
uses callRepository.getCall(callInfo.callId) and returns after sending the
appropriate broadcast via sendBroadcastEvent
(CallingxModuleImpl.CALL_REGISTERED_INCOMING_ACTION / CALL_REGISTERED_ACTION)
referencing existingCall and callInfo.callId to make intent handling rationale
clear.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt`:
- Around line 165-175: In the call activation branch inside
CallNotificationManager (the createNotification logic where call.isActive &&
optimisticState == OptimisticState.NONE), avoid re-reading
notificationsState[callId]?.activeWhen immediately after setting it; when you
set notificationsState[callId] = state.copy(activeWhen = now, hasBecameActive =
true) use the local now value for builder.setWhen(now) and then call
builder.setUsesChronometer(true)/setShowWhen(true) — this removes the redundant
lookup and guarantees the exact timestamp is used.

In
`@packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt`:
- Around line 34-45: The collector in calls.collect currently notifies
_listener.onCallStateChanged for every entry in currentCalls on any update,
causing unrelated calls to be retriggered; change the loop to compare
currentCalls against previousCalls and only call _listener.onCallStateChanged
for callIds that are new or whose Call value differs, and separately call
_listener.onCallStateChanged(callId, Call.None) only for callIds present in
previousCalls but missing in currentCalls; update the logic around previousCalls
to still assign previousCalls = currentCalls (or a shallow copy) after
processing so future diffs work correctly.
- Around line 46-48: The catch blocks in LegacyCallRepository (inside
setListener and the other collector around the observeCallsJob logic) currently
catch Exception and log CancellationException as an error; update these to
handle coroutine cancellation properly by adding a specific catch for
CancellationException that rethrows (or simply rethrowing if caught) before the
general catch (e: Exception) so only real failures are logged in Log.e; locate
the catch blocks in setListener and the other collector (the ones currently
doing Log.e(TAG, "[repository] setListener: Error collecting call state", e) and
the similar Log.e at lines ~103-105) and change the error handling to rethrow
CancellationException then log other Exceptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fefc53ad-9cfd-4ec8-a0a0-2f4cd0a0556d

📥 Commits

Reviewing files that changed from the base of the PR and between 65aa2fe and f5ad161.

📒 Files selected for processing (14)
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallRegistrationStore.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallService.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/model/Call.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/CallNotificationManager.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/notifications/NotificationIntentFactory.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/CallRepository.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/LegacyCallRepository.kt
  • packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/repo/TelecomCallRepository.kt
  • packages/react-native-callingx/android/src/newarch/java/io/getstream/rn/callingx/CallingxModule.kt
  • packages/react-native-callingx/android/src/oldarch/java/io/getstream/rn/callingx/CallingxModule.kt
  • packages/react-native-callingx/ios/Callingx.mm
  • packages/react-native-callingx/src/spec/NativeCallingx.ts
  • packages/react-native-sdk/src/utils/push/internal/utils.ts
💤 Files with no reviewable changes (4)
  • packages/react-native-callingx/android/src/newarch/java/io/getstream/rn/callingx/CallingxModule.kt
  • packages/react-native-callingx/ios/Callingx.mm
  • packages/react-native-callingx/src/spec/NativeCallingx.ts
  • packages/react-native-callingx/android/src/oldarch/java/io/getstream/rn/callingx/CallingxModule.kt

greenfrvr and others added 9 commits March 17, 2026 16:10
# Conflicts:
#	packages/react-native-callingx/android/src/main/java/io/getstream/rn/callingx/CallingxModuleImpl.kt
#	packages/react-native-callingx/android/src/newarch/java/io/getstream/rn/callingx/CallingxModule.kt
#	packages/react-native-callingx/android/src/oldarch/java/io/getstream/rn/callingx/CallingxModule.kt
#	packages/react-native-sdk/src/utils/push/internal/utils.ts
@greenfrvr greenfrvr merged commit 5f6e708 into feat/callkit-telecom-integration Mar 20, 2026
15 of 19 checks passed
@greenfrvr greenfrvr deleted the multi-call-handling branch March 20, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants