Skip to content

Add RingingCallJoinInterceptor hook for ringing call activation#1679

Open
rahul-lohra wants to merge 16 commits into
developfrom
feature/rahullohra/call-join-interception-2
Open

Add RingingCallJoinInterceptor hook for ringing call activation#1679
rahul-lohra wants to merge 16 commits into
developfrom
feature/rahullohra/call-join-interception-2

Conversation

@rahul-lohra
Copy link
Copy Markdown
Contributor

@rahul-lohra rahul-lohra commented May 12, 2026

Goal

Add a join interception hook that lets apps delay or abort call entry after the join response has been applied locally, so participant readiness/status can be synchronized before the user is treated as fully in-call. Mirrors the iOS CallJoinInterceptor API. GetStream/stream-video-swift#1108

Implementation

  • The sdk providers a CallJoinInterceptor interface. The integrators can pass their own instance of CallJoinInterceptor to Call.join( callJoinInterceptor)

  • This interface will run just before SDK transitions to [RingingState.Active]. You can throw CallJoinInterceptionException to abort the join operation.

  • This interface will run a maximum of 5 seconds, after this the transition to RingingState.Active will happen

class Call {
   suspend fun join(...callJoinInterceptor: CallJoinInterceptor? = null)
}
class CallState {
 @Volatile
 internal var callJoinInterceptor: CallJoinInterceptor? = null
}
public interface CallJoinInterceptor {

    /**
     * Called when the SDK is ready to transition to [RingingState.Active].
     * Suspend here to delay the transition; return to allow it to proceed.
     *
     * Throw [CallJoinInterceptionException] to abort the join — the SDK will leave
     * the call cleanly
     *
     * The SDK enforces a 5-second maximum — the transition proceeds automatically on timeout.
     */
    @Throws(CallJoinInterceptionException::class)
    public suspend fun callReadyToJoin(call: Call)
}

public class CallJoinInterceptionException(
    public val reason: String,
    cause: Throwable? = null,
) : RuntimeException(reason, cause)

Flow Diagram

Before After
image image

Example Usage

  • You can view the detailed usage in this PR on file CallActivity.kt, DemoCallJoinInterceptor.kt and App.kt
class DemoCallJoinInterceptor(
    private val previousRingingStates: Set<RingingState>,
) : CallJoinInterceptor {

    override suspend fun callReadyToJoin(call: Call) {

        val isOutgoing = previousRingingStates.any { it is RingingState.Outgoing }

        if (isOutgoing) {
            call.signalCallerReady()
            call.awaitCalleeReady()
        } else {
            call.signalCalleeReady()
            call.awaitCallerReady()
        }
    }
}
call.join(callJoinInterceptor = DemoCallJoinInterceptor(previousRingingStates) ) 

🎨 UI Changes

Demo App

Screen Description
image Added an option to use call join interceptor
Screen.Recording.2026-05-12.at.12.13.04.PM.mov

Testing

Change these timeouts
private const val PEER_CONNECTION_OBSERVER_TIMEOUT = 10_000L
private const val INTERCEPTOR_TIMEOUT_MS = 10_000L

  1. Open Direct Call Screen
  2. Select Use Interceptor
  3. Perform Video call
  4. Notice that both callee and caller transitioned to Active State at same time

Summary by CodeRabbit

Release Notes

  • New Features
    • Added Call Join Interceptor feature, enabling customization of call join timing and behavior
    • New "Use Call Join Interceptor" toggle in direct call setup screen
    • Improved call readiness event handling for caller and callee state management

Review Change Stack

@rahul-lohra rahul-lohra self-assigned this May 12, 2026
@rahul-lohra rahul-lohra requested a review from a team as a code owner May 12, 2026 06:28
@rahul-lohra rahul-lohra added the pr:new-feature Adds new functionality label May 12, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 12, 2026

PR checklist ✅

All required conditions are satisfied:

  • Title length is OK (or ignored by label).
  • At least one pr: label exists.
  • Sections ### Goal, ### Implementation, and ### Testing are filled (or ignored for dependabot PRs).

🎉 Great job! This PR is ready for review.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 12, 2026

Walkthrough

This PR introduces a CallJoinInterceptor mechanism that enables custom logic to control when calls transition to the active state. The feature threads through the call-join flow, includes comprehensive tests for the state machine, and provides a demo app showing event-based coordination between endpoints.

Changes

Call Join Interceptor Feature

Layer / File(s) Summary
Interceptor contract and exception
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt
CallJoinInterceptor interface and CallJoinInterceptionException define the public hook for delaying or aborting the transition to RingingState.Active.
Call and CallState integration
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt, stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt
Call.join and Call.joinAndRing accept an optional callJoinInterceptor parameter; CallState stores the interceptor for use during ringing-state transitions.
ActiveStateGate state machine refactor
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt
Gate refactored into two stages: wait for publisher connection, then invoke the interceptor. Adds explicit timeout handling, error classification (generic vs. rejection), and cleanup on CallJoinInterceptionException.
ActiveStateGate test suite
stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ActiveStateGateTest.kt
Comprehensive test coverage for publisher-connection waiting, interceptor ordering, error handling, timeout fallback, duplicate-launch guards, and cleanup semantics.
StreamCallActivity base support
stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt
Exposes overridable callJoinInterceptor property and threads it through joinAndRing, join, and acceptThenJoin code paths.
Public API surface declarations
stream-video-android-core/api/stream-video-android-core.api, stream-video-android-ui-core/api/stream-video-android-ui-core.api
Binary API updated with CallJoinInterceptor parameter in call join methods, new exception type, and new getter in StreamCallActivity.
Demo app: Event tracking and ringing state observation
demo-app/src/main/kotlin/io/getstream/video/android/App.kt, demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt
App tracks custom "ready to join" events via flows; CallActivity observes ringing state and maintains a set of previously-seen states for the interceptor.
Demo app: DemoCallJoinInterceptor implementation
demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt
Classifies prior ringing state as incoming or outgoing, sends a corresponding custom event, and waits for a matching readiness emission from the app's tracked flows.
Demo app: UI controls and navigation
demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt, demo-app/src/main/kotlin/io/getstream/video/android/ui/DogfoodingNavHost.kt
DirectCallJoinScreen gains a checkbox to toggle the interceptor; navigation threads the flag through to CallActivity before launching outgoing calls.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A call waits to join, but pauses to think,
Interceptor gates the path with a wink,
Custom events dance 'tween caller and callee,
Ready-to-join flows make harmony!
Tests ensure no gate gets stuck in a bind—
State management, clean and refined. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% 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
Title check ✅ Passed The title 'Add RingingCallJoinInterceptor hook for ringing call activation' directly and clearly describes the main feature being added—a join interceptor hook for ringing call state transitions. It accurately reflects the primary change across all modified files.
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.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering goal, implementation details, code examples, UI changes, and testing instructions.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/rahullohra/call-join-interception-2

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


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.

Copy link
Copy Markdown

@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: 4

🧹 Nitpick comments (4)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt (1)

176-179: 💤 Low value

Consider clarifying clearAllJobs semantics.

The method name clearAllJobs suggests it cancels jobs, but it only resets references to null without cancellation. This is intentional (the calling coroutine is already active and will complete naturally), but the behavior may be unclear to future maintainers.

💡 Consider adding a KDoc comment
+/**
+ * Resets job references without cancellation.
+ * Called when a job is already completing (e.g., after interceptor rejection).
+ */
 fun clearAllJobs() {
     peerConnectionObserverJob.set(null)
     interceptorJob.set(null)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt`
around lines 176 - 179, The clearAllJobs method currently only nulls the
peerConnectionObserverJob and interceptorJob references rather than cancelling
them, which can be confusing; add a KDoc above ActiveStateGate.clearAllJobs that
clearly states it intentionally does not cancel jobs, explains that cancellation
is handled by the caller/coroutine lifecycle, and documents that the method only
clears the two fields peerConnectionObserverJob and interceptorJob to release
references.
demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt (1)

34-34: ⚡ Quick win

Remove unused constructor parameter.

The callReadyToJoinFlow parameter is declared but never referenced in the implementation. Consider removing it to reduce confusion.

♻️ Proposed fix
 class DemoCallJoinInterceptor(
-    private val callReadyToJoinFlow: StateFlow<Boolean>,
     private val previousRingingStates: Set<RingingState>,
 ) : CallJoinInterceptor {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt`
at line 34, The constructor parameter callReadyToJoinFlow in class
DemoCallJoinInterceptor is unused; remove it from the primary constructor
signature and eliminate any corresponding constructor argument at instantiation
sites, and update any Kotlin imports or references accordingly so the class
compiles without that StateFlow<Boolean> parameter; ensure you only modify the
parameter list for DemoCallJoinInterceptor and its call sites, leaving the rest
of the class (methods and members) unchanged.
demo-app/src/main/kotlin/io/getstream/video/android/App.kt (1)

85-106: ⚡ Quick win

Use a structured application scope for readiness observation.

This collector is launched from an ad-hoc CoroutineScope(Dispatchers.Default). Prefer an owned app scope (SupervisorJob + Dispatchers.Default) so lifecycle/cancellation/error handling stays deterministic.

As per coding guidelines "Keep concurrency deterministic—use structured coroutines and avoid global scope".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demo-app/src/main/kotlin/io/getstream/video/android/App.kt` around lines 85 -
106, The observeCallReadyToJoin() function currently launches an ad-hoc
CoroutineScope(Dispatchers.Default); replace that with a structured, owned
application scope (e.g., an appScope backed by SupervisorJob() +
Dispatchers.Default) and launch the collector from that scope so
cancellation/errors are propagated deterministically. Update
observeCallReadyToJoin() to use the shared appScope when collecting
StreamVideo.instanceState and when updating callerReadyToJoinFlow /
calleeReadyToJoinFlow, and ensure the appScope is created once (SupervisorJob +
Dispatchers.Default) at the application-level so lifecycle and error handling
are consistent.
demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt (1)

90-104: ⚡ Quick win

Use lifecycleScope for ringing observation in this Activity.

This observer is launched from an ad-hoc scope. Binding it to lifecycleScope keeps cancellation deterministic without manual cleanup dependency on finish().

As per coding guidelines "Keep concurrency deterministic—use structured coroutines and avoid global scope".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt` around
lines 90 - 104, The observeRingingState function currently launches an ad-hoc
CoroutineScope; replace that with the Activity's lifecycleScope so the observer
is tied to the Activity lifecycle (use lifecycleScope.launch instead of
CoroutineScope(Dispatchers.Default).launch) and keep using
StreamVideo.instanceState, previousRingingStates, and observeRingingJob to
manage the flow; you can remove or keep the explicit observeRingingJob?.cancel()
if you prefer, but ensure the coroutine is created via lifecycleScope so it is
cancelled deterministically with the Activity lifecycle.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt`:
- Around line 64-66: The companion-level mutable flag USE_CALL_JOIN_INTERCEPTOR
should be removed and the interceptor behavior should be driven by an Intent
extra passed to CallActivity; change callers that start CallActivity to
putExtra("EXTRA_USE_CALL_JOIN_INTERCEPTOR", true/false) and inside CallActivity
(e.g., onCreate) read that extra into a private val (e.g.,
useCallJoinInterceptor) and use that instance field instead of the companion
var; if you need a default keep a private constant
DEFAULT_USE_CALL_JOIN_INTERCEPTOR and avoid mutable global state or
UPPER_SNAKE_CASE for non-constants.

In
`@demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt`:
- Around line 50-52: The current use of previousRingingStates.first { it is
RingingState.Incoming || it is RingingState.Outgoing } can throw
NoSuchElementException if no matching state exists; replace with a safe lookup
(e.g., firstOrNull) and add defensive handling when null is returned so
isIncomingOrOutgoing is computed safely (handle the null case where appropriate:
treat as false, log, or short-circuit) — update the logic around the
isIncomingOrOutgoing variable and any downstream code that assumes a non-null
match.

In
`@demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt`:
- Line 199: The Text composable in DirectCallJoinScreen.kt currently uses a
hardcoded label "Use Call Join Interceptor"; replace this literal with a
localized stringResource call (e.g.,
stringResource(R.string.use_call_join_interceptor)) in the Text invocation
inside DirectCallJoinScreen, and add the corresponding key/value ("Use Call Join
Interceptor") to your strings.xml (and localized variants as needed); ensure you
import androidx.compose.ui.res.stringResource and reference the new R.string
identifier from the Text call.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt`:
- Around line 98-99: The debug log "ignored duplicate gate launch" is emitted
before the code actually checks for a duplicate; update ActiveStateGate
(launchGate) so the logger.d call happens only when the duplicate condition is
true: after checking if peerConnectionObserverJob.get()?.isActive == true, log
the message immediately before returning (or log conditioned on that same
boolean), instead of logging unconditionally before the check.

---

Nitpick comments:
In `@demo-app/src/main/kotlin/io/getstream/video/android/App.kt`:
- Around line 85-106: The observeCallReadyToJoin() function currently launches
an ad-hoc CoroutineScope(Dispatchers.Default); replace that with a structured,
owned application scope (e.g., an appScope backed by SupervisorJob() +
Dispatchers.Default) and launch the collector from that scope so
cancellation/errors are propagated deterministically. Update
observeCallReadyToJoin() to use the shared appScope when collecting
StreamVideo.instanceState and when updating callerReadyToJoinFlow /
calleeReadyToJoinFlow, and ensure the appScope is created once (SupervisorJob +
Dispatchers.Default) at the application-level so lifecycle and error handling
are consistent.

In `@demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt`:
- Around line 90-104: The observeRingingState function currently launches an
ad-hoc CoroutineScope; replace that with the Activity's lifecycleScope so the
observer is tied to the Activity lifecycle (use lifecycleScope.launch instead of
CoroutineScope(Dispatchers.Default).launch) and keep using
StreamVideo.instanceState, previousRingingStates, and observeRingingJob to
manage the flow; you can remove or keep the explicit observeRingingJob?.cancel()
if you prefer, but ensure the coroutine is created via lifecycleScope so it is
cancelled deterministically with the Activity lifecycle.

In
`@demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt`:
- Line 34: The constructor parameter callReadyToJoinFlow in class
DemoCallJoinInterceptor is unused; remove it from the primary constructor
signature and eliminate any corresponding constructor argument at instantiation
sites, and update any Kotlin imports or references accordingly so the class
compiles without that StateFlow<Boolean> parameter; ensure you only modify the
parameter list for DemoCallJoinInterceptor and its call sites, leaving the rest
of the class (methods and members) unchanged.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt`:
- Around line 176-179: The clearAllJobs method currently only nulls the
peerConnectionObserverJob and interceptorJob references rather than cancelling
them, which can be confusing; add a KDoc above ActiveStateGate.clearAllJobs that
clearly states it intentionally does not cancel jobs, explains that cancellation
is handled by the caller/coroutine lifecycle, and documents that the method only
clears the two fields peerConnectionObserverJob and interceptorJob to release
references.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 22260e08-fbf5-4d67-ac6d-afa6c39f623f

📥 Commits

Reviewing files that changed from the base of the PR and between 6e99b02 and 2fd3023.

⛔ Files ignored due to path filters (1)
  • stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/CustomVideoEvent.kt is excluded by !**/generated/**
📒 Files selected for processing (13)
  • demo-app/src/main/kotlin/io/getstream/video/android/App.kt
  • demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt
  • demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt
  • demo-app/src/main/kotlin/io/getstream/video/android/ui/DogfoodingNavHost.kt
  • demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt
  • stream-video-android-core/api/stream-video-android-core.api
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt
  • stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ActiveStateGateTest.kt
  • stream-video-android-ui-core/api/stream-video-android-ui-core.api
  • stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt

Comment thread demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt
Comment thread demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 12, 2026

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.04 MB 12.04 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.28 MB 6.27 MB -0.02 MB 🚀

@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:new-feature Adds new functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant