Skip to content

Add Video Analytics in call-join flow - #1716

Merged
aleksandar-apostolov merged 35 commits into
developfrom
feature/rahullohra/error-reporting-impl
Jun 12, 2026
Merged

Add Video Analytics in call-join flow#1716
aleksandar-apostolov merged 35 commits into
developfrom
feature/rahullohra/error-reporting-impl

Conversation

@rahul-lohra

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

Copy link
Copy Markdown
Contributor

Goal

Closes #AND-1226 Add Video Analytics in call-join flow

TL;DR

  1. Adds a new internal analytics subsystem (io.getstream.video.android.core.analytics) that instruments the entire call-join funnel — coordinator WS → coordinator join → SFU WS join → ICE connect → first media frame — and reports paired INITIATED/COMPLETED ClientEvents (with correlation IDs, outcome, elapsed time, and failure reasons) to the new reportClientCallEvent endpoint.
  2. Delivery is async and resilient: exponential-backoff retry in process (5 attempts, 500 ms → 8 s),
  3. Joins abandoned mid-flight are flushed as explicit FAILURE events. Everything is internal — no public API changes beyond generated OpenAPI models.

Analytics Flow animation

You may open this and play this html animation to understand the flow
analytics-flow-animation.html

Give us visibility into the call-join funnel directly from the client. Today, when a user fails to get into a call (or joins but never sees/hears media), we have no client-side signal telling us which stage failed, why, and how long each stage took.

This PR adds a new analytics subsystem under io.getstream.video.android.core.analytics that instruments every stage of the call lifecycle and reports paired INITIATED / COMPLETED events to the new reportClientCallEvent backend endpoint:

Stage What it measures
CoordinatorWS Coordinator websocket connection (pre-call, client-scoped)
JoinInitiated call.join() SDK method invoked
CoordinatorJoin Coordinator join HTTP request
WSJoin SFU websocket join
PeerConnectionConnect ICE connectivity per peer connection (publisher / subscriber)
FirstAudioFrame / FirstVideoFrame Time to first rendered remote media
MediaDevicePermission Camera / microphone permission state at join time

Every event carries correlation IDs (join_attempt_id, stage_id, call_session_id, coordinator_connect_id, sfu_id), an outcome (SUCCESS/FAILURE), elapsed_time, retry counts and failure reason/code — so the backend can reconstruct the full join funnel per attempt.

Delivery is best-effort but resilient: events are sent fire-and-forget with in-process exponential-backoff retry,

Implementation

Follow below

Architecture

The LLC analytics is split into five layers with a strict one-way dependency flow — observers never talk to the network, the dispatcher never knows about call semantics:

flowchart TB
    subgraph SDK["SDK core (instrumentation hooks)"]
        Call["Call.kt<br/>(join / leave / first frame)"]
        Rtc["RtcSession /<br/>SfuSocketConnection"]
        Coord["CoordinatorSocketStateService<br/>(VideoSocketState flow)"]
    end

    subgraph OBS["Observation layer — analytics.call / analytics.coordinator"]
        CA["CallAnalytics<br/>(per-call façade, owns all observers)"]
        JA["JoinAnalytics"]
        SA["SfuAnalytics"]
        PCA["PeerConnectionAnalytics<br/>(collects publisher/subscriber<br/>PeerConnectionState flows)"]
        AA["AudioAnalytics<br/>(AudioTrackSink, first remote frame)"]
        VA["VideoAnalytics<br/>(first video frame)"]
        MPO["MediaPermissionObserver"]
        CoA["CoordinatorAnalytics<br/>(client-scoped)"]
    end

    subgraph STATE["Shared correlation state"]
        JSH["JoinAnalyticsStateHolder<br/>joinStageAttemptId · joinReason ·<br/>callSessionId · stage guard"]
        SSH["SfuAnalyticsStateHolder<br/>sfuId · wsStage"]
        PSH["PeerConnectionAnalyticsStateHolder<br/>publisher/subscriber stage · jobs"]
    end

    subgraph REP["Reporting layer — analytics.reporting"]
        CER["ClientEventReporter<br/>(in-flight session registry,<br/>elapsed time, ICE state machine,<br/>abort flush)"]
        CEF["ClientEventFactory<br/>(builds ClientEvent payloads)"]
    end

    subgraph DISP["Dispatch layer — analytics.reporting.dispatcher"]
        ED["EventDispatcher (interface)"]
        IED["ImmediateEventDispatcher<br/>(coroutine per batch,<br/>exp. backoff retry)"]
    end

    API["ProductvideoApi.reportClientCallEvent<br/>(generated)"]

    Call --> CA
    Rtc --> SA
    Rtc --> PCA
    Coord --> CoA
    CA --> JA & SA & PCA & AA & VA & MPO
    JA & SA & PCA & AA & VA & MPO -. read/write .-> JSH & SSH
    PCA -.-> PSH
    JA & SA & PCA & AA & VA & MPO --> CER
    CoA --> CER
    CER --> CEF
    CER --> ED
    ED --- IED
    IED -->|"send (async)"| API
    IED -->|"on failure: save"| SDS
    SDS --> FDS
    SDS -.-> IMD
    IED -->|"retryPending: loadAndClear"| SDS
Loading

Layer responsibilities

Layer Components Responsibility
Instrumentation hooks Call, RtcSession, SfuSocketConnection, CoordinatorSocketStateService, StreamVideoClient Emit raw lifecycle signals at the points where joins, sockets and peer connections change state. Minimal footprint — one-line calls into the observation layer.
Observation CallAnalytics (per-call façade), JoinAnalytics, SfuAnalytics, PeerConnectionAnalytics, AudioAnalytics, VideoAnalytics, MediaPermissionObserver, CoordinatorAnalytics (client-scoped) Translate SDK state changes into stage events. Each observer guards with a Stage state machine (NOT_STARTED → IN_PROGRESS → NOT_STARTED) so a stage is reported exactly once per attempt, even when callbacks fire repeatedly.
Shared state JoinAnalyticsStateHolder, SfuAnalyticsStateHolder, PeerConnectionAnalyticsStateHolder StateFlow-backed holders that share correlation context (joinStageAttemptId, joinReason, callSessionId, sfuId) across observers so events from different subsystems stitch together into one join attempt.
Reporting ClientEventReporter, ClientEventFactory, InFlightSession models Owns the INITIATED/COMPLETED pairing. On initiated, stores an InFlightSession keyed by a generated stageId; on completed, removes it and computes elapsed_time. Also runs the peer-connection ICE state machine and the abort flush (below).
Dispatch EventDispatcher, ImmediateEventDispatcher Fire-and-forget delivery on a coroutine scope, in-process retry with exponential backoff

Composition / scoping

  • Client-scoped (one per StreamVideo instance): ClientEventReporter is created in ClientState via ClientEventReporter.getDefault(context, api) and wired with ImmediateEventDispatcher CoordinatorAnalytics lives on StreamVideoClient and observes the coordinator socket state flow.
  • Call-scoped (one per Call): CallAnalytics instantiates all per-call observers and their state holders, sharing the client-scoped reporter. This keeps correlation state isolated per call

Communication between components (happy path)

sequenceDiagram
    participant Call as Call / RtcSession
    participant Obs as Observers<br/>(Join/Sfu/PC/Media)
    participant SH as StateHolders
    participant Rep as ClientEventReporter
    participant Disp as ImmediateEventDispatcher
    participant DS as PendingEventDataSource
    participant BE as Backend API

    Note over Call,BE: Pre-call (client scope)
    Call->>Rep: CoordinatorAnalytics: WS Connecting (INITIAL_CONNECTION)
    Rep->>Disp: CoordinatorWS INITIATED
    Disp->>BE: reportClientCallEvent
    Call->>Rep: WS Connected
    Rep->>Disp: CoordinatorWS COMPLETED (success, retryCount)
    Rep->>Disp: retryPending()
    Disp->>DS: loadAndClear() — replay any persisted events

    Note over Call,BE: call.join()
    Call->>Obs: onJoinFunctionStart()
    Obs->>SH: new joinStageAttemptId
    Obs->>Rep: JoinInitiated INITIATED
    Call->>Obs: onJoinRequestStart(joinReason)
    Obs->>Rep: CoordinatorJoin INITIATED → stageId
    Rep->>Rep: store InFlightSession(stageId, startedAtMs)
    Call->>Obs: onJoinRequestSuccess(sessionId)
    Obs->>SH: callSessionId
    Obs->>Rep: CoordinatorJoin COMPLETED (elapsed = now − startedAtMs)

    Call->>Obs: SFU WS connect
    Obs->>Rep: WSJoin INITIATED / COMPLETED

    Call->>Obs: PeerConnectionState changes (publisher & subscriber flows)
    Obs->>Rep: onPeerConnectionStateChanged(role, iceState)
    Note over Rep: ICE state machine:<br/>CHECKING → open session (close superseded as FAILURE)<br/>CONNECTED → COMPLETED success<br/>FAILED → COMPLETED failure

    Call->>Obs: first video frame / first remote audio frame
    Obs->>Rep: FirstVideoFrame / FirstAudioFrame INITIATED
    Rep->>Disp: send(event)
    Disp->>BE: reportClientCallEvent
Loading

Abort path: when the user leaves a call while any stage is still IN_PROGRESS (CallAnalytics.onCallLeave), ClientEventReporter.abortAllPostCallInFlight(reason) snapshots and clears every in-flight session and emits a COMPLETED/FAILURE event for each, with an AnalyticsCallAbortReason (CLIENT_ABORTED or BACKEND_LEAVE) — so abandoned joins are never silently dropped from the funnel.

Retry mechanism

Delivery is an in-process backoff loop for transient blips

flowchart TB
    A["dispatcher.send(events)"] --> B["launch coroutine<br/>(UserScope)"]
    B --> C{"POST reportClientCallEvent"}
    C -->|success| D([done])
    C -->|failure| E{"retryable?<br/>HTTP 5xx · SocketTimeout ·<br/>Connect · UnknownHost · IOException"}
    E -->|"yes, attempts < 5"| F["delay 500ms × 2^attempt<br/>(500 → 1000 → 2000 → 4000 → 8000)"]
    F --> C
    E -->|"no / attempts exhausted"| G["dataSource.save(events)<br/>append to pending_events.ndjson"]

    H["Coordinator WS connected<br/>(initial connect or reconnect)"] --> I["retryPending()"]
    I --> J["loadAndClear()<br/>drain batch of 10"]
    J --> A
Loading
  • Tier 1 — in-process backoff (ImmediateEventDispatcher.retryInternal): up to 5 attempts with exponential backoff (base 500 ms, doubling to 8 s). Only transient failures are retried — HTTP 5xx and connectivity exceptions (SocketTimeoutException, ConnectException, UnknownHostException, IOException). Client errors (4xx) fail fast without burning retries.

Implementation details

  • New package io.getstream.video.android.core.analytics (~20 classes): observation (call.observer, coordinator), reporting (reporting, reporting.model), dispatch (reporting.dispatcher).
    Everything is internal — no public API surface added (.api changes are from generated models only).
  • Generated API surface: new OpenAPI models ClientEvent, ReportClientEventRequest/Response and ProductvideoApi.reportClientCallEvent endpoint.
  • ClientEventFactory centralizes payload construction: SDK version, user agent (capped at 512 chars), user id, timestamps, permission status mapping, and the client-generated coordinator_connect_id.
  • Peer connection observation uses flatMapLatest over RtcSession.publisher/subscriber StateFlows, so observers automatically re-attach across rejoins/ICE restarts (stopAndObservePeerConnections).
  • First-audio-frame detection attaches a lightweight AudioTrackSink to remote tracks with strict real-time-thread discipline (no logging/blocking on the WebRTC audio thread; CAS guard + coroutine hand-off; sink removal deferred to the coroutine to avoid deadlocking WebRTC's sink-list lock). Currently behind a disabled flag (isEnabled = false).
  • Dedup guards everywhere: stage state machines in the state holders ensure reconnect storms or duplicate callbacks produce exactly one INITIATED/COMPLETED pair per attempt.
  • Integrates with the CallLeaveReason work from this branch: leave reasons map to AnalyticsCallAbortReason for the abort flush.

🎨 UI Changes

None

Testing

  • Manually verified: join funnel events on happy path, retry across reconnect.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added comprehensive call analytics infrastructure to track join attempts, connection states, peer connections, media permissions, and audio/video frame rendering events.

@rahul-lohra rahul-lohra self-assigned this Jun 9, 2026
@rahul-lohra
rahul-lohra requested a review from a team as a code owner June 9, 2026 22:00
@rahul-lohra rahul-lohra added the pr:internal Internal or infra-only changes label Jun 9, 2026
@github-actions

github-actions Bot commented Jun 9, 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.

@rahul-lohra rahul-lohra changed the title [AND-1226] Add Video Analytics in call-join flow Add Video Analytics in call-join flow (4/4) Jun 9, 2026
@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

SDK Size Comparison 📏

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

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds comprehensive analytics instrumentation across the video call lifecycle. New analytics state machines track join progression, SFU WebSocket connection, peer connection ICE states, and media frame rendering. A centralized ClientEventReporter orchestrates event dispatch with pending-event persistence for offline resilience. Analytics observers are integrated into the Call class with join/reconnect/leave lifecycle hooks, and coordinator socket state is monitored separately. The changes preserve backward compatibility through deprecation patterns.

Changes

Call Analytics Instrumentation

Layer / File(s) Summary
Analytics Model Types & Enums
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/*
Defines EventType, EventOutcome, EventStage, PeerConnectionRole enums; AnalyticsCallAbortReason and AnalyticsFailureCodes enums with code/message pairs; sealed JoinReason hierarchy; Stage (NOT_STARTED/IN_PROGRESS); and InFlightSession sealed class with PostCallFlightSession and PreCallInFlightSession subclasses for tracking in-flight analytics sessions.
Event Dispatcher & Datasources
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/*, datasource/*
EventDispatcher interface defines send/sendAll/retryPending/deleteAll contract. ImmediateEventDispatcher sends events on a coroutine with exponential-backoff retry (up to 5 attempts) for HTTP 5xx, timeouts, and connection errors. PendingEventDataSource interface with InMemoryPendingEventDataSource (thread-safe list), FileBasedPendingEventDataSource (NDJSON file with batch loading), and SynchronizedPendingEventDataSource (read/write-locked decorator) for persisting failed events.
Event Factory & Core Reporter
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventFactory.kt, ClientEventReporter.kt
ClientEventFactory builds ClientEvent payloads with timestamp, user-agent, coordinator ID, and permission/WebRTC state mappings. ClientEventReporter maintains concurrent maps for in-flight sessions keyed by stage ID and peer-connection role, with methods for coordinator WS, SDK join, coordinator join, SFU WS, peer connection ICE state, audio/video frame, and media permission lifecycle reporting, plus abort handling for unfinished stages.
Join Analytics Observer & State
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsStateHolder.kt, JoinAnalytics.kt
JoinAnalyticsStateHolder encapsulates MutableStateFlow<JoinTelemetryState> with fields for join reason, stage attempt ID, stage ID, join stage, and call session ID. JoinAnalytics generates stage-attempt UUIDs at join start, conditionally creates stage state on request start, reports success only when stage is IN_PROGRESS, and resets on completion or error.
SFU WebSocket Analytics Observer & State
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsStateHolder.kt, SfuAnalytics.kt
SfuAnalyticsStateHolder exposes StateFlow views of SFU ID, WS stage, and event stage ID. SfuAnalytics (gated by isEnabled flag) reports WS initiation and completion with retry/failure details, provides getFakeSfuAnalytics() for backward compatibility, and transitions stage from NOT_STARTED → IN_PROGRESS → NOT_STARTED.
Peer Connection Analytics Observer & State
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt, PeerConnectionAnalytics.kt
PeerConnectionAnalyticsStateHolder tracks observer, publisher, and subscriber coroutine jobs plus publisher/subscriber stage values. PeerConnectionAnalytics concurrently observes publisher/subscriber flows from RtcSession, maps WebRTC states to analytics Stage, and reports ICE state changes with join/SFU context.
Audio, Video & Media Permission Analytics
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/AudioAnalytics.kt, VideoAnalytics.kt, MediaPermissionObserver.kt
AudioAnalytics observes remote participant audio tracks via registered AudioTrackSink and reports first frame once per instance (disabled by default). VideoAnalytics reports first non-screen-share video frame once per stage ID. MediaPermissionObserver checks microphone and camera permissions and reports status with join context.
Call Analytics Orchestrator
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt
Wires together all call-level observers (join, SFU, peer connection, media permission, audio, video), resets audio/video state on join success, begins first-remote-audio observation, maps CallLeaveReason to abort reason on leave, and stops peer-connection observers.
Call Join/Reconnect/Leave Integration
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt
Call initializes CallAnalytics, invokes join analytics at function start and media-permission observation, passes JoinAnalyticsModel (with retry attempt and join reason) through join/reconnect flows, reports analytics on permanent errors and retry exhaustion, wires SFU analytics state on session creation, stops peer observers during monitoring, and emits call-leave event with abort reason handling.
RtcSession & SFU Socket Integration
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt, SfuSocket.kt, SfuSocketConnection.kt, SfuConnectionModule.kt
RtcSession accepts SfuAnalytics, passes it to SfuConnectionModule, reports WS success on connect, and forwards JoinAnalyticsModel to connectInternal for failure analytics. SfuSocket reports WS initiation with wasPreviouslyConnected status. SfuSocketConnection accepts SfuAnalytics with deprecated public constructor for backward compatibility. SfuConnectionModule threads SfuAnalytics to SfuSocketConnection.
Coordinator Analytics & Client Integration
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt, StreamVideoClient.kt, CoordinatorAnalytics.kt
ClientState initializes ClientEventReporter.getDefault(). StreamVideoClient wires CoordinatorAnalytics, starts observation during initialization, and ends observers during cleanup. CoordinatorAnalytics collects VideoSocketState updates, reports WS initiation on INITIAL_CONNECTION, and reports completion success/failure on connected/permanently-disconnected.
Coordinator Socket Retry Tracking
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketStateService.kt
Adds retryAttempts and lastRetryAttempts companion object variables to CoordinatorSocketStateService, increments on reconnect, and resets on connect/established/error callbacks for analytics context.
API Signature Updates
stream-video-android-core/api/stream-video-android-core.api
ClientEvent primary constructor, component accessors, and copy signature updated; new getJoinReason(): String accessor added. SfuSocketConnection public constructor parameter reordered: CoroutineScope moved to end of parameter list.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

pr:new-feature

Suggested reviewers

  • PratimMallick
  • aleksandar-apostolov

🐰 Analytics hop through the call tree,
Stages track each attempt with glee,
Dispatcher sends with care,
Pending events repaired,
Now every metric is free! 📊✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.61% 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
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 pull request description is comprehensive and well-structured, covering goals, implementation, architecture, and component responsibilities.
Title check ✅ Passed The title clearly summarizes the main change: adding video analytics to the call-join flow. It is part 4 of a series, directly related to the extensive analytics instrumentation changeset covering call lifecycle stages, event reporting, and persistence.

✏️ 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/error-reporting-impl

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.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt (1)

693-785: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate joinAnalyticsModel into SFU connect.

At Line 773, _join calls connectInternal() without joinAnalyticsModel, so SFU websocket failure events in initial join retries always emit retryCount=0.

Suggested fix
-        when (val result = session.value?.connectInternal()) {
+        when (
+            val result =
+                session.value?.connectInternal(
+                    joinAnalyticsModel = joinAnalyticsModel,
+                )
+        ) {
🤖 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/Call.kt`
around lines 693 - 785, _join currently calls session.value?.connectInternal()
without passing joinAnalyticsModel, so SFU websocket connect attempts don't
receive the analytics model and retry events show retryCount=0; update the call
in _join to pass joinAnalyticsModel into connectInternal (i.e.,
session.value?.connectInternal(joinAnalyticsModel)), and if
RtcSession.connectInternal does not accept that parameter, add a parameter to
RtcSession.connectInternal (and any other callers) to accept JoinAnalyticsModel
and thread it through to the SFU websocket connection logic so initial join
retries emit correct analytics.
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt (1)

691-706: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid hardcoded retryCount = 0 on successful SFU WS completion.

At Line 702, success completion is emitted with a constant retry count, so successful rejoin/migrate attempts are reported with incorrect retry metadata.

Suggested fix
@@
-                        call.callAnalytics.sfuAnalytics.onSfuWsCompleted(
-                            success = true,
-                            retryCount = 0,
-                        )
                     }
@@
             is SfuSocketState.Connected -> {
+                call.callAnalytics.sfuAnalytics.onSfuWsCompleted(
+                    success = true,
+                    retryCount = joinAnalyticsModel?.retryAttempt ?: 0,
+                )
                 sendConnectionTimeStats(reconnectDetails?.strategy)
                 SfuConnectionResult.Connected
             }

Also applies to: 887-911

🤖 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/call/RtcSession.kt`
around lines 691 - 706, Replace the hardcoded retryCount = 0 in the
SfuSocketState.Connected success handler with the actual SFU websocket retry
counter used by the session (the same counter incremented during rejoin/migrate
attempts), e.g. use the session's sfuReconnectAttempts or sfuRejoinAttemptCount
variable instead of 0; update both places where onSfuWsCompleted(success = true,
retryCount = 0) is called (the SfuSocketState.Connected block and the similar
block around lines 887-911) and ensure the chosen counter is reset or read
consistently when emitting the success event.
🧹 Nitpick comments (1)
stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt (1)

73-76: ⚡ Quick win

Make PeerConnectionAnalyticsState immutable to preserve flow snapshot semantics.

Using var here allows in-place mutation of state.value fields without emitting a new StateFlow value. Converting these to val keeps updates funneled through copy(...) and MutableStateFlow.update.

♻️ Suggested change
 internal data class PeerConnectionAnalyticsState(
-    var peerConnectionObserverJob: Job? = null,
-    var publisherJob: Job? = null,
-    var subscriberJob: Job? = null,
+    val peerConnectionObserverJob: Job? = null,
+    val publisherJob: Job? = null,
+    val subscriberJob: Job? = null,
     val publisherStage: Stage = Stage.NOT_STARTED,
     val subscriberStage: Stage = Stage.NOT_STARTED,
 )
🤖 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/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt`
around lines 73 - 76, The PeerConnectionAnalyticsState data class currently uses
mutable vars (peerConnectionObserverJob, publisherJob, subscriberJob) which
permits in-place mutation of state.value and breaks StateFlow snapshot
semantics; change those properties to val so the class is immutable, and update
call sites to modify the state via PeerConnectionAnalyticsState.copy(...) inside
MutableStateFlow.update { current -> current.copy(...) } (or assign a new
instance) so all changes emit new StateFlow values and preserve snapshot
semantics.
🤖 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/api/stream-video-android-core.api`:
- Around line 14728-14729: SfuSocketConnection's public deprecated constructor
changed parameter order causing binary-compat breaks; add a new public
deprecated constructor overload that preserves the original parameter ordering
(the version with CoroutineScope as the last positional parameter) which
delegates to the internal primary constructor (or to the existing public
delegating constructor) so existing compiled callers continue to work, and add a
short KDoc on the deprecated overload explaining the migration to the new
constructor signature and recommending named-argument usage. Ensure the new
overload's signature and `@Deprecated` marker match the previous public API and
delegate without duplicating initialization logic (refer to SfuSocketConnection
constructors and the internal primary <init>).

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/AudioAnalytics.kt`:
- Around line 46-59: The isEnabled flag in AudioAnalytics is hardcoded to false,
preventing observeParticipantsForFirstRemoteAudioFrame and related logic from
ever running; change isEnabled to be configurable at runtime (e.g., accept a
constructor parameter, inject a FeatureFlag/Config, or read from a runtime
setting) and update any call sites that construct AudioAnalytics to pass the
correct flag or flag provider; ensure the
observeParticipantsForFirstRemoteAudioFrame, recordedFirstFrame, trackSinks, and
observeJob behavior remains identical when enabled, and remove the early return
guard that checks the hardcoded isEnabled so the observer can run when the
runtime flag is true.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsStateHolder.kt`:
- Around line 84-90: JoinTelemetryState currently has mutable properties (vars:
joinStageAttemptId, stageId, joinStage, callSessionId) which allow in-place
mutation of state.value and can break StateFlow emission semantics; change these
mutable properties to immutable vals so the data class is fully immutable, and
ensure any updates to the held state are done via the state holder's updater
(e.g., _state.update { it.copy(...) }) rather than mutating fields directly;
locate the JoinTelemetryState data class and replace the var declarations with
val for joinStageAttemptId, stageId, joinStage, and callSessionId.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalytics.kt`:
- Around line 51-54: The Log.d call in VideoAnalytics (tag "VideoObserver")
exposes sensitive identifiers (videoTrackId, videoSessionId, callSessionId);
update the logging in the method containing this debug (the
firstVideoFrameRendered/observer log block inside VideoAnalytics) to remove or
redact those identifiers (e.g., replace with fixed "[REDACTED]" or a
non-reversible short hash) or gate the detailed log behind a development-only
flag, and keep only non-sensitive info (trackType, width, height) in production
logs.
- Around line 62-65: The event reporting is using
joinAnalyticsStateHolder.state.value.callSessionId (which can be stale) instead
of the function parameter callSessionId; update the reporter call in
VideoAnalytics (the method that builds the join event) to pass the method
argument callSessionId into the event payload (e.g., where joinReason and
trackId are set) rather than reading callSessionId from
joinAnalyticsStateHolder.state.value so the reported session ID reflects the
current callSessionId parameter.
- Around line 45-50: The analytics misses screen-share because getTrack is
hard-coded to TrackType.TRACK_TYPE_VIDEO; update the track lookup in
VideoAnalytics (the branch handling TrackType.TRACK_TYPE_VIDEO,
TrackType.TRACK_TYPE_SCREEN_SHARE) to pass the dynamic trackType variable to
rtcSession?.subscriber?.value?.getTrack(videoSessionId, trackType) (instead of
TrackType.TRACK_TYPE_VIDEO) so screen-share tracks resolve and
FIRST_VIDEO_FRAME_RENDERED gets reported; keep the existing videoSessionId vs
callSessionId check and asVideoTrack()/video?.id() usage.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalytics.kt`:
- Around line 53-71: In the VideoSocketState handling (branches for
VideoSocketState.Connected and
VideoSocketState.Disconnected.DisconnectedPermanently) call
eventReporter.reportCoordinatorWSCompleted as you do, then immediately reset the
stageId to an empty value (e.g., stageId.value = "") to avoid duplicate
completion reports on later terminal-state emissions; update the branches that
reference CoordinatorSocketStateService.Companion.lastRetryAttempts to report
first, then clear stageId.value so the session is no longer considered
in-flight.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporter.kt`:
- Around line 440-515: The three reporter methods reportFirstAudioFrameRendered,
reportFirstVideoFrameRendered, and reportMediaPermissionStatus only emit
EventType.INITIATED and return a stageId but have no matching completion events,
so create paired completion APIs that send EventType.COMPLETED with the same
stageId and relevant outcome data: add functions like
reportFirstAudioFrameRenderedCompleted(stageId: String, sfuId: String, callId:
String, callType: String, joinStageAttemptId: String, callSessionId: String,
joinReason: JoinReason, success: Boolean, optional error/reason: String?),
reportFirstVideoFrameRenderedCompleted(stageId: String, trackId: String, ...
success/error?), and reportMediaPermissionStatusCompleted(stageId: String,
callId: String, callType: String, joinStageAttemptId: String, joinReason:
JoinReason, cameraAllowed: Boolean, microphoneAllowed: Boolean), each calling
clientEventFactory.buildRequest with the same EventStage
(EventStage.Call.FIRST_AUDIO_FRAME_RENDERED, FIRST_VIDEO_FRAME_RENDERED,
MEDIA_DEVICE_PERMISSION), eventType = EventType.COMPLETED, and the original
stageId so elapsed/outcome can be recorded.
- Around line 107-111: The PreCallInFlightSession is being created with the
wrong stage constant: when opening a coordinator websocket pre-call session (the
entry stored in postCallFlightSessions inside ClientEventReporter), replace
EventStage.Call.COORDINATOR_JOIN with EventStage.CoordinatorWs so the in-flight
metadata reflects a coordinator WS session; update the PreCallInFlightSession
instantiation (the call that sets stage = ...) to use EventStage.CoordinatorWs.
- Around line 134-142: The COMPLETED coordinator WS event built in
clientEventFactory.buildRequest is missing the stageId, preventing correlation
with the INITIATED event; update the call in ClientEventReporter (the
buildRequest invocation where stage = EventStage.CoordinatorWs and eventType =
EventType.COMPLETED) to pass the stageId argument (use the same stage.id or
stageId value used when building the INITIATED event) so the COMPLETED event
contains stageId, keeping all other fields (outcome, retryCountAttempt,
retryFailureCode, elapsedTime, retryFailureReason) unchanged.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/datasource/FileBasedPendingEventDataSource.kt`:
- Around line 35-39: The constructor of FileBasedPendingEventDataSource must
validate batchSize to prevent non-positive values that cause loadAndClear() to
never drain files; add a guard in the FileBasedPendingEventDataSource
initializer (or primary constructor) that checks if batchSize <= 0 and either
throw an IllegalArgumentException with a clear message or coerce it to
DEFAULT_BATCH_SIZE, so any call creating this class (and code paths using
batchSize in loadAndClear()) will never operate with a non-positive batch size.
- Around line 60-70: The current loadAndClear in FileBasedPendingEventDataSource
reads and removes lines from the NDJSON file before deserializing them, which
can permanently drop events if parsing fails; change the logic in loadAndClear
to first read the batch lines (use file.readLines().filter { it.isNotBlank() }
and take(batchSize)), attempt to deserialize those lines via adapter.fromJson
(using runCatching) and collect results and parse failures, and only if parsing
succeeded for all intended lines mutate the file (delete if no remaining lines,
or write remaining.joinToString(...)); if any line in the batch fails to parse,
do not delete/overwrite the file (leave it intact) and return only the
successfully parsed items or handle according to existing failure semantics —
update references in this method (FileBasedPendingEventDataSource, loadAndClear,
adapter.fromJson, file.delete, file.writeText) accordingly.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/datasource/PendingEventDataSource.kt`:
- Line 45: The loadAndClear() implementation takes a snapshot and clears in two
non-atomic steps which lets a concurrent save() slip in and be lost; make
loadAndClear() and save() mutually exclusive by protecting accesses to the
shared queue with the same synchronization mechanism (e.g., synchronized(this)
block or a dedicated Mutex) so that loadAndClear() performs snapshot + clear
atomically and save() appends under the same lock; update functions
loadAndClear() and save() to acquire/release that lock around all queue
operations.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcher.kt`:
- Around line 58-62: ImmediateEventDispatcher currently keys the "don't requeue"
decision on e.message == WONT_RETRY but retryInternal never returns that
sentinel; instead make the control flow based on exception type: introduce/use a
dedicated NonRetryableException (or reuse an existing domain exception) thrown
by retryInternal when events should not be retried, and change the onFailure
handler in ImmediateEventDispatcher to check for that exception type (e.g., if
(e is NonRetryableException) skip dataSource.save(events) else
dataSource.save(events)); update retryInternal to throw NonRetryableException
where appropriate so non-retryable failures are not re-queued.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsFailureCodes.kt`:
- Around line 25-26: The enum entries REQUEST_TIMEOUT and SFU_REQUEST_TIMEOUT in
AnalyticsFailureCodes.kt are colliding because both use the same code string
"REQUEST_TIMEOUT"; change the SFU_REQUEST_TIMEOUT code string to a unique
identifier (for example "SFU_REQUEST_TIMEOUT") while keeping/describing the
human-readable message ("SFU connection timed out") intact so backend analytics
can differentiate the two failure classes; update only the code string for the
SFU entry in the AnalyticsFailureCodes enum (refer to the symbols
REQUEST_TIMEOUT and SFU_REQUEST_TIMEOUT) and run tests/compile to verify no
other references need adjusting.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketStateService.kt`:
- Around line 34-37: The companion-object counters retryAttempts and
lastRetryAttempts are process-global and must be moved to per-instance,
thread-safe state to avoid cross-client corruption; update
CoordinatorSocketStateService to make these fields instance properties (e.g.,
private var retryAttempts / lastRetryAttempts on the class) or use
confined/thread-safe primitives (AtomicInteger or coroutine-confined vars) and
replace all companion references with the instance properties, then adjust
CoordinatorAnalytics interactions to accept and record per-instance retry
snapshots (pass the instance counters or a Snapshot data class) so telemetry is
derived from the specific CoordinatorSocketStateService rather than a shared
companion object.

---

Outside diff comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt`:
- Around line 693-785: _join currently calls session.value?.connectInternal()
without passing joinAnalyticsModel, so SFU websocket connect attempts don't
receive the analytics model and retry events show retryCount=0; update the call
in _join to pass joinAnalyticsModel into connectInternal (i.e.,
session.value?.connectInternal(joinAnalyticsModel)), and if
RtcSession.connectInternal does not accept that parameter, add a parameter to
RtcSession.connectInternal (and any other callers) to accept JoinAnalyticsModel
and thread it through to the SFU websocket connection logic so initial join
retries emit correct analytics.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt`:
- Around line 691-706: Replace the hardcoded retryCount = 0 in the
SfuSocketState.Connected success handler with the actual SFU websocket retry
counter used by the session (the same counter incremented during rejoin/migrate
attempts), e.g. use the session's sfuReconnectAttempts or sfuRejoinAttemptCount
variable instead of 0; update both places where onSfuWsCompleted(success = true,
retryCount = 0) is called (the SfuSocketState.Connected block and the similar
block around lines 887-911) and ensure the chosen counter is reset or read
consistently when emitting the success event.

---

Nitpick comments:
In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt`:
- Around line 73-76: The PeerConnectionAnalyticsState data class currently uses
mutable vars (peerConnectionObserverJob, publisherJob, subscriberJob) which
permits in-place mutation of state.value and breaks StateFlow snapshot
semantics; change those properties to val so the class is immutable, and update
call sites to modify the state via PeerConnectionAnalyticsState.copy(...) inside
MutableStateFlow.update { current -> current.copy(...) } (or assign a new
instance) so all changes emit new StateFlow values and preserve snapshot
semantics.
🪄 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: e0a542a1-4360-4ebc-b3c2-d3730848a964

📥 Commits

Reviewing files that changed from the base of the PR and between 64799cb and 974d0d6.

⛔ Files ignored due to path filters (1)
  • stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/ClientEvent.kt is excluded by !**/generated/**
📒 Files selected for processing (36)
  • stream-video-android-core/api/stream-video-android-core.api
  • 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/ClientState.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/AudioAnalytics.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsStateHolder.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/MediaPermissionObserver.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalytics.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalytics.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsStateHolder.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalytics.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/JoinAnalyticsModel.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/Stage.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalytics.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventFactory.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporter.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/datasource/FileBasedPendingEventDataSource.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/datasource/PendingEventDataSource.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/datasource/SynchronizedPendingEventDataSource.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/EventDispatcher.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcher.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsCallAbortReason.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsFailureCodes.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventOutcome.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventStage.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventType.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/InFlightSession.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/PeerConnectionRole.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/SfuConnectionModule.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketStateService.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt
  • stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt

Comment thread stream-video-android-core/api/stream-video-android-core.api Outdated
@aleksandar-apostolov

Copy link
Copy Markdown
Contributor

Two TODO Rahul markers still in production code — one block at the top of ClientEventReporter.kt ("Ask clarification" for previouslyConnectedTimestamp and retryFailureCode), one inline at line 205 ("ask tomorrow"). Combined with the RetryExhausted("TODO") default in #1715, worth a sweep across both PRs to resolve or convert to tracked tickets before merge.

Base automatically changed from feature/rahullohra/error-reporting-introduce-call-leave-reason to develop June 12, 2026 13:23
@rahul-lohra rahul-lohra changed the title Add Video Analytics in call-join flow (4/4) Add Video Analytics in call-join flow Jun 12, 2026
@rahul-lohra
rahul-lohra force-pushed the feature/rahullohra/error-reporting-impl branch from dac2d12 to 0ae3774 Compare June 12, 2026 14:50
@sonarqubecloud

Copy link
Copy Markdown

@aleksandar-apostolov
aleksandar-apostolov merged commit 2016186 into develop Jun 12, 2026
21 of 22 checks passed
@aleksandar-apostolov
aleksandar-apostolov deleted the feature/rahullohra/error-reporting-impl branch June 12, 2026 15:28
@stream-public-bot stream-public-bot added the released Included in a release label Jun 12, 2026
@stream-public-bot

Copy link
Copy Markdown
Collaborator

🚀 Available in v1.27.0

PratimMallick added a commit that referenced this pull request Aug 10, 2026
* Upgrade to `m145` webrtc and noise-cancellation lib `v3.0.0` (#1678)

* upgrade to m145 webrtc and noise-cancellation

* Update to webrtc m145 and corresponding noiseCancellation lib

* Remove the snapshot repo resolution

* [skip ci] Update SDK sizes

* AUTOMATION: Version Bump

* Honor SFU-provided degradation preference for adaptive video publishing (#1699)

* feat(core): pick up SFU DegradationPreference and add WebRTC mapper

Regenerate SFU protos to pick up the new DegradationPreference enum and
its degradation_preference fields on PublishOption and VideoSender (plus
TrackInfo.self_sub_audio_video), and refresh the public API dump.

Add toRtcDegradationPreference() converting the SFU enum to
org.webrtc.RtpParameters.DegradationPreference, returning null for
UNSPECIFIED so callers can keep the current value. Includes unit tests
covering every enum variant.

Co-authored-by: Cursor <cursoragent@cursor.com>

* publisher changes for applying degradation preferences

* Remove duplicate handling of ChangePublishQualityEvent from callState. This event directly gets handled by RtcSession handleEvent method

* Added test for the two call sites where degradation Preference is getting set to test for the case where sfu sends the same degrdation preference which is already set in the transcevier sender param

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(core): initialize Call.events before CallState to avoid NPE in sorted-participants init (#1701)

* AUTOMATION: Version Bump

* [skip ci] Update SDK sizes

* Add ability to add custom user in demo-app (#1700)

* demo: Add logic to add custom user

* chore(demo-app): gate add user dialog to development flavor

Hide the new add-user button and popup outside the development flavor
so the production demo app doesn't expose internal user injection.

---------

Co-authored-by: Aleksandar Apostolov <apostolov.alexandar@gmail.com>

* fix(core): send only the changed track in UpdateMuteStates (#1706)

When toggling a single track (e.g. muting the mic while the camera stays
on, or turning the camera off while the mic stays on), RtcSession sent the
full declarative mute-state map for all track types. Re-asserting an
unchanged track as un-muted made the SFU re-emit a redundant
TrackPublishedEvent for that track. That event carries a potentially stale
published_tracks snapshot which re-enabled the just-disabled track, freezing
the local self-view on the last frame / showing the avatar while no frames
arrive.

Send only the mute state of the track that actually changed, matching the
web SDK. On SFU (re)connect/migration each enabled track is re-signalled
individually via listenToMediaChanges, so the full state is still restored.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(core): clear stored push device even when deleteDevice API fails (#1705)

deleteDevice previously only purged the cached Device on API success. When
DELETE /devices returned 404 or the network failed, the stale token sat in
deviceTokenStorage. The next createDevice for a different user short-circuited
on token equality (when autoRegisterPushDevice=true) and returned Success
without calling POST /devices, silently leaving the new user without a device
row server-side and breaking incoming-call push.

Local cleanup now runs unconditionally and is guarded so a storage failure
doesn't mask the API outcome. CancellationException is re-thrown to preserve
structured concurrency.

Three regression tests added.

AND-1214

* [skip ci] Update SDK sizes

* fix(core): make apiCall wait for guestUserJob to avoid anonymous push device registration (#1703)

Guest user setup runs asynchronously: StreamVideoBuilder.build returns immediately
while setupGuestUser kicks off a background createGuest call to fetch the JWT.
Any authenticated request that fires in that window goes out with stream-auth-type
"anonymous" and no Authorization header, so the backend silently registers it
against the wrong identity. The customer-visible effect is push device
registration succeeding under !anon and incoming-call pushes never reaching
the guest user.

apiCall now awaits guestUserJob before invoking the request block, with a
self-job guard so createGuestUser — which also goes through apiCall — does not
await its own enclosing job and deadlock.

Adds two regression tests: one for the wait, one for the deadlock guard.

AND-1202

Co-authored-by: Rahul Kumar Lohra <tgunix@gmail.com>

* [skip ci] Update SDK sizes

* Adopt createGuest's server-issued identity for guest users (#1704)

* fix(core): adopt response.user from createGuest to keep guest identity in sync

The createGuest endpoint returns the server-resolved user (which may differ
from what was passed in — e.g. normalized id). The SDK previously kept only
the access token and left its in-memory user as the builder's input, so the
WS auth payload and the JWT user_id claim could disagree.

setupGuestUser now also updates client.user from response.user (matching the
JS SDK's connectUser(response.user, response.access_token) semantics).
userId becomes a computed property so every existing reader of client.userId
picks up the new identity automatically. CoordinatorSocketConnection.user
turns into a var so its onCreated() auth payload reads the latest user.

Adds three regression tests: userId reactivity, the var update inside the
socket connection's connect path, and a full setupGuestUser flow with the
api mocked to return a different user id than the input.

AND-1202

* fix(core): mirror adopted guest user into ClientState.user

ClientState._user was snapshotted from the integrator-supplied user at
construction, so observers of state.user kept the old id after
setupGuestUser adopted the server-issued one. Propagate the adopted
user via a new internal ClientState.setUser.

* [skip ci] Update SDK sizes

* Introduce UserRepository as single source of truth for SDK user (#1708)

* refactor(core): introduce UserRepository as single source of truth for SDK user

Replaces the parallel `var user` fields in `StreamVideoClient` and
`CoordinatorSocketConnection` (and the `_user` mirror in `ClientState`) with
a single `UserRepository`:

- `UserRepository` — public, read-only access via `user` / `userFlow`.
- `WritableUserRepository` — internal sub-interface with `setUser`. Only
  `StreamVideoClient` holds a write reference, so identity updates go through
  one path.
- `StreamUserRepositoryImpl` — in-memory impl backed by a `MutableStateFlow`.

`StreamVideoBuilder` constructs one instance and shares it between the client
(writer) and the coordinator socket / `ClientState` (readers). `setupGuestUser`
writes the adopted user to the repo once; readers pick it up automatically
without any local copy to keep in sync. `connect()`/`reconnect()` no longer
mutate a snapshot of the user on the socket — they only forward the call to
`internalSocket`, and `onCreated()` reads from the repository when building
the WS auth payload.

* test(core): add direct unit tests for StreamUserRepositoryImpl

Covers seed-from-constructor, user/userFlow reads, setUser write, emission
to active StateFlow collectors, and replacement semantics. Lifts coverage
on the new repository from indirect-only (via StreamVideoClient tests) to
full coverage of the impl.

* Auto-connect and register push device for guest users (#1707)

* feat(core): auto-connect and register push device for guest users

StreamVideoBuilder previously only ran the auto-register-push and
auto-connect block for UserType.Authenticated. Guest users fell through,
forcing every Guest integrator to write the same boilerplate (manual
registerPushDevice + connect after build) — boilerplate the iOS and JS
SDKs don't require.

Widen the gate to include UserType.Guest. registerPushDevice() and
connectAsync() inside StreamVideoClient already await guestUserJob, so
both are safe to fire from the builder block before /video/guest
completes. Anonymous users still don't have an identity to register a
device against, so they remain excluded.

AND-1202

* fix(core): wait for guestUserJob before registering push device

StreamNotificationManager.createDevice() goes straight to api.createDevice()
without the apiCall {} wrapper, so the guestUserJob await guard added in #1703
doesn't cover it. registerPushDevice() now waits for guest setup itself before
delegating, so the push generator can't fire createDevice() before the
coordinator's auth headers flip from anonymous to JWT.

* Include internal audio switch to fix concurrency issue (#1710)

* fix: include internal audio switch to fix concurrency issue

* fix: include aar

* chore(core): deprecate StreamVideo.logOut (#1709)

The KDoc claimed `logOut` clears internal user state, removes push
notification devices, and clears call state. The actual implementation
only writes null to the local DeviceTokenStorage — no `DELETE /devices`,
no socket disconnect, no in-memory clear.

The name and the historical doc invite a customer to ship broken
user-switching: anyone reading the API surface would reasonably assume a
clean slate. Surfaced while diagnosing a customer integration where push
delivery silently failed across user transitions.

Annotate the interface declaration and the StreamVideoClient override
with `@Deprecated`. Update the KDoc to describe current behavior
accurately. Point `ReplaceWith` at `StreamVideo.removeClient()`, which
triggers a real `cleanup()` and uninstalls the singleton. Customers who
need to remove the server-side device row should call `deleteDevice()`
before `removeClient()`.

No binary signature change — `@Deprecated` is annotation-only, so the
public `.api` file is unchanged.

AND-1217

Co-authored-by: Rahul Kumar Lohra <tgunix@gmail.com>

* fix(core): prevent DevicePreferences ClassCastException in updateDevice (#1711)

Assign the DataStore.updateData() result to a local in
DeviceTokenStorage.updateUserDevice so the suspend function is compiled
as a state machine that returns Unit. Without it the compiler
tail-call-optimizes the call and propagates the DevicePreferences result
up the updateDevice suspend chain, which can surface as
"DevicePreferences cannot be cast to kotlin.Unit" at the caller once R8
inlines the chain.

Co-authored-by: Cursor <cursoragent@cursor.com>

* AUTOMATION: Version Bump

* fix(core): stop coordinator events from clobbering SFU participant count (#1712)

Post-join, the SFU healthcheck delivers the authoritative participant count.
Coordinator session events (participant_joined/left, counts_updated, anything
carrying a CallSessionResponse) carry a smaller, stale snapshot that disagrees
at scale. The previous guard checked only !is RealtimeConnection.Joined — a
transient state immediately replaced by Connected — so every coordinator
session event re-wrote the count, producing wild swings during livestreams
(e.g. 25k -> 32k -> 42k -> 28k in seconds).

Broaden the guard to cover the entire in-call lifetime (Joined, Connected,
Reconnecting, Migrating). Pre-join, the session-derived path now uses
max(byRoleCount, participants.size) for monotonicity during fast joins,
matching the stream-video-js SDK.

AND-926

* Prevent "MediaSource has been disposed" crash when leaving a call (#1718)

* fix(core): prevent "MediaSource has been disposed" crash on leave

Guards the lazy audio/video source and track creation/disposal in
MediaManagerImpl with a single reentrant lock, and adds a terminal
`released` flag so the mic/camera mute paths no-op after cleanup instead
of lazily resurrecting native objects.

The crash occurred when a call was left while the first AudioSwitch setup
was still in flight: cleanup() disposed the audio source on one thread
while the deferred mic-disable callback recreated the audio track from
that disposed source on stream-audio-thread.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(core): stub runOnAudioTrackIfAvailable in MicrophoneManager tests

enable()/disable() now route the track toggle through
mediaManager.runOnAudioTrackIfAvailable instead of the audioTrack getter,
so the test helper stubs the new helper to invoke its block with the mock
track. Fixes the 5 failing MicrophoneManagerTest verifications.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* [skip ci] Update SDK sizes

* Revert audio switch local AAR (#1710) and reset version to allow re-release of 1.26.0 (#1719)

* Revert " Include internal audio switch to fix concurrency issue (#1710)"

This reverts commit 9a8da36.

* chore(release): reset version to 1.25.0 to allow re-release of 1.26.0

The 1.26.0 Maven publish failed due to the local AAR introduced in #1710.
Resetting gradle.properties to 1.25.0 so the release workflow can re-tag
and publish 1.26.0 cleanly from the reverted develop state.

* AUTOMATION: Version Bump

* fix(core): set small icon on setting-up-call notification (#1720)

The "setting up call" foreground-service notification was built without a
small icon for any non-incoming trigger (outgoing/ongoing/livestream). The
non-deprecated getSettingUpCallNotification(trigger, callId) delegated its
else branch to the deprecated no-arg overload, which never called
setSmallIcon. A small icon is mandatory for foreground-service
notifications, so Android 13+ rejected it with
CannotPostForegroundServiceNotificationException ("Bad notification for
startForeground") when the call foreground service started.

Extract a non-deprecated buildSettingUpCallNotification() helper that always
sets setSmallIcon(R.drawable.stream_video_ic_call), and have both the
non-deprecated else branch and the deprecated overload delegate to it. Add a
regression test covering the non-incoming (outgoing) trigger path.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Update open api generated models in Video SDK(1/4) (#1713)

* update open api generated models

* update code gen script

* Fix self cancelling coroutine code (2/4) (#1714)

* update open api generated models

* update code gen script

* fix: self cancelling coroutine code

* fix: fix self cancelling coroutine code

* Send standardized call leave reason instead of string when leaving the call  (3/4) (#1715)

* update open api generated models

* update code gen script

* fix: self cancelling coroutine code

* internal: add call leave reason

* internal: update Call Leave Reason LLC

* fix: fix unit tests

* 📝 CodeRabbit Chat: Implement requested code changes

* Update stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallLeaveReason.kt

* chore: send correct leave reason from StreamCallActivity.kt

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Aleksandar Apostolov <apostolov.alexandar@gmail.com>

* [skip ci] Update SDK sizes

* Add Video Analytics in call-join flow (#1716)

* update open api generated models

* internal: add call leave reason

* internal: Add analytics

* internal: renaming

* internal: spotless

* chore: fix tests, remove logs

* chore: fix retry exception

* chore: fix binary compatibility

* chore: add capping to analytics file

* chore: use isolate coroutine scope to interact with data source

* chore: renaming

* chore: renaming

* chore: fix paparazzi tests

* chore: write analytics tests

* chore: fix tests

* chore: Enable Audio Analytics

* chore: update audio analytics unit-test

* chore: update PeerConnectionAnalyticsState

* chore: add temp commit

* fix: fix incorrect events

* fix: fix incorrect events in pc and video

* fix: fix pc publish analytics

* fix: remove persistance and audio analytics, update tests

fix: remove persistance and audio analytics

* fix: fix error code and reason

* fix: fix tests

* fix: remove comments

* fix: spotless

* fix: Correctly send sfu ws terminal

* fix: spotless

* fix: spotless

* fix: fix failed permission

* fix: fix failed permission

* fix: add missing class

* fix: run only if user id is not null

* fix: fix tests

* AUTOMATION: Version Bump

* [skip ci] Update SDK sizes

* Do not show error message while video is loading (#1721)

* fix(compose): show loading spinner in CallLobby while camera warms up

Replace the misleading track-failure fallback with a neutral loading
placeholder and overlay it in CallLobby until the first video frame
renders. Removes the redundant demo-app workaround.

Fixes #1656

Co-authored-by: Cursor <cursoragent@cursor.com>

* use DefaultBadNetworkFallbackContent with a CircularProgressBar instead of Text

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(ui-compose): add participantLabelContent slot to CallLobby (#1726)

The previous CallLobby rendered the lobby participant label
unconditionally with no way to hide or override it. iOS and React do
not render a participant label on the local preview, so Android was
the outlier and customers had no escape hatch.

Add a participantLabelContent slot (matches React VideoPreview's
slot-based overrides). Pass `{}` to hide the label or override to
supply custom content and positioning. The previous overload
(labelPosition: Alignment) is deprecated and delegates to the new one.

* [skip ci] Update SDK sizes

* Chore(deps): Bump actions/checkout from 6 to 7 (#1728)

Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Move @Preview composables out of published artifact to fix R8 builds (#1730)

* fix(compose): move @Preview composables out of published artifact to fix R8 builds

Compose-generated ComposableSingletons classes referenced the compileOnly
stream-video-android-previewdata module (StreamPreviewDataUtils, previewCall,
etc.) from production code, breaking consumers' minified (R8) release builds
with "Missing class" errors.

- Move all @Preview composables from src/main into a new debug-only source set
  (one <Name>Preview.kt per file), so preview-data is never referenced from the
  release artifact.
- Change previewdata dependency from compileOnly to debugImplementation.
- Declare kotlinx-datetime explicitly (used by the livestream countdown UI) so
  it no longer leaks in transitively, fixing the related kotlinx.datetime R8
  missing-class errors.

Fixes #1448

Co-authored-by: Cursor <cursoragent@cursor.com>

* api dump

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* [skip ci] Update SDK sizes

* Fix `runCallServiceInForeground` handling for incoming PN and non-ringing call flows (#1729)

* temp: force-commit

* fix: invoke updateRingingState() when setting active call

* fix: add canRunService on incoming calls

* demo-app: add screen to configure call settings

* fix: refactor

* fix: refactor

* fix: remove unused classes

* fix: fix unit-test

* Improve video analytics by correctly sending ice state (#1724)

* improve: fix sending correct ice state

* improve: correctly update analytics stage

* improve: refactor

* fix: remove grace period to wait for ice state

* fix: refactor

* fix: refactor

---------

Co-authored-by: Aleksandar Apostolov <apostolov.alexandar@gmail.com>

* Code refactor for Video Analytics  (#1727)

* improvement: video analytics refactor

* fix: fix unit test

* Fix a crash when application put to background restriction (#1731)

* temp: force-commit

* fix: refactor

* fix: remove unused classes

* fix: Fix a crash when application put to background restriction by rendering normal notification instead of CallStyle notification

* fix: add safe null-check

* fix: moved file to utils

* [skip ci] Update SDK sizes

* Fix notification dismissal for Leave/Hang Up notification actions (#1732)

* temp: force-commit

* fix: invoke updateRingingState() when setting active call

* fix: add canRunService on incoming calls

* demo-app: add screen to configure call settings

* fix: refactor

* fix: refactor

* fix: remove unused classes

* fix: Fix a crash when application put to background restriction by rendering normal notification instead of CallStyle notification

* fix: add safe null-check

* fix: fix unit-test

* fix: fix notification cleaning from leave button in notification

* fix: add todos for future fixes

* feat(ui-compose): add videoPreviewModifier slot to CallLobby (#1733)

The lobby video preview Box was hardcoded to a responsive height
(180/280/200dp by screen size and orientation), fillMaxWidth, and a 12dp
rounded corner clip with no way to override size or shape. iOS sizes the
preview via the parent's GeometryReader; React drives size via
className on VideoPreview. Android was the outlier.

Add a videoPreviewModifier parameter so callers can override the box
modifier directly. The default preserves the previous responsive height,
full width, and 12dp clip, so existing callers see no change.

* AUTOMATION: Version Bump

* fix: fix tracer timestamp and remove additional lock (#1734)

* Report SFU WebSocket connection and join timeouts accurately (#1740)

* fix(core): report SFU WebSocket connection/join timeouts accurately

Splits the SFU socket connect into a transport-open phase (bounded by
OkHttp's connect timeout) and a distinct join-response phase (bounded by
a dedicated timer via a new WebSocketConnected state), so a silent SFU no
longer hangs the join. Surfaces real transport failure messages and HTTP
status codes on the resulting NetworkError, routes all recoverable errors
through a single DisconnectedTemporarily state carrying the exact
code/reason, and maps both timeout flavours to REQUEST_TIMEOUT in analytics
(join-response via error code, transport via SocketTimeoutException cause).

Also moves join-error analytics to the join flow only, adds a per-session
SFU WS retry counter, and wires connectionTimeoutInMs from the builder to
both the OkHttp client and the join-response deadline.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove !! operator on session.value and send a proper Failure when session.value was cleared to null while connecting to sfu was still in progress

* Update comment

* fix(core): address PR review comments for SFU WS timeout handling

- Rename SfuSocketStateEvent.WebSocketConnected to WebSocketEstablished to
  avoid the naming collision with the SfuSocketState.WebSocketConnected state.
- Guard monitorSession() in _join recovery so it only runs when the original
  session is reused, preventing double-registration after rejoin/migrate.
- Default connectionTimeoutInMs to 5s and fix stale KDoc.
- Rename triggersReconnect() to canTriggersReconnect() for clarity.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(core): classify OkHttp InterruptedIOException timeouts as REQUEST_TIMEOUT

OkHttp connect/call timeouts surface as InterruptedIOException("timeout"),
not SocketTimeoutException, so SFU join analytics were incorrectly reporting
SFU_ERROR despite the failure reason being "timeout".

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(core): add safety timeout to connectInternal SFU socket wait

Wrap the wait for a terminal SfuSocketState in withTimeoutOrNull so
connectInternal always returns even if the socket state machine never
reaches Connected or Disconnected. On timeout, return a recoverable
Failure with REQUEST_TIMEOUT so higher-level reconnect logic can escalate.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rahul Kumar Lohra <tgunix@gmail.com>

* [skip ci] Update SDK sizes

* CI: add least-privilege permissions to GitHub Actions workflows (#1742)

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* CI: add least-privilege permissions to GitHub Actions workflows

Add explicit workflow-level permissions blocks to resolve CodeQL
actions/missing-workflow-permissions alerts. Scopes are derived from
workflow operations (git push, artifact upload, Danger, release lanes).

Refs: APPSEC-164

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: move workflow permissions to job level (SonarCloud)

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: address CodeRabbit review feedback and fix workflow YAML

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* ci: repair workflow permissions blocks for actionlint/SonarCloud

* e2e: Port the chat test harness improvements: retry visibility, video on retries only (#1746)

- RetryRule: each failed attempt that is retried is written as its own Allure result sharing the real test's historyId, with that attempt's steps, error and artifacts, so TestOps groups attempts as retries and can flag flaky tests
- RetryRule: screen recording runs only on retry attempts; previously every test recorded an 8 Mbit/s video that was discarded on pass
- RetryRule: the recording file uses methodName instead of displayName (the display name's parentheses break when the recording commands go through the device shell), and a recording stop failure no longer replaces the test result
- RetryRule: move to io.getstream.video.android.rules (the file declared a chat package), remove the unused Retry annotation, simplify DatabaseOperations
- Wait: waitForText and waitForCount poll at 50ms instead of spinning the CPU until timeout; remove the unused waitForTextToChange
- Allurefile: name launches 'Cron checks' only for real scheduled events, so manual dispatches stay distinguishable in TestOps
- Fastfile: batch_tests splits round-robin, always yielding exactly batch_count groups; the previous rounded-up slicing could produce fewer groups and crash on nil for the last batch

Ported from GetStream/stream-chat-android#6568.

* Recover initial join when connect safety-timeout leaves no reconnect (#1741)

* fix(core): recover initial join when connect safety-timeout leaves no reconnect

When RtcSession.connectInternal hits its own safety-timeout, the socket is
left in a non-terminal state that stateJob ignores, so no Call.reconnect is
ever launched and _join's didReconnectSucceed() would block forever.

- Add SfuConnectionResult.Failure.reconnectTriggered so the join flow can tell
  whether a recovery loop has already been started by stateJob.
- On the safety-timeout path, tear down the abandoned (still-in-flight) socket
  so a late Connected/JoinResponse can't resurface and resurrect the session.
- In Call._join, trigger a REJOIN ourselves when a recoverable failure reports
  no reconnect was triggered; otherwise await the existing loop.
- Rename canTriggersReconnect() to triggersStateJobReconnect() for clarity.
- demo-app: align connectionTimeoutInMs with the SDK default (5s).

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(core): type SFU connect failures instead of a reconnect flag

Address PR review: reconnect-orchestration state should not live on the
SfuConnectionResult.Failure DTO. Replace the boolean flag with a typed error
so callers of connectInternal branch on the failure kind.

- Add sealed SfuConnectException with Timeout (connect safety-timeout tore down
  a stuck socket; no recovery started) and Disconnected (terminal SFU state).
- connectInternal now returns these typed errors instead of Exception("msg").
- Drop SfuConnectionResult.Failure.hasReconnectStarted; _join self-triggers a
  REJOIN only when the error is SfuConnectException.Timeout, else awaits the
  loop stateJob already started.
- Update tests to construct/assert the typed errors.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Replace SfuConnectException with SfuConnectFailureCause code (#1744)

* fix: Refactor with SfuConnectFailureCause code

* fix: Add kdoc

* fix: Replace Singletone shared Call.testInstanceProvider.rtcSessionCreator with  Call..unitTestRtcSessionFactory

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Aleksandar Apostolov <apostolov.alexandar@gmail.com>
Co-authored-by: Rahul Kumar Lohra <tgunix@gmail.com>

* AUTOMATION: Version Bump

* test(e2e): harden recording and outgoing-call waits against backend latency (#1751)

* test(e2e): harden recording and outgoing-call waits against backend latency

The composite recorder can take 20-30s to start and emit call.recording_started,
and the outgoing ringing screen only renders after the call create/ring network
round-trip. The 5s default (recording icon) and 10s (outgoing decline button)
waits time out before the UI appears, causing deterministic findObject NPEs in
testParticipantRecordsCall and testUserRejectsTheOutgoingAudioCall.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): extend recording-consent and stop waits to 30s

The recording-consent dialog (acceptCallRecording/declineCallRecording) and the
recording-icon disappear assert are all gated on backend recorder events
(call.recording_started / call.recording_stopped), which can take 20-30s. The
10s/5s waits time out first, so testParticipantRecordsCall still failed at
acceptCallRecording after the initial icon-wait bump. Extend these waits to 30s.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(e2e): record for 60s so the recording icon is observable

Composite recording spins up ~10-15s after the request, so recording for only
15s left a ~5s client-visible window that closed before the 3-view assertion
loop could observe the icon. Record for 60s and keep the buddy in the call for
120s (matching the ReconnectionTests pattern) so the icon is reliably visible,
then extend the "recording disappeared" wait to 70s to cover the longer run.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore: pass OpenAPI generator config via --opt flags (#1743)

* chore: pass OpenAPI generator config via --opt flags

The chat-side Kotlin generator now takes its configuration through the generic
`--opt key=value` flag (via the Configurable interface) instead of bespoke named
flags. Update the generation script accordingly:

- Convert the generate-client invocation to `--opt key=value` form.
- Drop `--model-dir` (the generator hardcodes the models directory; the flag was
  a no-op) and its now-unused MODEL_DIR variable / argument.
- classes-to-skip is space-separated (the --opt slice flag splits comma lists).
- Point REFERENCE_VALUE at `master`, since the generator changes land there.

Generated output is unchanged (byte-identical vs the previous generator).

* fix: use the renamed android-sdk generator option

The backend generator's SDK option was renamed androidSdk -> android-sdk during
review before landing on master. Update the invocation to match, otherwise
generate-client fails with `kotlin: unknown option "androidSdk"`.

* chore: drop retired --model-dir from the openapi generator gradle task

generate_openapi_v2.sh no longer accepts --model-dir (the backend generator
hardcodes the models directory and never honored the flag), so the Gradle
caller passing it would fail with "Unknown argument". Remove the argument and
the now-unused modelsDir property.

* fix: default the openapi generator to clone master

The Gradle task's default refValue still pointed at the merged (now-deleted)
feature branch, so `generateOpenApiClient` would fail at `git clone --branch`.
Default to master, matching the script's own default and the intended source.

* Chore(deps): Bump actions/checkout from 7.0.0 to 7.0.1 (#1748)

Bumps [actions/checkout](https://github.com/actions/checkout) from 7.0.0 to 7.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](actions/checkout@9c091bb...3d3c42e)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Gianmarco <47775302+gpunto@users.noreply.github.com>

* fix(core): stop orphaned publisher transceivers to prevent SFU rejoin loop (#1757)

Three transceiver-lifecycle bugs in Publisher could strand live sendonly
m-lines that SetPublisher.tracks never announces, causing the SFU to
force-rejoin the publisher (VID-1376):

- syncPublishOptions: the cleanup loop compared the cache against itself
  (always true), so every transceiver was torn down on each
  ChangePublishOptions event. Now keep transceivers whose option is still
  requested by the SFU.
- publishStreamInternal fallback: replaced the old transceiver without
  stopping it, leaving an orphaned m-line. Now stop() the old one first.
- addTransceiver: silently overwrote the cache entry for the same
  [track_type, publish_option_id], stranding the old transceiver. Now
  stop() any pre-existing transceiver before adding.

All paths use stop() only (never dispose()) on a live PeerConnection; the
PC owns the native transceiver/sender and frees it safely at teardown.
Disposing mid-session is a use-after-free on network_thread (SIGSEGV).

Adds unit tests covering all three cases.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(core): resolve develop merge compile/runtime conflicts for v2

Keep UserRepository + StreamConnectionState + StreamClient as v2 SSOT while
adopting develop analytics/degradation-preference changes under io.getstream.webrtc.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(ui-compose): refresh API dump after develop merge

Co-authored-by: Cursor <cursoragent@cursor.com>

* Decompose Call into focused internal components (#1747)

* refactor(core): decompose Call into focused internal components

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>

* fix(core): remove duplicated join preflight from Call facade

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>

* test(core): add unit tests for Call decomposition components

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>

* test(core): add unit tests for join/reconnect/connectivity/ice components

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>

* test(core): cover reconnector rejoin/migrate and renderer audio paths

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>

* refactor(core): decouple Call collaborators from the Call facade

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>

* refactor(core): finish decoupling Call collaborators and repair the suite

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>

* refactor(core): read session state from its owner instead of the Call 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>

* refactor(core): break the reconnector/join-coordinator dependency cycle

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>

* chore: remove unused code (#1762)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Rahul Kumar Lohra <tgunix@gmail.com>

* [skip ci] Update SDK sizes

* Make the Android Video SDK compatible with Android 17. (#1752)

* Chore: make incoming call android 17 compatible

* Chore: revert

* Chore: refactor name

* refactor: remove maxSize named argument for AGP 9 compatibility

* refactor: remove unnecessary inline arg

* update: Update streamLog to 1.3.4 which fixes namespace issue which is returned as error in AGP 9

* test: cover incoming-ring service type on Android 15/16/17

Move VERSION_CODES polyfills to shared AndroidVersionCodes.kt (fix BALAKLAVA typo).
Force SDK_INT for API 35/36/37 tests and restore it via @after teardown.

* AUTOMATION: Version Bump

* test(e2e): widen call-start wait to 30s for reconnection/recording flow

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Aleksandar Apostolov <apostolov.alexandar@gmail.com>
Co-authored-by: Rahul Kumar Lohra <tgunix@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Peter Matkovski <peter.matkovski@getstream.io>
Co-authored-by: André Mion <andremion@gmail.com>
Co-authored-by: Gianmarco <47775302+gpunto@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:internal Internal or infra-only changes released Included in a release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants