Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ object StreamVideoInitHelper {
audioProcessing = NoiseCancellation(context),
telecomConfig = TelecomConfig(context.packageName),
connectOnInit = false,
rejectCallWhenBusy = false,
).build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8867,6 +8867,7 @@ public final class io/getstream/video/android/core/ClientState {
public final fun getActiveCall ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getCallConfigRegistry ()Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;
public final fun getConnection ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getRejectCallWhenBusy ()Z
public final fun getRingingCall ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getTelecomIntegrationType ()Lio/getstream/video/android/core/notifications/internal/telecom/TelecomIntegrationType;
public final fun getUser ()Lkotlinx/coroutines/flow/StateFlow;
Expand Down Expand Up @@ -9465,7 +9466,8 @@ public final class io/getstream/video/android/core/StreamVideoBuilder {
public fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZ)V
public fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;)V
public fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;Z)V
public synthetic fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ZZ)V
public synthetic fun <init> (Landroid/content/Context;Ljava/lang/String;Lio/getstream/video/android/core/GEO;Lio/getstream/video/android/model/User;Ljava/lang/String;Lkotlin/jvm/functions/Function2;Lio/getstream/video/android/core/socket/common/token/TokenProvider;Lio/getstream/video/android/core/logging/LoggingLevel;Lio/getstream/video/android/core/notifications/NotificationConfig;Lkotlin/jvm/functions/Function1;JZLjava/lang/String;ZLio/getstream/video/android/core/notifications/internal/service/CallServiceConfig;Lio/getstream/video/android/core/notifications/internal/service/CallServiceConfigRegistry;Ljava/lang/String;Lio/getstream/video/android/core/sounds/Sounds;Lio/getstream/video/android/core/sounds/RingingCallVibrationConfig;ZLio/getstream/video/android/core/permission/android/StreamPermissionCheck;ILjava/lang/String;Lorg/webrtc/ManagedAudioProcessingFactory;JZZZLio/getstream/video/android/core/notifications/internal/telecom/TelecomConfig;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun build ()Lio/getstream/video/android/core/StreamVideo;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import io.getstream.android.video.generated.models.ConnectedEvent
import io.getstream.android.video.generated.models.VideoEvent
import io.getstream.log.taggedLogger
import io.getstream.result.Error
import io.getstream.video.android.core.call.CallBusyHandler
import io.getstream.video.android.core.notifications.internal.service.CallService
import io.getstream.video.android.core.notifications.internal.service.ServiceLauncher
import io.getstream.video.android.core.notifications.internal.telecom.TelecomIntegrationType
Expand Down Expand Up @@ -86,6 +87,11 @@ class ClientState(private val client: StreamVideo) {
public val callConfigRegistry = (client as StreamVideoClient).callServiceConfigRegistry
private val serviceLauncher = ServiceLauncher(client.context)

public val rejectCallWhenBusy: Boolean = (client as StreamVideoClient).rejectCallWhenBusy

internal val callBusyHandler = CallBusyHandler(this.client as StreamVideoClient)
internal val eventPropagationPolicy = EventPropagationPolicy(callBusyHandler)

/**
* Returns true if there is an active or ringing call
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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

import io.getstream.android.video.generated.models.CallRingEvent
import io.getstream.android.video.generated.models.VideoEvent
import io.getstream.video.android.core.call.CallBusyHandler

internal class EventPropagationPolicy(private val callBusyHandler: CallBusyHandler) {
fun shouldPropagate(event: VideoEvent): Boolean {
return when (event) {
is CallRingEvent -> callBusyHandler.shouldPropagateEvent(event)
else -> true
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,12 @@ import java.net.ConnectException
* geo = GEO.GlobalEdgeNetwork,
* user = user,
* token = token,
* sounds = ringingConfig(
* defaultResourcesRingingConfig(context),
* defaultMutedRingingConfig(true, true)
* ),
* loggingLevel = LoggingLevel.BODY,
*
* // ...
* ).build()
*```
Expand Down Expand Up @@ -97,6 +102,9 @@ import java.net.ConnectException
* When `false` and the socket is not connected, incoming calls will not be delivered via WebSocket events;
* the SDK will rely on push notifications instead.
* To start receiving WebSocket events, explicitly invoke `client.connect()`.
* @property rejectCallWhenBusy Automatically rejects incoming calls when the user is already in another call.
* When enabled, the SDK suppresses incoming call notifications.
* CallRingEvent will not be propagated if there is an active or ongoing ringing call.
*
* @see build
* @see ClientState.connection
Expand All @@ -118,7 +126,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(
private val loggingLevel: LoggingLevel = LoggingLevel(),
private val notificationConfig: NotificationConfig = NotificationConfig(),
private val ringNotification: ((call: Call) -> Notification?)? = null,
private val connectionTimeoutInMs: Long = 10000,
private val connectionTimeoutInMs: Long = 10_000,
private var ensureSingleInstance: Boolean = true,
private val videoDomain: String = "video.stream-io-api.com",
@Deprecated(
Expand Down Expand Up @@ -153,6 +161,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(
private val enableStereoForSubscriber: Boolean = true,
private val telecomConfig: TelecomConfig? = null,
private val connectOnInit: Boolean = true,
private val rejectCallWhenBusy: Boolean = false,
) {
private val context: Context = context.applicationContext
private val scope = UserScope(ClientScope())
Expand Down Expand Up @@ -282,6 +291,7 @@ public class StreamVideoBuilder @JvmOverloads constructor(
enableStereoForSubscriber = enableStereoForSubscriber,
telecomConfig = telecomConfig,
tokenRepository = tokenRepository,
rejectCallWhenBusy = rejectCallWhenBusy,
)

if (user.type == UserType.Guest) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ internal class StreamVideoClient internal constructor(
internal val enableStatsCollection: Boolean = true,
internal val enableStereoForSubscriber: Boolean = true,
internal val telecomConfig: TelecomConfig? = null,
internal val rejectCallWhenBusy: Boolean = false,
) : StreamVideo, NotificationHandler by streamNotificationManager {

private var locationJob: Deferred<Result<String>>? = null
Expand Down Expand Up @@ -531,64 +532,86 @@ internal class StreamVideoClient internal constructor(
* Internal function that fires the event. It starts by updating client state and call state
* After that it loops over the subscriptions and calls their listener
*/

internal fun fireEvent(event: VideoEvent, cid: String = "") {
logger.d { "Event received $event" }
// update state for the client

if (!shouldProcessEvent(event)) return

propagateEventToClientState(event)

val selectedCid = resolveSelectedCid(event, cid)

notifyClientSubscriptions(event)

if (selectedCid.isEmpty()) return

forwardToCallSubscriptions(selectedCid, event)

if (!shouldProcessCallAcceptedEvent(event)) return

propagateEventToCall(selectedCid, event)

deliverToDestroyedCalls(event)
}

internal fun shouldProcessEvent(event: VideoEvent): Boolean {
return state.eventPropagationPolicy.shouldPropagate(event)
}

internal fun propagateEventToClientState(event: VideoEvent) {
state.handleEvent(event)
}

// update state for the calls. calls handle updating participants and members
val selectedCid = cid.ifEmpty {
val callEvent = event as? WSCallEvent
callEvent?.getCallCID()
} ?: ""
internal fun resolveSelectedCid(event: VideoEvent, cid: String): String {
if (cid.isNotEmpty()) return cid

val callEvent = event as? WSCallEvent
return callEvent?.getCallCID().orEmpty()
}

// client level subscriptions
internal fun notifyClientSubscriptions(event: VideoEvent) {
subscriptions.forEach { sub ->
if (!sub.isDisposed) {
// subs without filters should always fire
if (sub.filter == null) {
sub.listener.onEvent(event)
}
if (sub.isDisposed) return@forEach

// if there is a filter, check it and fire if it matches
sub.filter?.let {
if (it.invoke(event)) {
sub.listener.onEvent(event)
}
}
if (sub.filter == null || sub.filter.invoke(event)) {
sub.listener.onEvent(event)
}
}
// call level subscriptions
if (selectedCid.isNotEmpty()) {
calls[selectedCid]?.fireEvent(event)
notifyDestroyedCalls(event)
}
}

if (selectedCid.isNotEmpty()) {
// Special handling for accepted events
if (event is CallAcceptedEvent) {
// Skip accepted events not meant for the current outgoing call.
val currentRingingCall = state.ringingCall.value
val state = currentRingingCall?.state?.ringingState?.value
if (currentRingingCall != null &&
(state is RingingState.Outgoing || state == RingingState.Idle) &&
currentRingingCall.cid != event.callCid
) {
// Skip this event
return
}
}
internal fun forwardToCallSubscriptions(cid: String, event: VideoEvent) {
calls[cid]?.fireEvent(event)
notifyDestroyedCalls(event)
}

// Update calls as usual
calls[selectedCid]?.let {
it.state.handleEvent(event)
it.session?.handleEvent(event)
it.handleEvent(event)
}
deliverIntentToDestroyedCalls(event)
internal fun shouldProcessCallAcceptedEvent(event: VideoEvent): Boolean {
if (event !is CallAcceptedEvent) return true

val currentRingingCall = state.ringingCall.value ?: return true
val ringingState = currentRingingCall.state.ringingState.value

val isOutgoingOrIdle =
ringingState is RingingState.Outgoing ||
ringingState == RingingState.Idle

val isDifferentCall = currentRingingCall.cid != event.callCid

return !(isOutgoingOrIdle && isDifferentCall)
}

internal fun propagateEventToCall(cid: String, event: VideoEvent) {
calls[cid]?.let { call ->
call.state.handleEvent(event)
call.session?.handleEvent(event)
call.handleEvent(event)
}
}

internal fun deliverToDestroyedCalls(event: VideoEvent) {
deliverIntentToDestroyedCalls(event)
}

private fun shouldProcessDestroyedCall(event: VideoEvent, callCid: String): Boolean {
return when (event) {
is WSCallEvent -> event.getCallCID() == callCid
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* 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

import io.getstream.android.video.generated.models.CallRingEvent
import io.getstream.video.android.core.StreamVideoClient
import io.getstream.video.android.core.model.RejectReason
import kotlinx.coroutines.launch

internal class CallBusyHandler(private val streamVideo: StreamVideoClient) {

fun shouldPropagateEvent(event: CallRingEvent): Boolean {
return !isBusyWithAnotherCall(event.callCid, true)
}

fun isBusyWithAnotherCall(callCid: String, rejectViaApi: Boolean = false): Boolean {
val clientState = streamVideo.state
if (!clientState.rejectCallWhenBusy) return false

val (type, id) = callCid.split(":")

val isBusy =
clientState.activeCall.value?.id?.let { it != id } == true ||
clientState.ringingCall.value?.id?.let { it != id } == true

if (isBusy && rejectViaApi) {
streamVideo.scope.launch {
streamVideo.call(type, id).reject(RejectReason.Busy)
}
}
Comment on lines +40 to +44
Copy link

@coderabbitai coderabbitai bot Feb 18, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Silent rejection failure leaves the remote caller ringing indefinitely.

The fire-and-forget launch swallows any exception from reject(RejectReason.Busy). If the API call fails (network error, server error, etc.), the caller never receives a busy signal. At minimum, the failure should be logged; ideally it is also surfaced upstream.

🛡️ Proposed fix
         if (isBusy && rejectViaApi) {
             streamVideo.scope.launch {
-                streamVideo.call(type, id).reject(RejectReason.Busy)
+                streamVideo.call(type, id).reject(RejectReason.Busy)
+                    .onError { error ->
+                        // Log so operators can observe rejection failures
+                        streamLog(priority = StreamLog.WARN) {
+                            "[CallBusyHandler] Failed to reject busy call $callCid: ${error.message}"
+                        }
+                    }
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (isBusy && rejectViaApi) {
streamVideo.scope.launch {
streamVideo.call(type, id).reject(RejectReason.Busy)
}
}
if (isBusy && rejectViaApi) {
streamVideo.scope.launch {
streamVideo.call(type, id).reject(RejectReason.Busy)
.onError { error ->
// Log so operators can observe rejection failures
streamLog(priority = StreamLog.WARN) {
"[CallBusyHandler] Failed to reject busy call $callCid: ${error.message}"
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallBusyHandler.kt`
around lines 40 - 44, The fire-and-forget in CallBusyHandler (the isBusy &&
rejectViaApi branch using streamVideo.scope.launch) swallows exceptions from
streamVideo.call(type, id).reject(RejectReason.Busy); change this to capture and
handle failures by wrapping the reject call in a try/catch (or use runCatching)
inside the coroutine, log the error via the appropriate logger, and surface the
failure upstream (e.g., return a Result or call a failure callback) instead of
silently dropping it; update the code around streamVideo.scope.launch and the
reject invocation to ensure errors are logged and propagated.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's okay

Copy link

Choose a reason for hiding this comment

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

@rahul-lohra, understood! I'll skip this suggestion for error handling in the busy call rejection flow.

   /)_/)
  (o.o)
  />📋  Noted!

✏️ Learnings added
Learnt from: rahul-lohra
Repo: GetStream/stream-video-android PR: 1614
File: stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallBusyHandler.kt:40-44
Timestamp: 2026-02-18T12:32:54.490Z
Learning: In the `CallBusyHandler` class in `stream-video-android-core`, fire-and-forget rejection calls (using `streamVideo.scope.launch` to call `reject(RejectReason.Busy)`) without explicit error handling are acceptable for the busy call rejection feature.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: rahul-lohra
Repo: GetStream/stream-video-android PR: 1588
File: stream-video-android-core/api/stream-video-android-core.api:3561-3569
Timestamp: 2026-01-07T09:52:52.695Z
Learning: The `LocalCallMissedEvent` class in `stream-video-android-core` is only instantiated internally by the library maintainers. Clients only read these instances and never construct them directly, so breaking changes to the constructor are acceptable.


return isBusy
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import io.getstream.video.android.core.R
import io.getstream.video.android.core.RingingState
import io.getstream.video.android.core.StreamVideo
import io.getstream.video.android.core.StreamVideoClient
import io.getstream.video.android.core.call.CallBusyHandler
import io.getstream.video.android.core.internal.ExperimentalStreamVideoApi
import io.getstream.video.android.core.notifications.DefaultNotificationIntentBundleResolver
import io.getstream.video.android.core.notifications.DefaultStreamIntentResolver
Expand Down Expand Up @@ -150,6 +151,11 @@ constructor(
private val logger by taggedLogger("Video:StreamNotificationHandler")
private val serviceLauncher = ServiceLauncher(application)

internal fun shouldShowIncomingCallNotification(
callBusyHandler: CallBusyHandler,
callCid: String,
) = !callBusyHandler.isBusyWithAnotherCall(callCid)

// START REGION : On push arrived
override fun onRingingCall(
callId: StreamCallId,
Expand All @@ -158,6 +164,14 @@ constructor(
) {
logger.d { "[onRingingCall] #ringing; callId: ${callId.id}" }
val streamVideo = StreamVideo.instance()
if (!shouldShowIncomingCallNotification(
streamVideo.state.callBusyHandler,
callId.cid,
)
) {
return
}

serviceLauncher.showIncomingCall(
application,
callId,
Expand Down
Loading
Loading