Skip to content

[Enhancement] Extend CallJoinInterceptor with will-join and did-join hooks#1736

Open
rahul-lohra wants to merge 11 commits into
developfrom
feature/rahullohra/call-join-interceptor-extension
Open

[Enhancement] Extend CallJoinInterceptor with will-join and did-join hooks#1736
rahul-lohra wants to merge 11 commits into
developfrom
feature/rahullohra/call-join-interceptor-extension

Conversation

@rahul-lohra

@rahul-lohra rahul-lohra commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Goal

IOS PR: GetStream/stream-video-swift#1176
Closes AND-1269

CallJoinInterceptor previously exposed a single gate, callReadyToJoin(), which only let integrators run (and optionally veto) work at the very last step of a join. That isn't enough for use cases that need to bracket the entire connection window — for example muting remote media while the peer connection is still negotiating and restoring it once the call is live. This PR adds two best-effort lifecycle hooks around the existing gate so integrators get a clear before/after pair.

Implementation

New public API

  • Add CallJoinLifecycleInterceptor (core/call/interceptor) extending CallJoinInterceptor with callWillJoin(call) and callDidJoin(call).
public interface CallJoinLifecycleInterceptor : CallJoinInterceptor {
    public suspend fun callWillJoin(call: Call)
    public suspend fun callDidJoin(call: Call)
}
  • Deprecate CallJoinInterceptor (WARNING) pointing to the new interface.

Lifecycle wiring

  • Call.join() invokes callWillJoin(this) once the join result is produced, when the interceptor implements CallJoinLifecycleInterceptor.

  • ActiveStateGate.invokeInterceptor() invokes callDidJoin(call) right after
    onReady() callback (which transitions to RingingState to Active)

    call.join()
     │
     ├─▶ callWillJoin()      session ready, before Active
     │
     │   ⏳ wait: publisher CONNECTED
     │
     ├─▶ callReadyToJoin()   gate — suspend to delay · throw to abort · 5s max
     │
     ├─── RingingState.Active
     │
     └─▶ callDidJoin()       after Active
    
    

Demo App

  • DemoCallJoinInterceptor now implements the lifecycle interface: it silences incoming audio in callWillJoin and restores it in callDidJoin.

Example Usage

class DemoCallJoinInterceptor(
   private val previousRingingStates: Set<RingingState>,
) : CallJoinLifecycleInterceptor {

   override suspend fun callWillJoin(call: Call) {
       // Perform any setup before the join starts.
       // For example:
       // - Start observing participants.
       // - Temporarily mute incoming audio.
       // - Initialize resources needed during the join.
   }

   override suspend fun callReadyToJoin(call: Call) {        
       // - Exchange readiness signals between caller and callee.
       // - Wait until both sides are ready.
   }

   override suspend fun callDidJoin(call: Call) {
       // Clean up any temporary state.
       // For example:
       // - Stop observers.
       // - Re-enable incoming audio.
   }
}
call.join(callJoinInterceptor = DemoCallJoinInterceptor(previousRingingStates) ) 

Other

  • Update stream-video-android-core.api with the new public surface.

🎨 UI Changes

None

Testing

  1. Go to Direct Call Screen
  2. Enable Call Join First
  3. Enable Use Call Join Interceptor
  4. Choose a contact to make a call

Summary by CodeRabbit

  • New Features

    • Added call join lifecycle hooks for apps that need to react before and after a call fully joins.
    • Join handling now supports additional setup and cleanup steps around the join flow.
  • Bug Fixes

    • Improved handling for participant audio during join, helping prevent audio from being active at the wrong time.
  • Chores

    • The previous join interceptor is now deprecated in favor of the new lifecycle-based interceptor.

@rahul-lohra rahul-lohra self-assigned this Jun 30, 2026
@github-actions

github-actions Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

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 the PR is bot-authored.
  • An issue is linked (Linear ticket or GitHub issue), or the PR is bot-authored.

🎉 Great job! This PR is ready for review.

@github-actions

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

SDK Before After Difference Status
stream-video-android-core 12.27 MB 12.27 MB 0.00 MB 🟢
stream-video-android-ui-xml 5.68 MB 5.68 MB 0.00 MB 🟢
stream-video-android-ui-compose 6.20 MB 6.20 MB 0.00 MB 🟢

@rahul-lohra rahul-lohra changed the title Add lifecycle methods to call join interceptor [Enhancement] Extend CallJoinIntercepting with will-join and did-join hooks Jun 30, 2026
1. Remove public api to mute audio tracks
2. Update DemoCallJoinInterceptor
@rahul-lohra rahul-lohra changed the title [Enhancement] Extend CallJoinIntercepting with will-join and did-join hooks [Enhancement] Extend CallJoinInterceptor with will-join and did-join hooks Jun 30, 2026
@rahul-lohra rahul-lohra changed the title [Enhancement] Extend CallJoinInterceptor with will-join and did-join hooks [AND-1269] [Enhancement] Extend CallJoinInterceptor with will-join and did-join hooks Jun 30, 2026
@rahul-lohra rahul-lohra added the pr:new-feature Adds new functionality label Jun 30, 2026
@rahul-lohra rahul-lohra changed the title [AND-1269] [Enhancement] Extend CallJoinInterceptor with will-join and did-join hooks [Enhancement] Extend CallJoinInterceptor with will-join and did-join hooks Jun 30, 2026
@rahul-lohra rahul-lohra marked this pull request as ready for review June 30, 2026 13:46
@rahul-lohra rahul-lohra requested a review from a team as a code owner June 30, 2026 13:46
@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Introduces a new CallJoinLifecycleInterceptor interface extending the now-deprecated CallJoinInterceptor, adding callWillJoin and callDidJoin suspend hooks. The hooks are wired into Call.join and ActiveStateGate. The demo app's DemoCallJoinInterceptor is updated to implement the new interface with coroutine-based audio track observation.

CallJoinLifecycleInterceptor

Layer / File(s) Summary
Interface definition and deprecation
stream-video-android-core/src/main/kotlin/.../call/interceptor/CallJoinLifecycleInterceptor.kt, stream-video-android-core/src/main/kotlin/.../CallJoinInterceptor.kt, stream-video-android-core/api/stream-video-android-core.api
Defines CallJoinLifecycleInterceptor with callWillJoin and callDidJoin suspend hooks, deprecates CallJoinInterceptor with a ReplaceWith pointing to the new interface, and publishes the API surface.
Hook invocation in Call.join and ActiveStateGate
stream-video-android-core/src/main/kotlin/.../core/Call.kt, stream-video-android-core/src/main/kotlin/.../core/ActiveStateGate.kt
Call.join conditionally calls callWillJoin after RtcSession creation; ActiveStateGate conditionally calls callDidJoin after onReady() in both legacy and main transition paths, guarded by an instanceof check.
Demo app implementation
demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt
DemoCallJoinInterceptor migrated to CallJoinLifecycleInterceptor; callWillJoin launches a background coroutine job that observes non-local participants' audio tracks and disables them; callDidJoin cancels the job and re-enables those audio tracks.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • GetStream/stream-video-android#1679: Directly modifies DemoCallJoinInterceptor and the callReadyToJoin interception flow that this PR extends with callWillJoin/callDidJoin lifecycle hooks.

Suggested reviewers

  • aleksandar-apostolov

🐇 A lifecycle hook here, a lifecycle hook there,
callWillJoin whispers, "Mute tracks with care!"
Then callDidJoin hops in once the state turns Active,
Un-muting all ears — the flow is now attractive! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.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 clearly matches the main change: adding will-join and did-join hooks to CallJoinInterceptor.
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 covers the required goal, implementation, UI changes, and testing sections and is mostly complete.
✨ 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-interceptor-extension

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt (1)

49-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider private visibility for the mutable lifecycle state.

observeJob and observerScope are exposed as public mutable members; marking them private keeps the interceptor's coroutine state encapsulated. As per coding guidelines: "Prefer explicit visibility modifiers; limit internal leakage across modules".

🤖 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`
around lines 49 - 50, The interceptor’s coroutine lifecycle state is currently
exposed as public mutable members, so update the declarations in
DemoCallJoinInterceptor to make observeJob and observerScope private and keep
them encapsulated. Use the DemoCallJoinInterceptor class as the place to locate
the fix, and preserve the existing coroutine behavior while restricting
visibility as per the guideline to prefer explicit visibility modifiers and
avoid leaking mutable state across modules.

Source: Coding guidelines

🤖 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/DemoCallJoinInterceptor.kt`:
- Line 63: The log message in DemoCallJoinInterceptor.callWillJoin contains
leftover debug text (“noob”) and should be cleaned up. Update the logger.d call
in callWillJoin to use a professional message that only describes disabling
audio tracks, keeping the rest of the behavior unchanged.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt`:
- Around line 87-92: Guard the user callback in ActiveStateGate so interceptor
failures do not skip teardown: in the launch path around the
CallJoinLifecycleInterceptor.callDidJoin(call) hook, catch non-cancellation
exceptions and still allow cancelInterceptorJob() to run, and apply the same
protection in the main-transition path before cleanup() is invoked. Keep
CancellationException behavior unchanged by rethrowing it, and use the existing
ActiveStateGate, callDidJoin, cancelInterceptorJob, and cleanup symbols to place
the fix in both branches.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`:
- Around line 629-633: The post-join hook `callWillJoin` in `Call` should be
protected from interceptor failures so a third-party exception does not turn a
successful join into a failed `join()` result. Update the `if
(callJoinInterceptor is CallJoinLifecycleInterceptor)` block to invoke
`callWillJoin(this)` through the same kind of `try/catch` shielding used for
`callReadyToJoin` via `ActiveStateGate.invokeInterceptor`, and log or swallow
the interceptor error so the join flow still completes successfully.

---

Nitpick comments:
In
`@demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt`:
- Around line 49-50: The interceptor’s coroutine lifecycle state is currently
exposed as public mutable members, so update the declarations in
DemoCallJoinInterceptor to make observeJob and observerScope private and keep
them encapsulated. Use the DemoCallJoinInterceptor class as the place to locate
the fix, and preserve the existing coroutine behavior while restricting
visibility as per the guideline to prefer explicit visibility modifiers and
avoid leaking mutable state across modules.
🪄 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: 8d2281af-e686-439a-ad95-f4ae82ed9b0c

📥 Commits

Reviewing files that changed from the base of the PR and between 6035a0d and 2c6fec2.

📒 Files selected for processing (6)
  • demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.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/call/interceptor/CallJoinLifecycleInterceptor.kt

Comment thread demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt Outdated
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
50.0% Coverage on New Code (required ≥ 80%)
C Maintainability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@aleksandar-apostolov aleksandar-apostolov left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice feature. Two things before it hits the public API:

1. Consider default methods instead of a new interface. We build with -Xjvm-default=all, so we can add callWillJoin/callDidJoin directly to CallJoinInterceptor with empty default bodies — existing implementers keep compiling, callReadyToJoin stays required. That avoids introducing CallJoinLifecycleInterceptor and deprecating the base interface (which pushes a warning onto everyone implementing callReadyToJoin, even though it isn't going away). Fewer types, no deprecation churn.

2. callWillJoin isn't best-effort. In Call.kt it's unwrapped, so a throwing hook propagates out of join() and the successful join is never returned. callDidJoin is wrapped — callWillJoin should be too.

}

if (callJoinInterceptor is CallJoinLifecycleInterceptor) {
callJoinInterceptor.callWillJoin(this)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wrap this like callDidJoin in ActiveStateGate (rethrow CancellationException, log-and-proceed) — otherwise a throwing hook fails the whole join.

if (!isActive) return@launch

if (shouldProceed) onReady()
if (shouldProceed) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same callDidJoin try/catch at both onReady() sites — extract to a private helper.

const val CALLER_READY_TO_JOIN_EVENT_TYPE = "caller_ready_join"
const val CALLEE_READY_TO_JOIN_EVENT_TYPE = "callee_ready_join"
}
var observeJob: Job? = null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

observerScope is never cancelled and observeJob keeps running if callDidJoin never fires (aborted/failed join) — remote audio stays muted. Tie the scope to a cancellable lifecycle and make observeJob/observerScope private. Integrators copy the demo.

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.

2 participants