From fea7a6e85ad85912af6a69a147f7b6d42c153570 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 13:04:10 +0530 Subject: [PATCH 01/14] feat: Add lifecycle methods to call join interceptor --- .../video/android/DemoCallJoinInterceptor.kt | 20 +++- .../api/stream-video-android-core.api | 6 ++ .../video/android/core/ActiveStateGate.kt | 7 +- .../io/getstream/video/android/core/Call.kt | 18 ++++ .../video/android/core/CallJoinInterceptor.kt | 3 +- .../CallJoinLifecycleInterceptor.kt | 25 +++++ .../LegacyCallJoinInterceptorAdapter.kt | 33 +++++++ .../call/connection/stats/StatsTracerTest.kt | 94 +++++++++++++++++++ 8 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt index 3c1ab350ffa..4db5d8561d9 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt @@ -19,10 +19,14 @@ package io.getstream.video.android import io.getstream.log.taggedLogger import io.getstream.video.android.CallActivity.Companion.USE_CALL_JOIN_INTERCEPTOR import io.getstream.video.android.core.Call -import io.getstream.video.android.core.CallJoinInterceptor import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch /** * Do the following changes before testing this flow @@ -31,13 +35,20 @@ import kotlinx.coroutines.flow.first */ class DemoCallJoinInterceptor( private val previousRingingStates: Set, -) : CallJoinInterceptor { +) : CallJoinLifecycleInterceptor { private val logger by taggedLogger("DemoCallJoinInterceptor") companion object { const val CALLER_READY_TO_JOIN_EVENT_TYPE = "caller_ready_join" const val CALLEE_READY_TO_JOIN_EVENT_TYPE = "callee_ready_join" } + var observeJob: Job? = null + val observerScope = CoroutineScope(Dispatchers.Default) + override suspend fun callWillJoin(call: Call) { + observeJob = observerScope.launch { + call.setIncomingAudioMuted(false) + } + } override suspend fun callReadyToJoin(call: Call) { if (USE_CALL_JOIN_INTERCEPTOR) { @@ -81,4 +92,9 @@ class DemoCallJoinInterceptor( } } } + + override suspend fun callDidJoin(call: Call) { + observeJob?.cancel() + call.setIncomingAudioMuted(true) + } } diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 1ef85740a88..d74f85b041a 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8928,6 +8928,7 @@ public final class io/getstream/video/android/core/Call { public final fun setAudioProcessingEnabled (Z)V public final fun setIncomingAudioEnabled (ZLjava/util/List;)V public static synthetic fun setIncomingAudioEnabled$default (Lio/getstream/video/android/core/Call;ZLjava/util/List;ILjava/lang/Object;)V + public final fun setIncomingAudioMuted (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setIncomingVideoEnabled (Ljava/lang/Boolean;Ljava/util/List;)V public static synthetic fun setIncomingVideoEnabled$default (Lio/getstream/video/android/core/Call;Ljava/lang/Boolean;Ljava/util/List;ILjava/lang/Object;)V public final fun setPreferredIncomingVideoResolution (Lio/getstream/video/android/core/model/PreferredVideoResolution;Ljava/util/List;)V @@ -10070,6 +10071,11 @@ public final class io/getstream/video/android/core/call/connection/StreamPeerCon public final fun toggleAudioProcessing ()Z } +public abstract interface class io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor : io/getstream/video/android/core/CallJoinInterceptor { + public abstract fun callDidJoin (Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun callWillJoin (Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class io/getstream/video/android/core/call/signal/socket/RTCEventMapper { public static final field INSTANCE Lio/getstream/video/android/core/call/signal/socket/RTCEventMapper; public final fun mapEvent (Lstream/video/sfu/event/SfuEvent;)Lio/getstream/video/android/core/events/SfuDataEvent; diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt index 403ad040e27..bd6d8095656 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt @@ -18,6 +18,7 @@ package io.getstream.video.android.core import androidx.lifecycle.AtomicReference import io.getstream.log.taggedLogger +import io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -33,7 +34,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.webrtc.PeerConnection.PeerConnectionState private const val PEER_CONNECTION_OBSERVER_TIMEOUT = 5_000L -private const val INTERCEPTOR_TIMEOUT_MS = 5_000L +private const val INTERCEPTOR_TIMEOUT_MS = 8_000L internal class ActiveStateGate( private val coroutineScope: CoroutineScope, @@ -142,6 +143,10 @@ internal class ActiveStateGate( withTimeoutOrNull(interceptorTimeoutMs) { interceptor.callReadyToJoin(call) } + + if (interceptor is CallJoinLifecycleInterceptor) { + interceptor.callDidJoin(call) + } logger.d { "[invokeInterceptor] finish at ${(System.currentTimeMillis() - startTime) / 1000}s " } true } catch (e: CancellationException) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 5243867ef84..5f09b24678b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -71,6 +71,7 @@ import io.getstream.video.android.core.call.SfuConnectionResult import io.getstream.video.android.core.call.audio.InputAudioFilter import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.getstream.video.android.core.call.connection.Subscriber +import io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor import io.getstream.video.android.core.call.scope.ScopeProvider import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.call.utils.SoundInputProcessor @@ -112,6 +113,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -624,6 +626,11 @@ public class Call( "is joined. MediaManager will not be initialised with server settings." } } + + if (callJoinInterceptor is CallJoinLifecycleInterceptor) { + callJoinInterceptor.callReadyToJoin(this) + } + return result } if (result is Failure) { @@ -1447,6 +1454,17 @@ public class Call( return clientImpl.muteUsers(type, id, request) } + suspend fun setIncomingAudioMuted(audio: Boolean) { + val subscriber = session.filterNotNull() + .flatMapLatest { it.subscriber.filterNotNull() } + .first() // wait until a subscriber exists, take it once + + val volume = if (audio) 1.0 else 0.0 // gain range is 0..10; 0 = silence + subscriber.tracks.values + .mapNotNull { it[TrackType.TRACK_TYPE_AUDIO] as? AudioTrack } + .forEach { it.audio.setVolume(volume) } // org.webrtc.AudioTrack.setVolume(double) + } + fun setVisibility( sessionId: String, trackType: TrackType, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt index 3b873923309..22af9c66aee 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt @@ -25,6 +25,7 @@ import kotlin.jvm.Throws * the publisher peer connection becoming ready and the call going active. * Has no effect on non-ringing joins (livestream, direct join). */ +@Deprecated("Use CallJoinLifecycleInterceptor instead") public interface CallJoinInterceptor { /** @@ -34,7 +35,7 @@ public interface CallJoinInterceptor { * Throw [CallJoinInterceptionException] to abort the join — the SDK will leave * the call cleanly * - * The SDK enforces a 5-second maximum — the transition proceeds automatically on timeout. + * The SDK enforces a 5-second maximum [INTERCEPTOR_TIMEOUT_MS] — the transition proceeds automatically on timeout. */ @Throws(CallJoinInterceptionException::class) public suspend fun callReadyToJoin(call: Call) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt new file mode 100644 index 00000000000..56f0d39f745 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.interceptor + +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallJoinInterceptor + +public interface CallJoinLifecycleInterceptor : CallJoinInterceptor { + public suspend fun callWillJoin(call: Call) + public suspend fun callDidJoin(call: Call) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt new file mode 100644 index 00000000000..6188e4eace7 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.interceptor + +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallJoinInterceptor + +internal class LegacyCallJoinInterceptorAdapter( + private val interceptor: CallJoinInterceptor, +) : CallJoinLifecycleInterceptor { + + override suspend fun callWillJoin(call: Call) = Unit + + override suspend fun callReadyToJoin(call: Call) = Unit + + override suspend fun callDidJoin(call: Call) { + interceptor.callReadyToJoin(call) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt index d979389d2e8..c3d3d54928d 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt @@ -23,6 +23,7 @@ import io.mockk.unmockkAll import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -33,6 +34,7 @@ import org.webrtc.RTCStatsCollectorCallback import org.webrtc.RTCStatsReport import stream.video.sfu.models.PeerType import stream.video.sfu.models.TrackType +import java.math.BigInteger class StatsTracerTest { private lateinit var peerConnection: PeerConnection @@ -125,4 +127,96 @@ class StatsTracerTest { assertNotNull(result.delta) assertEquals(report, result.stats) } + + @Test + fun `get polls inbound audio rtp stats for subscriber`() = runBlocking { + val inboundAudioStat = inboundAudioStat( + id = "inbound-audio-1", + trackIdentifier = "audio-track-1", + packetsReceived = 5, + bytesReceived = BigInteger.valueOf(1000), + totalAudioEnergy = 0.4, + totalSamplesReceived = BigInteger.valueOf(480), + silentConcealedSamples = BigInteger.ZERO, + ) + val report = mockk { + every { statsMap } returns mapOf("inbound-audio-1" to inboundAudioStat) + } + every { peerConnection.getStats(any()) } answers { + val cb = arg(0) + cb.onStatsDelivered(report) + } + + tracer = StatsTracer(peerConnection, PeerType.PEER_TYPE_SUBSCRIBER) + val result = tracer.get( + trackIdToTrackType = mapOf("audio-track-1" to TrackType.TRACK_TYPE_AUDIO), + trackIdToParticipant = mapOf("audio-track-1" to "session-1"), + ) + + assertEquals(1, result.inboundAudioStats.size) + val audioStats = result.inboundAudioStats.first() + assertEquals("audio-track-1", audioStats.trackIdentifier) + assertEquals("session-1", audioStats.sessionId) + assertEquals(TrackType.TRACK_TYPE_AUDIO, audioStats.trackType) + assertEquals(5, audioStats.deltaPacketsReceived) + assertEquals(BigInteger.valueOf(1000), audioStats.deltaBytesReceived) + assertEquals(BigInteger.valueOf(480), audioStats.deltaTotalSamplesReceived) + assertTrue(audioStats.isReceivingRealAudio) + } + + @Test + fun `get does not mark silent concealed inbound audio as real audio`() = runBlocking { + val inboundAudioStat = inboundAudioStat( + id = "inbound-audio-1", + trackIdentifier = "audio-track-1", + packetsReceived = 5, + bytesReceived = BigInteger.valueOf(1000), + totalAudioEnergy = 0.0, + totalSamplesReceived = BigInteger.valueOf(480), + silentConcealedSamples = BigInteger.valueOf(480), + ) + val report = mockk { + every { statsMap } returns mapOf("inbound-audio-1" to inboundAudioStat) + } + every { peerConnection.getStats(any()) } answers { + val cb = arg(0) + cb.onStatsDelivered(report) + } + + tracer = StatsTracer(peerConnection, PeerType.PEER_TYPE_SUBSCRIBER) + val result = tracer.get( + trackIdToTrackType = mapOf("audio-track-1" to TrackType.TRACK_TYPE_AUDIO), + trackIdToParticipant = mapOf("audio-track-1" to "session-1"), + ) + + assertFalse(result.inboundAudioStats.first().isReceivingRealAudio) + } + + private fun inboundAudioStat( + id: String, + trackIdentifier: String, + packetsReceived: Long, + bytesReceived: BigInteger, + totalAudioEnergy: Double, + totalSamplesReceived: BigInteger, + silentConcealedSamples: BigInteger, + ): RTCStats { + val members = mapOf( + "kind" to "audio", + "trackIdentifier" to trackIdentifier, + "packetsReceived" to packetsReceived, + "bytesReceived" to bytesReceived, + "audioLevel" to 0.2, + "totalAudioEnergy" to totalAudioEnergy, + "totalSamplesReceived" to totalSamplesReceived, + "concealedSamples" to BigInteger.ZERO, + "silentConcealedSamples" to silentConcealedSamples, + ) + return mockk(relaxed = true) { + every { this@mockk.id } returns id + every { type } returns "inbound-rtp" + every { timestampUs } returns 1_000.0 + every { this@mockk.members } returns members + } + } } From a335dd4c8964c90e709575008419b194286372eb Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 13:12:36 +0530 Subject: [PATCH 02/14] feat: remove LegacyCallJoinInterceptorAdapter --- .../LegacyCallJoinInterceptorAdapter.kt | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt deleted file mode 100644 index 6188e4eace7..00000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/LegacyCallJoinInterceptorAdapter.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-video-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.video.android.core.call.interceptor - -import io.getstream.video.android.core.Call -import io.getstream.video.android.core.CallJoinInterceptor - -internal class LegacyCallJoinInterceptorAdapter( - private val interceptor: CallJoinInterceptor, -) : CallJoinLifecycleInterceptor { - - override suspend fun callWillJoin(call: Call) = Unit - - override suspend fun callReadyToJoin(call: Call) = Unit - - override suspend fun callDidJoin(call: Call) { - interceptor.callReadyToJoin(call) - } -} From b68ebf3e50ac7582713c1bcfc20970784c54d707 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 13:13:44 +0530 Subject: [PATCH 03/14] feat: revert Stats Tracer Test --- .../call/connection/stats/StatsTracerTest.kt | 94 ------------------- 1 file changed, 94 deletions(-) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt index c3d3d54928d..d979389d2e8 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/connection/stats/StatsTracerTest.kt @@ -23,7 +23,6 @@ import io.mockk.unmockkAll import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -34,7 +33,6 @@ import org.webrtc.RTCStatsCollectorCallback import org.webrtc.RTCStatsReport import stream.video.sfu.models.PeerType import stream.video.sfu.models.TrackType -import java.math.BigInteger class StatsTracerTest { private lateinit var peerConnection: PeerConnection @@ -127,96 +125,4 @@ class StatsTracerTest { assertNotNull(result.delta) assertEquals(report, result.stats) } - - @Test - fun `get polls inbound audio rtp stats for subscriber`() = runBlocking { - val inboundAudioStat = inboundAudioStat( - id = "inbound-audio-1", - trackIdentifier = "audio-track-1", - packetsReceived = 5, - bytesReceived = BigInteger.valueOf(1000), - totalAudioEnergy = 0.4, - totalSamplesReceived = BigInteger.valueOf(480), - silentConcealedSamples = BigInteger.ZERO, - ) - val report = mockk { - every { statsMap } returns mapOf("inbound-audio-1" to inboundAudioStat) - } - every { peerConnection.getStats(any()) } answers { - val cb = arg(0) - cb.onStatsDelivered(report) - } - - tracer = StatsTracer(peerConnection, PeerType.PEER_TYPE_SUBSCRIBER) - val result = tracer.get( - trackIdToTrackType = mapOf("audio-track-1" to TrackType.TRACK_TYPE_AUDIO), - trackIdToParticipant = mapOf("audio-track-1" to "session-1"), - ) - - assertEquals(1, result.inboundAudioStats.size) - val audioStats = result.inboundAudioStats.first() - assertEquals("audio-track-1", audioStats.trackIdentifier) - assertEquals("session-1", audioStats.sessionId) - assertEquals(TrackType.TRACK_TYPE_AUDIO, audioStats.trackType) - assertEquals(5, audioStats.deltaPacketsReceived) - assertEquals(BigInteger.valueOf(1000), audioStats.deltaBytesReceived) - assertEquals(BigInteger.valueOf(480), audioStats.deltaTotalSamplesReceived) - assertTrue(audioStats.isReceivingRealAudio) - } - - @Test - fun `get does not mark silent concealed inbound audio as real audio`() = runBlocking { - val inboundAudioStat = inboundAudioStat( - id = "inbound-audio-1", - trackIdentifier = "audio-track-1", - packetsReceived = 5, - bytesReceived = BigInteger.valueOf(1000), - totalAudioEnergy = 0.0, - totalSamplesReceived = BigInteger.valueOf(480), - silentConcealedSamples = BigInteger.valueOf(480), - ) - val report = mockk { - every { statsMap } returns mapOf("inbound-audio-1" to inboundAudioStat) - } - every { peerConnection.getStats(any()) } answers { - val cb = arg(0) - cb.onStatsDelivered(report) - } - - tracer = StatsTracer(peerConnection, PeerType.PEER_TYPE_SUBSCRIBER) - val result = tracer.get( - trackIdToTrackType = mapOf("audio-track-1" to TrackType.TRACK_TYPE_AUDIO), - trackIdToParticipant = mapOf("audio-track-1" to "session-1"), - ) - - assertFalse(result.inboundAudioStats.first().isReceivingRealAudio) - } - - private fun inboundAudioStat( - id: String, - trackIdentifier: String, - packetsReceived: Long, - bytesReceived: BigInteger, - totalAudioEnergy: Double, - totalSamplesReceived: BigInteger, - silentConcealedSamples: BigInteger, - ): RTCStats { - val members = mapOf( - "kind" to "audio", - "trackIdentifier" to trackIdentifier, - "packetsReceived" to packetsReceived, - "bytesReceived" to bytesReceived, - "audioLevel" to 0.2, - "totalAudioEnergy" to totalAudioEnergy, - "totalSamplesReceived" to totalSamplesReceived, - "concealedSamples" to BigInteger.ZERO, - "silentConcealedSamples" to silentConcealedSamples, - ) - return mockk(relaxed = true) { - every { this@mockk.id } returns id - every { type } returns "inbound-rtp" - every { timestampUs } returns 1_000.0 - every { this@mockk.members } returns members - } - } } From 53ff5855faa2db42d08b4146ef44ebfbfb7f503c Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 13:23:38 +0530 Subject: [PATCH 04/14] fix: minor fixes --- .../kotlin/io/getstream/video/android/core/ActiveStateGate.kt | 2 +- .../src/main/kotlin/io/getstream/video/android/core/Call.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt index bd6d8095656..1a95d9f174f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt @@ -34,7 +34,7 @@ import kotlinx.coroutines.withTimeoutOrNull import org.webrtc.PeerConnection.PeerConnectionState private const val PEER_CONNECTION_OBSERVER_TIMEOUT = 5_000L -private const val INTERCEPTOR_TIMEOUT_MS = 8_000L +private const val INTERCEPTOR_TIMEOUT_MS = 5_000L internal class ActiveStateGate( private val coroutineScope: CoroutineScope, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 5f09b24678b..af789b0cfb9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -628,7 +628,7 @@ public class Call( } if (callJoinInterceptor is CallJoinLifecycleInterceptor) { - callJoinInterceptor.callReadyToJoin(this) + callJoinInterceptor.callWillJoin(this) } return result From 7172acb53ae26fe00d8bc737e807b10ea6e0b0fe Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 14:16:16 +0530 Subject: [PATCH 05/14] fix: 1. Remove public api to mute audio tracks 2. Update DemoCallJoinInterceptor --- .../video/android/DemoCallJoinInterceptor.kt | 12 ++++++++++-- .../api/stream-video-android-core.api | 1 - .../kotlin/io/getstream/video/android/core/Call.kt | 11 ----------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt index 4db5d8561d9..c9d1dcdb71b 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt @@ -46,7 +46,12 @@ class DemoCallJoinInterceptor( val observerScope = CoroutineScope(Dispatchers.Default) override suspend fun callWillJoin(call: Call) { observeJob = observerScope.launch { - call.setIncomingAudioMuted(false) + call.state.participants.collect { participants -> + participants.forEach { participant -> + val audioTrack = participant.audioTrack.value + audioTrack?.audio?.setEnabled(false) + } + } } } @@ -95,6 +100,9 @@ class DemoCallJoinInterceptor( override suspend fun callDidJoin(call: Call) { observeJob?.cancel() - call.setIncomingAudioMuted(true) + call.state.participants.value.forEach { + val audioTrack = it.audioTrack.value + audioTrack?.audio?.setEnabled(false) + } } } diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index d74f85b041a..37e765bd276 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8928,7 +8928,6 @@ public final class io/getstream/video/android/core/Call { public final fun setAudioProcessingEnabled (Z)V public final fun setIncomingAudioEnabled (ZLjava/util/List;)V public static synthetic fun setIncomingAudioEnabled$default (Lio/getstream/video/android/core/Call;ZLjava/util/List;ILjava/lang/Object;)V - public final fun setIncomingAudioMuted (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun setIncomingVideoEnabled (Ljava/lang/Boolean;Ljava/util/List;)V public static synthetic fun setIncomingVideoEnabled$default (Lio/getstream/video/android/core/Call;Ljava/lang/Boolean;Ljava/util/List;ILjava/lang/Object;)V public final fun setPreferredIncomingVideoResolution (Lio/getstream/video/android/core/model/PreferredVideoResolution;Ljava/util/List;)V diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index af789b0cfb9..d5e40f8d1e4 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -1454,17 +1454,6 @@ public class Call( return clientImpl.muteUsers(type, id, request) } - suspend fun setIncomingAudioMuted(audio: Boolean) { - val subscriber = session.filterNotNull() - .flatMapLatest { it.subscriber.filterNotNull() } - .first() // wait until a subscriber exists, take it once - - val volume = if (audio) 1.0 else 0.0 // gain range is 0..10; 0 = silence - subscriber.tracks.values - .mapNotNull { it[TrackType.TRACK_TYPE_AUDIO] as? AudioTrack } - .forEach { it.audio.setVolume(volume) } // org.webrtc.AudioTrack.setVolume(double) - } - fun setVisibility( sessionId: String, trackType: TrackType, From 195590ceffc974454df4579f8b5d539bf2303353 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 18:57:40 +0530 Subject: [PATCH 06/14] fix: refactor --- .../video/android/DemoCallJoinInterceptor.kt | 30 +++++++++++++------ .../video/android/core/CallJoinInterceptor.kt | 8 ++++- .../CallJoinLifecycleInterceptor.kt | 10 +++++++ 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt index c9d1dcdb71b..b2606786d26 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt @@ -24,8 +24,12 @@ import io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterce import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.launch /** @@ -46,12 +50,19 @@ class DemoCallJoinInterceptor( val observerScope = CoroutineScope(Dispatchers.Default) override suspend fun callWillJoin(call: Call) { observeJob = observerScope.launch { - call.state.participants.collect { participants -> - participants.forEach { participant -> - val audioTrack = participant.audioTrack.value - audioTrack?.audio?.setEnabled(false) + call.state.participants + .flatMapLatest { participants -> + participants + .filter { !it.isLocal } + .asFlow() + .flatMapMerge { participant -> + participant.audioTrack.filterNotNull() + } + } + .collect { track -> + logger.d { "noob [callWillJoin] disabling audio tracks" } + track.audio.setEnabled(false) } - } } } @@ -100,9 +111,10 @@ class DemoCallJoinInterceptor( override suspend fun callDidJoin(call: Call) { observeJob?.cancel() - call.state.participants.value.forEach { - val audioTrack = it.audioTrack.value - audioTrack?.audio?.setEnabled(false) - } + call.state.participants.value.filter { !it.isLocal } + .forEach { + val audioTrack = it.audioTrack.value + audioTrack?.audio?.setEnabled(true) + } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt index 22af9c66aee..6fe8d3cbc6f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt @@ -25,7 +25,13 @@ import kotlin.jvm.Throws * the publisher peer connection becoming ready and the call going active. * Has no effect on non-ringing joins (livestream, direct join). */ -@Deprecated("Use CallJoinLifecycleInterceptor instead") +@Deprecated( + message = "Use CallJoinLifecycleInterceptor instead", + replaceWith = ReplaceWith( + "CallJoinLifecycleInterceptor", + "io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor", + ), +) public interface CallJoinInterceptor { /** diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt index 56f0d39f745..f575e5246fe 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt @@ -19,7 +19,17 @@ package io.getstream.video.android.core.call.interceptor import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallJoinInterceptor +/** + * Extends [CallJoinInterceptor] with hooks around the join lifecycle. + * + * Order: [callWillJoin] → [callReadyToJoin] → [callDidJoin]. + * Only affects ringing joins. + */ public interface CallJoinLifecycleInterceptor : CallJoinInterceptor { + + /** Called right after the [io.getstream.video.android.core.call.RtcSession] is created, before the call goes [io.getstream.video.android.core.RingingState.Active]. */ public suspend fun callWillJoin(call: Call) + + /** Called right after [CallJoinInterceptor.callReadyToJoin]. */ public suspend fun callDidJoin(call: Call) } From 2d727fb51371ab31cf487de7fd8f5b21aa4a692c Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 19:09:06 +0530 Subject: [PATCH 07/14] fix: callDidJoin invokation --- .../video/android/core/ActiveStateGate.kt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt index 1a95d9f174f..7379026977a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt @@ -84,7 +84,12 @@ internal class ActiveStateGate( val shouldProceed = invokeInterceptor(call, interceptor) if (!isActive) return@launch - if (shouldProceed) onReady() + if (shouldProceed) { + onReady() + if (interceptor is CallJoinLifecycleInterceptor) { + interceptor.callDidJoin(call) + } + } cancelInterceptorJob() }, ) @@ -108,7 +113,12 @@ internal class ActiveStateGate( val shouldProceed = invokeInterceptor(call, interceptor) if (!isActive) return@launch - if (shouldProceed) onReady() + if (shouldProceed) { + onReady() + if (interceptor is CallJoinLifecycleInterceptor) { + interceptor.callDidJoin(call) + } + } cleanup() }, ) From 429d7b1e81fff679d6ef6cdf1597e35f89267dbd Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 19:10:45 +0530 Subject: [PATCH 08/14] fix: suppress deprecation --- .../core/call/interceptor/CallJoinLifecycleInterceptor.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt index f575e5246fe..6ee836b3a10 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt @@ -25,11 +25,15 @@ import io.getstream.video.android.core.CallJoinInterceptor * Order: [callWillJoin] → [callReadyToJoin] → [callDidJoin]. * Only affects ringing joins. */ +@Suppress("DEPRECATION") public interface CallJoinLifecycleInterceptor : CallJoinInterceptor { /** Called right after the [io.getstream.video.android.core.call.RtcSession] is created, before the call goes [io.getstream.video.android.core.RingingState.Active]. */ public suspend fun callWillJoin(call: Call) - /** Called right after [CallJoinInterceptor.callReadyToJoin]. */ + /** + * Called once the call has transitioned to [io.getstream.video.android.core.RingingState.Active] + * (after [CallJoinInterceptor.callReadyToJoin]). + */ public suspend fun callDidJoin(call: Call) } From 2c6fec2996b78ce51d78a30b187c6ef860bc1bb4 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 19:11:12 +0530 Subject: [PATCH 09/14] fix: suppress deprecation and fix invokation --- .../kotlin/io/getstream/video/android/core/ActiveStateGate.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt index 7379026977a..4013f5cb058 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt @@ -153,10 +153,6 @@ internal class ActiveStateGate( withTimeoutOrNull(interceptorTimeoutMs) { interceptor.callReadyToJoin(call) } - - if (interceptor is CallJoinLifecycleInterceptor) { - interceptor.callDidJoin(call) - } logger.d { "[invokeInterceptor] finish at ${(System.currentTimeMillis() - startTime) / 1000}s " } true } catch (e: CancellationException) { From 1c843d8f94a7facb4255ff22afe4368438c8d641 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 19:27:03 +0530 Subject: [PATCH 10/14] fix: remove logs --- .../kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt index b2606786d26..5fb75d5ee46 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt @@ -60,7 +60,6 @@ class DemoCallJoinInterceptor( } } .collect { track -> - logger.d { "noob [callWillJoin] disabling audio tracks" } track.audio.setEnabled(false) } } From 79b8001a28751e5ecd33c670cd4ca59d216cacc4 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 30 Jun 2026 19:50:24 +0530 Subject: [PATCH 11/14] fix: catch exceptions --- .../video/android/core/ActiveStateGate.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt index 4013f5cb058..1895d5897bb 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt @@ -86,8 +86,14 @@ internal class ActiveStateGate( if (shouldProceed) { onReady() - if (interceptor is CallJoinLifecycleInterceptor) { - interceptor.callDidJoin(call) + try { + if (interceptor is CallJoinLifecycleInterceptor) { + interceptor.callDidJoin(call) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + logger.e(ex) { "[callDidJoin] interceptor threw, proceeding" } } } cancelInterceptorJob() @@ -115,8 +121,14 @@ internal class ActiveStateGate( if (shouldProceed) { onReady() - if (interceptor is CallJoinLifecycleInterceptor) { - interceptor.callDidJoin(call) + try { + if (interceptor is CallJoinLifecycleInterceptor) { + interceptor.callDidJoin(call) + } + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + logger.e(ex) { "[callDidJoin] interceptor threw, proceeding" } } } cleanup() From fce7c0c119e4e890124d058239d33f2881baa425 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Thu, 2 Jul 2026 13:59:16 +0530 Subject: [PATCH 12/14] fix: revert CallJoinLifecycleInterceptor.kt, try-catches on invocation of interface methods, improve cancellation observer in DemoCallJoinInterceptor.kt --- .../getstream/video/android/CallActivity.kt | 6 ++- .../video/android/DemoCallJoinInterceptor.kt | 48 ++++++++++++------- .../video/android/core/ActiveStateGate.kt | 31 +++++------- .../io/getstream/video/android/core/Call.kt | 10 ++-- .../video/android/core/CallJoinInterceptor.kt | 25 ++++++---- .../CallJoinLifecycleInterceptor.kt | 39 --------------- 6 files changed, 71 insertions(+), 88 deletions(-) delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt b/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt index bf4df4beff9..1481bacfc40 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt @@ -49,6 +49,7 @@ import io.getstream.video.android.util.StreamVideoInitHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest @@ -68,7 +69,9 @@ class CallActivity : ComposeStreamCallActivity() { var observeCallReadyToJoinJob: Job? = null var observeRingingJob: Job? = null private val previousRingingStates = ConcurrentHashMap.newKeySet() - override val callJoinInterceptor = DemoCallJoinInterceptor(previousRingingStates) + private val coroutineScope = CoroutineScope(Dispatchers.Default) + override val callJoinInterceptor = + DemoCallJoinInterceptor(previousRingingStates, coroutineScope) /** * This code is required to pass the UI-tests (as it hardcodes the configuration) @@ -207,6 +210,7 @@ class CallActivity : ComposeStreamCallActivity() { super.finish() observeCallReadyToJoinJob?.cancel() observeRingingJob?.cancel() + coroutineScope.cancel() previousRingingStates.clear() } } diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt index 5fb75d5ee46..35ca410c0da 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt @@ -19,10 +19,10 @@ package io.getstream.video.android import io.getstream.log.taggedLogger import io.getstream.video.android.CallActivity.Companion.USE_CALL_JOIN_INTERCEPTOR import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallJoinInterceptor +import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.RingingState -import io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.filter @@ -39,29 +39,40 @@ import kotlinx.coroutines.launch */ class DemoCallJoinInterceptor( private val previousRingingStates: Set, -) : CallJoinLifecycleInterceptor { + private val coroutineScope: CoroutineScope, +) : CallJoinInterceptor { private val logger by taggedLogger("DemoCallJoinInterceptor") companion object { const val CALLER_READY_TO_JOIN_EVENT_TYPE = "caller_ready_join" const val CALLEE_READY_TO_JOIN_EVENT_TYPE = "callee_ready_join" } - var observeJob: Job? = null - val observerScope = CoroutineScope(Dispatchers.Default) + private var observeJob: Job? = null override suspend fun callWillJoin(call: Call) { - observeJob = observerScope.launch { - call.state.participants - .flatMapLatest { participants -> - participants - .filter { !it.isLocal } - .asFlow() - .flatMapMerge { participant -> - participant.audioTrack.filterNotNull() - } - } - .collect { track -> - track.audio.setEnabled(false) - } + observeJob?.cancel() + observeJob = coroutineScope.launch { + val muteJob = launch { + call.state.participants + .flatMapLatest { participants -> + participants + .filter { !it.isLocal } + .asFlow() + .flatMapMerge { participant -> + participant.audioTrack.filterNotNull() + } + } + .collect { track -> + track.audio.setEnabled(false) + } + } + // Stop muting once the call reaches a terminal state, even if callDidJoin + // never runs (e.g. the interceptor aborts the join or it's left externally). + call.state.connection.first { + it is RealtimeConnection.Disconnected || + it is RealtimeConnection.Failed || + it is RealtimeConnection.ReconnectingFailed + } + muteJob.cancel() } } @@ -110,6 +121,7 @@ class DemoCallJoinInterceptor( override suspend fun callDidJoin(call: Call) { observeJob?.cancel() + observeJob = null call.state.participants.value.filter { !it.isLocal } .forEach { val audioTrack = it.audioTrack.value diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt index 1895d5897bb..2f621c03dae 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ActiveStateGate.kt @@ -18,7 +18,6 @@ package io.getstream.video.android.core import androidx.lifecycle.AtomicReference import io.getstream.log.taggedLogger -import io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -86,15 +85,7 @@ internal class ActiveStateGate( if (shouldProceed) { onReady() - try { - if (interceptor is CallJoinLifecycleInterceptor) { - interceptor.callDidJoin(call) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - logger.e(ex) { "[callDidJoin] interceptor threw, proceeding" } - } + invokeCallDidJoin(call, interceptor) } cancelInterceptorJob() }, @@ -121,21 +112,23 @@ internal class ActiveStateGate( if (shouldProceed) { onReady() - try { - if (interceptor is CallJoinLifecycleInterceptor) { - interceptor.callDidJoin(call) - } - } catch (ex: CancellationException) { - throw ex - } catch (ex: Exception) { - logger.e(ex) { "[callDidJoin] interceptor threw, proceeding" } - } + invokeCallDidJoin(call, interceptor) } cleanup() }, ) } + private suspend fun invokeCallDidJoin(call: Call, interceptor: CallJoinInterceptor?) { + try { + interceptor?.callDidJoin(call) + } catch (ex: CancellationException) { + throw ex + } catch (ex: Exception) { + logger.e(ex) { "[callDidJoin] interceptor threw, proceeding" } + } + } + private suspend fun awaitPeerConnection(call: Call) { val start = System.currentTimeMillis() val result = diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index d5e40f8d1e4..3a92bfc6586 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -71,7 +71,6 @@ import io.getstream.video.android.core.call.SfuConnectionResult import io.getstream.video.android.core.call.audio.InputAudioFilter import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.getstream.video.android.core.call.connection.Subscriber -import io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor import io.getstream.video.android.core.call.scope.ScopeProvider import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.call.utils.SoundInputProcessor @@ -104,6 +103,7 @@ import io.getstream.video.android.core.utils.safeCallWithDefault import io.getstream.video.android.core.utils.toQueriedMembers import io.getstream.video.android.model.User import io.getstream.webrtc.android.ui.VideoTextureViewRenderer +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -627,8 +627,12 @@ public class Call( } } - if (callJoinInterceptor is CallJoinLifecycleInterceptor) { - callJoinInterceptor.callWillJoin(this) + try { + callJoinInterceptor?.callWillJoin(this) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.e(e) { "[callWillJoin] interceptor threw, proceeding" } } return result diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt index 6fe8d3cbc6f..7a8f5c86524 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallJoinInterceptor.kt @@ -19,21 +19,24 @@ package io.getstream.video.android.core import kotlin.jvm.Throws /** - * Controls when a ringing call transitions to [RingingState.Active]. + * Controls when a ringing call transitions to [RingingState.Active], and provides + * best-effort hooks around the join lifecycle. * * Implement this to insert custom logic (e.g. waiting for user confirmation) between * the publisher peer connection becoming ready and the call going active. * Has no effect on non-ringing joins (livestream, direct join). + * + * Order: [callWillJoin] → [callReadyToJoin] → [callDidJoin]. */ -@Deprecated( - message = "Use CallJoinLifecycleInterceptor instead", - replaceWith = ReplaceWith( - "CallJoinLifecycleInterceptor", - "io.getstream.video.android.core.call.interceptor.CallJoinLifecycleInterceptor", - ), -) public interface CallJoinInterceptor { + /** + * Called right after the [io.getstream.video.android.core.call.RtcSession] is created, + * before the call goes [RingingState.Active]. Exceptions are logged and swallowed — + * this hook is best-effort and never fails the join. + */ + public suspend fun callWillJoin(call: Call) {} + /** * Called when the SDK is ready to transition to [RingingState.Active]. * Suspend here to delay the transition; return to allow it to proceed. @@ -45,6 +48,12 @@ public interface CallJoinInterceptor { */ @Throws(CallJoinInterceptionException::class) public suspend fun callReadyToJoin(call: Call) + + /** + * Called once the call has transitioned to [RingingState.Active] (after [callReadyToJoin]). + * Exceptions are logged and swallowed — this hook is best-effort and never fails the join. + */ + public suspend fun callDidJoin(call: Call) {} } /** diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt deleted file mode 100644 index 6ee836b3a10..00000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-video-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.video.android.core.call.interceptor - -import io.getstream.video.android.core.Call -import io.getstream.video.android.core.CallJoinInterceptor - -/** - * Extends [CallJoinInterceptor] with hooks around the join lifecycle. - * - * Order: [callWillJoin] → [callReadyToJoin] → [callDidJoin]. - * Only affects ringing joins. - */ -@Suppress("DEPRECATION") -public interface CallJoinLifecycleInterceptor : CallJoinInterceptor { - - /** Called right after the [io.getstream.video.android.core.call.RtcSession] is created, before the call goes [io.getstream.video.android.core.RingingState.Active]. */ - public suspend fun callWillJoin(call: Call) - - /** - * Called once the call has transitioned to [io.getstream.video.android.core.RingingState.Active] - * (after [CallJoinInterceptor.callReadyToJoin]). - */ - public suspend fun callDidJoin(call: Call) -} From 4f232e7c8a3234f77874694115ee5563b1c6e1cf Mon Sep 17 00:00:00 2001 From: rahullohra Date: Mon, 6 Jul 2026 18:46:56 +0530 Subject: [PATCH 13/14] fix: fix api --- .../api/stream-video-android-core.api | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 769a92167cb..49fe341b99a 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8989,7 +8989,11 @@ public final class io/getstream/video/android/core/CallJoinInterceptionException } public abstract interface class io/getstream/video/android/core/CallJoinInterceptor { + public fun callDidJoin (Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun callDidJoin$suspendImpl (Lio/getstream/video/android/core/CallJoinInterceptor;Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun callReadyToJoin (Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun callWillJoin (Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun callWillJoin$suspendImpl (Lio/getstream/video/android/core/CallJoinInterceptor;Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class io/getstream/video/android/core/CallKt { @@ -10070,11 +10074,6 @@ public final class io/getstream/video/android/core/call/connection/StreamPeerCon public final fun toggleAudioProcessing ()Z } -public abstract interface class io/getstream/video/android/core/call/interceptor/CallJoinLifecycleInterceptor : io/getstream/video/android/core/CallJoinInterceptor { - public abstract fun callDidJoin (Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun callWillJoin (Lio/getstream/video/android/core/Call;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - public final class io/getstream/video/android/core/call/signal/socket/RTCEventMapper { public static final field INSTANCE Lio/getstream/video/android/core/call/signal/socket/RTCEventMapper; public final fun mapEvent (Lstream/video/sfu/event/SfuEvent;)Lio/getstream/video/android/core/events/SfuDataEvent; From 5bf5d72e1dbb2693f3f72ffa710cb2039d7e041e Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 7 Jul 2026 12:02:12 +0530 Subject: [PATCH 14/14] fix: add boolean flag to run call demo call join intereceptor --- .../video/android/DemoCallJoinInterceptor.kt | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt index 35ca410c0da..bcf2c73757c 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/DemoCallJoinInterceptor.kt @@ -49,30 +49,32 @@ class DemoCallJoinInterceptor( } private var observeJob: Job? = null override suspend fun callWillJoin(call: Call) { - observeJob?.cancel() - observeJob = coroutineScope.launch { - val muteJob = launch { - call.state.participants - .flatMapLatest { participants -> - participants - .filter { !it.isLocal } - .asFlow() - .flatMapMerge { participant -> - participant.audioTrack.filterNotNull() - } - } - .collect { track -> - track.audio.setEnabled(false) - } - } - // Stop muting once the call reaches a terminal state, even if callDidJoin - // never runs (e.g. the interceptor aborts the join or it's left externally). - call.state.connection.first { - it is RealtimeConnection.Disconnected || - it is RealtimeConnection.Failed || - it is RealtimeConnection.ReconnectingFailed + if (USE_CALL_JOIN_INTERCEPTOR) { + observeJob?.cancel() + observeJob = coroutineScope.launch { + val muteJob = launch { + call.state.participants + .flatMapLatest { participants -> + participants + .filter { !it.isLocal } + .asFlow() + .flatMapMerge { participant -> + participant.audioTrack.filterNotNull() + } + } + .collect { track -> + track.audio.setEnabled(false) + } + } + // Stop muting once the call reaches a terminal state, even if callDidJoin + // never runs (e.g. the interceptor aborts the join or it's left externally). + call.state.connection.first { + it is RealtimeConnection.Disconnected || + it is RealtimeConnection.Failed || + it is RealtimeConnection.ReconnectingFailed + } + muteJob.cancel() } - muteJob.cancel() } } @@ -120,12 +122,14 @@ class DemoCallJoinInterceptor( } override suspend fun callDidJoin(call: Call) { - observeJob?.cancel() - observeJob = null - call.state.participants.value.filter { !it.isLocal } - .forEach { - val audioTrack = it.audioTrack.value - audioTrack?.audio?.setEnabled(true) - } + if (USE_CALL_JOIN_INTERCEPTOR) { + observeJob?.cancel() + observeJob = null + call.state.participants.value.filter { !it.isLocal } + .forEach { + val audioTrack = it.audioTrack.value + audioTrack?.audio?.setEnabled(true) + } + } } }