Decompose Call into focused internal components - #1747
Conversation
Break the ~2,260-line Call class into 11 internal collaborators under call/components (CallApiClient, CallStatsReporter, CallRenderer, CallEventManager, CallMediaManager, CallSessionManager, CallIceConnectionMonitor, CallConnectivityMonitor, CallJoinCoordinator, CallReconnector, CallLifecycleManager). Call remains a thin, binary-compatible public facade that delegates to them; public API is unchanged (apiCheck passes). Update white-box reflection tests to target the new component owners after internals moved out of Call. Co-authored-by: Cursor <cursoragent@cursor.com>
…nto refactor/call-class-decomposition Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt
PR checklist ✅All required conditions are satisfied:
🎉 Great job! This PR is ready for review. |
SDK Size Comparison 📏
|
WalkthroughChangesCall responsibility decomposition
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Call
participant CallJoinCoordinator
participant RtcSession
participant CallReconnector
participant CallLifecycleManager
Call->>CallJoinCoordinator: join()
CallJoinCoordinator->>RtcSession: connect to SFU
RtcSession-->>CallJoinCoordinator: connection result
Call->>CallReconnector: reconnect(strategy, reason)
CallReconnector->>CallJoinCoordinator: rejoin or migrate
CallJoinCoordinator->>RtcSession: create replacement session
CallReconnector-->>Call: connected or exhausted
CallReconnector->>CallLifecycleManager: leave after exhausted recovery
Possibly related PRs
Suggested labels: Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 15
🤖 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
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`:
- Around line 413-420: Remove the duplicated join preflight logic from Call’s
public join flow, including analytics, permission observation/checking, and
guest-token waiting around the shown joinCoordinator.join call. Keep that setup
exclusively in CallJoinCoordinator.join(), leaving Call’s join method as a
direct delegation with the existing arguments.
- Around line 759-765: Update Call.shutDownJobsGracefully so teardown does not
immediately cancel scope while supervisorJob children are draining. Complete the
supervisor to reject new children, await existing children within an owned
structured client scope, then perform final scope cancellation; handle
cancellation and child failures deterministically without relying on the
detached UserScope launch.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt`:
- Around line 73-92: Update the disconnect handling around
leaveTimeoutAfterDisconnect so the timeout coroutine is created only when
clientImpl.leaveAfterDisconnectSeconds is positive. Treat zero or negative
values as disabled, avoiding delay(0) and the subsequent call.leave flow;
preserve the existing reconnection check and timeout behavior for positive
values.
- Line 40: Remove or redact call.id from the logger tags used by
CallConnectivityMonitor, CallIceConnectionMonitor, CallJoinCoordinator, and
CallReconnector. Update the logger initialization in
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt:40-40,
CallIceConnectionMonitor.kt:35-35, CallJoinCoordinator.kt:52-52, and
CallReconnector.kt:69-69 while preserving non-sensitive call context such as
call.type.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt`:
- Around line 77-95: The fireEvent method currently invokes listeners while
holding the subscriptions monitor and allows listener exceptions to abort
dispatch. Snapshot active subscriptions under synchronized(subscriptions),
release the lock, invoke each matching listener with per-listener failure
isolation so remaining callbacks continue, then always call
events.tryEmit(event) afterward.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt`:
- Around line 69-83: Update startSubscriberMonitor to collect the session and
subscriber flows rather than reading call.session.value?.subscriber?.value once
before collecting iceState. Keep the monitor active when the subscriber is
initially null and automatically switch to observing each replacement
subscriber’s iceState, matching the publisher monitor’s flow-observation pattern
while preserving the existing restart and logging behavior.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt`:
- Around line 163-184: Update the join flow in CallJoinCoordinator so a failed
join also resets the join-and-ring progress state. Handle the error on the outer
join result, not only inside the flatMap block, and call
state.toggleJoinAndRingProgress(false) while preserving the existing
ring-failure handling.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt`:
- Around line 63-80: Sanitize all raw call identifiers in component logging:
update CallLifecycleManager.leave and internalLeave to remove or hash call.cid
and call.id, and sanitize or remove call.id from logger tags in
CallLifecycleManager (39-39), CallMediaManager (47-47), CallRenderer (43-43),
and CallStatsReporter (35-35). Preserve the existing log context without
exposing call IDs or CIDs.
- Around line 107-118: Update the coroutine launched in the call-leave flow to
wrap the leave analytics, tracing, stats, and leave-event operations in a
try/finally block, with cleanup() in finally. Ensure cleanup always runs after
the leave markers are attempted, including when onCallLeave() throws or the
coroutine is cancelled.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt`:
- Around line 270-272: Update CallMediaManager.cleanup() to dispose the owned
peer-connection factory during final teardown, in addition to calling
mediaManager.cleanup(). Reuse the existing factory instance and disposal
mechanism already used during recreation, ensuring native WebRTC resources are
released.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt`:
- Around line 254-271: Update the FAST reconnect failure handling in
CallReconnector’s ReconnectOutcome.Failed branch to consult
MAX_FAST_RECONNECT_ATTEMPTS and escalate to the REJOIN strategy once the
configured fast-retry limit is reached. Preserve the existing migration and
deadline escalation conditions, and apply the same logic to the corresponding
failure branch around the alternate referenced location.
- Around line 178-187: Update the reconnect loop in CallReconnector so the
network-availability wait is skipped when the active strategy is DISCONNECT,
allowing the server-requested disconnect path to execute offline and produce
SFU_DISCONNECT. Preserve the existing connectivity polling behavior for other
reconnect strategies.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt`:
- Around line 156-178: Update takeScreenshot around suspendCancellableCoroutine
to register an invokeOnCancellation handler that asynchronously removes
screenshotSink through the same call.scope cleanup path used after the first
frame. Ensure cancellation cleanup is safe if no frame has arrived, while
preserving the existing first-frame removal behavior.
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt`:
- Around line 45-57: Update CallStatsReporter.start so the coroutine keeps the
initial reportingIntervalMs delay, then sends the first stats report immediately
upon entering the while (isActive) loop; retain the interval delay only for
subsequent reports.
In
`@stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt`:
- Around line 53-67: Update CallFieldDeclarationOrderTest to assert that
eventManagerLine precedes eventsLine, in addition to the existing ordering
checks. Keep the requirement that both eventManager and events are declared
before state.
🪄 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: ce0c99a0-1916-4ef7-85a5-2b59619e7d82
📒 Files selected for processing (17)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.ktstream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.ktstream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.ktstream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.ktstream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.ktstream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.ktstream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt
Call.join() ran join analytics, permission checks, and the guest-token wait before delegating to CallJoinCoordinator.join(), which performed the exact same preflight — so every join() executed it twice. Make the facade a pure delegation so the preflight runs only once in the coordinator. Co-authored-by: Cursor <cursoragent@cursor.com>
…nto refactor/call-class-decomposition
Add JVM unit tests for the extracted Call collaborators (CallApiClient, CallEventManager, CallSessionManager, CallRenderer, CallMediaManager) to raise coverage on the refactor's new code toward the SonarCloud gate. Co-authored-by: Cursor <cursoragent@cursor.com>
…ents Broaden new-code coverage for the extracted Call components: exercise the CallJoinCoordinator retry loop and join-and-ring flow (via the RtcSession test factory), the CallConnectivityMonitor reconnect/leave listener, the reachable CallReconnector state-machine branches, the CallIceConnectionMonitor restart paths, plus additional CallMediaManager (monitorHeadset, not-selected devices) and CallApiClient (ring request, ringing create) cases. Co-authored-by: Cursor <cursoragent@cursor.com>
Add a unitTestRtcSessionFactory seam to CallReconnector's rejoin/migrate so the session-swap, monitor and finalize paths are unit-testable, and add tests for them (success + retry-until-exhausted). Also cover CallRenderer's incoming-audio track walking for all/selected participants. Co-authored-by: Cursor <cursoragent@cursor.com>
rahul-lohra
left a comment
There was a problem hiding this comment.
Solid PR, please review the comments. They are mostly on code-quality. There is 1 related to usage of client scope
Give the Call decomposition components explicit dependencies so they no longer reach into the Call facade: - CallApiClient, CallConnectivityMonitor, CallEventManager, CallIceConnectionMonitor, CallRenderer, CallStatsReporter, CallSessionManager and CallMediaManager now take the granular collaborators they need (type/id/scope/state/session/clientImpl/eglBase) instead of a Call. - Isolate the unavoidable identity hand-offs behind small seams/providers: RingingCallRegistrar for CallApiClient's ring/accept client-state writes, and a lazy () -> Call provider for CallMediaManager's MediaManagerImpl (a public type that requires a Call). - Behaviour is unchanged; component unit tests now construct each collaborator directly without a Call mock. The three orchestrators (CallReconnector, CallJoinCoordinator, CallLifecycleManager) still hold Call and are left for a follow-up. Co-authored-by: Cursor <cursoragent@cursor.com>
…nto refactor/call-class-decomposition
…uite Removes the last Call references from the extracted components so no class under call/components holds the facade any more. CallLifecycleManager now takes its collaborators directly (lazy providers, since it is constructed before CallState and the monitors exist), which made Call.stopConnectionMonitors/stopStatsReporting/cancelSfuObservers/ shutDownJobsGracefully dead; they are removed. CallMediaManager gains disableLocalCapture() so the lifecycle no longer reaches through to the device handles. The three callback interfaces into Call (CallHost, CallTeardownHost, RingingCallRegistrar) were named after who implements them rather than what they do, and six of their eight methods did the same thing: register or deregister this call in the client's ringing/active registries. Two were byte-identical. They collapse into one ClientCallRegistry; the genuine outliers (hasRequiredPermissions, shutDownJobs) become plain lambdas. Two production fixes surfaced while repairing the tests: - CallMediaManager evaluated eglBase().eglBaseContext to build an argument for MediaManagerFactory.create, forcing a real EGL context before the factory ran. Call owns both the context and the factory, so the parameter is dropped and the factory resolves it itself. - The reconnect loop reads connectivity straight off the connection module (it must not go through CallConnectivityMonitor, which would close a dependency cycle), but injectMockNetwork was repointed at the monitor. The loop therefore polled the real provider and stalled without consuming an attempt. Injecting at the module fixes three reconnect tests that had been failing since the decomposition commit. JoinRecoverableFailureTest is rebuilt on the coordinator harness: it relied on spying Call and reflectively repointing CallJoinCoordinator.call, a field that no longer exists. Core suite: 978 tests, 0 failures. Co-authored-by: Cursor <cursoragent@cursor.com>
… facade CallSessionManager owns the session identity and reconnect bookkeeping, but Call still re-exposed it through internal accessors that mostly existed for a single caller. Remove them: location and nonFastReconnectAttempts were only reachable from tests, connectStartTime/reconnectStartTime had dead setters, and unifiedSessionId was read by RtcSession alone. RtcSession now takes CallSessionManager directly and reads session identity and reconnect timings from it. The elapsed-time arithmetic moves onto the manager as connectionTimeSeconds()/reconnectionTimeSeconds(), next to the timestamps it derives from. The two test-only reads move into CallTestSeams.kt so they stay out of the production API. Co-authored-by: Cursor <cursoragent@cursor.com>
CallReconnector and CallJoinCoordinator each depended on the other, so one had to be injected as a lazy provider. Move the shared joinRequest into CallApiClient, which already owns the coordinator REST calls, and relocate the failed-SFU set to CallSessionManager so the request no longer has to ask the reconnector for it. Both orchestrators now depend on the api client and neither depends on the other. Also drops the provider lambdas around state, analytics, stats and media by declaring those components before their consumers. FailedSfuIdsTest no longer needs reflection into private reconnector members; the behaviour is covered directly in CallSessionManagerTest. Co-authored-by: Cursor <cursoragent@cursor.com>
…nto refactor/call-class-decomposition
rahul-lohra
left a comment
There was a problem hiding this comment.
All queries are resolved now. Good work 👍
|
|
🚀 Available in v1.30.0 |


Goal
closes AND-1318
Call.kthad grown to ~2,350 lines and mixed many unrelated responsibilities (REST API calls, media, session/RTC lifecycle, reconnect state machine, connectivity/ICE monitoring, events, stats, rendering, teardown). This made it hard to read, test, and evolve.This PR decomposes
Callinto focusedinternalcollaborators while keepingCallas a thin, binary-compatible public facade — no public API changes.Call.ktgoes from 2,351 to 1,043 lines.Implementation
Structure
flowchart LR Call["<b>Call</b><br/>public facade<br/>(binary-compatible)"] Orch["<b>Orchestrators</b><br/>drive a flow across siblings<br/><br/>CallJoinCoordinator<br/>CallReconnector<br/>CallLifecycleManager"] Svc["<b>Services</b><br/>each owns one concern<br/><br/>CallApiClient · CallMediaManager<br/>CallEventManager · CallStatsReporter<br/>CallRenderer · SessionMonitor<br/>CallIceConnectionMonitor<br/>CallConnectivityMonitor"] Sess["<b>CallSessionManager</b><br/>single writer for<br/>the RTC session"] Seams["<b>Seams</b><br/>the only route back to<br/>the Call instance<br/><br/>ClientCallRegistry<br/>RtcSessionFactory<br/>MediaManagerFactory"] Call -->|constructs, delegates| Orch Call -->|constructs, delegates| Svc Orch <-->|direct sibling calls| Svc Orch -->|read / write| Sess Svc -->|read| Sess Orch -.->|needs the Call instance| Seams Svc -.-> Seams Seams -.->|implemented by| CallThe rules the diagram encodes:
Callconstructs every component and delegates its public API down to them. No component holds aCallreference, and none of them can reach back up except through a seam.CallReconnectorandCallLifecycleManagerare built beforeCallState, because the event pipeline depends on them), the dependency is injected as a lazy provider.CallSessionManagerexposes the RTC session as a read-onlyStateFlowwithsetActiveSession()as the sole write path, so join, reconnect and teardown can read it but only one place mutates it.ClientCallRegistryregisters and deregisters the call in the client's ringing/active slots, telecom and per-call cleanup;RtcSessionFactoryandMediaManagerFactorykeep theCallargument ofRtcSessionandMediaManagerImplat the facade boundary.Callimplements them as anonymous objects capturingthis, so the call identity never leaks into a component.CallReconnectorreads connectivity straight from the connection module rather than throughCallConnectivityMonitor, which would otherwise close aConnectivityMonitor → Reconnector → ConnectivityMonitorcycle.Components
CallApiClientCallEventManagereventsflow, subscriptions, dispatchCallStatsReporterstatsReport/statLatencyHistoryCallRendererCallMediaManagerCallSessionManagerCallIceConnectionMonitorCallConnectivityMonitorCallJoinCoordinatorCallReconnectorCallLifecycleManagerSessionMonitorCallkeeps its existing member names as delegating accessors, soRtcSession,CallStateand existing callers keep working. Facade one-liners that existed only to route a call from one component to another (stopConnectionMonitors,stopStatsReporting,cancelSfuObservers,shutDownJobsGracefully,monitorSession,getFailedSfuIdsSnapshot) are removed now that siblings talk directly.Two production bugs found while repairing the test suite
CallMediaManagerevaluatedeglBase().eglBaseContextto build an argument forMediaManagerFactory.create, forcing a real EGL context before the factory (and its test hook) ran.Callowns both the context and the factory, so the parameter is dropped and the factory resolves it itself.CallConnectivityMonitor. The loop therefore polled the real provider and stalled onisConnected()without consuming an attempt. Injecting at the module restores three reconnect tests that had been failing silently since the first decomposition commit.Merge with
developReconciled
develop'sCallchanges into the new components: typed SFU connect-failure recovery (SfuConnectionResult+SfuConnectFailureCause) now lives inCallJoinCoordinator, and theunitTestRtcSessionFactoryhook replacesTestInstanceProvider.rtcSessionCreator.Testing
./gradlew :stream-video-android-core:testDebugUnitTest— pass (978 tests, 0 failures, 50 skipped)./gradlew :stream-video-android-core:apiCheck— pass (public API unchanged / binary-compatible)./gradlew :stream-video-android-core:spotlessCheck— pass./gradlew assembleDebug— pass (all modules incl. demo-app)New unit tests cover each extracted component:
CallApiClientTest,CallEventManagerTest,CallStatsReporterTest,CallRendererTest,CallMediaManagerTest,CallSessionManagerTest,CallIceConnectionMonitorTest,CallConnectivityMonitorTest,CallJoinCoordinatorTest,CallReconnectorTest.Two existing tests were rebuilt rather than patched, because they depended on internals that no longer exist:
JoinRecoverableFailureTestspiedCalland reflectively repointed a field insideCallJoinCoordinator. It now constructs the coordinator directly with mocked collaborators and drivesjoinInternal, covering the same three SFU connect-failure causes.CallReconnectorTestnow builds the reconnector with its injected siblings and a realCallSessionManager, so the session and reconnect bookkeeping behave as they do in production instead of being stubbed.Test-only reflection seams (
injectSession,injectMockNetwork) are consolidated into a singleCallTestSeams.ktrather than duplicated across test classes, so no test-only API was added toCall.☑️Contributor Checklist
General
developbranchCode & documentation
stream-video-examples)☑️Reviewer Checklist
🎉 GIF
Skipped — internal refactor with no user-facing change.
Made with Cursor