diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index 11cc5055f7..8dc1a2787f 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -346,6 +346,7 @@ object StreamVideoInitHelper { audioProcessing = NoiseCancellation(context), telecomConfig = TelecomConfig(context.packageName), connectOnInit = false, + rejectCallWhenBusy = false, ).build() } } 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 8f0d9ab1a9..da84b568e1 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -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; @@ -9465,7 +9466,8 @@ public final class io/getstream/video/android/core/StreamVideoBuilder { public fun (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 (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 (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 (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 (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 (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; } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt index 7455e64e37..825dfdbe3f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt @@ -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 @@ -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 */ diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/EventPropagationPolicy.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/EventPropagationPolicy.kt new file mode 100644 index 0000000000..767a321e24 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/EventPropagationPolicy.kt @@ -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 + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index bc85d2ac77..cf0f201f84 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -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() *``` @@ -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 @@ -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( @@ -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()) @@ -282,6 +291,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( enableStereoForSubscriber = enableStereoForSubscriber, telecomConfig = telecomConfig, tokenRepository = tokenRepository, + rejectCallWhenBusy = rejectCallWhenBusy, ) if (user.type == UserType.Guest) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index a11aeabeaf..41596d045f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -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>? = null @@ -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 diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallBusyHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallBusyHandler.kt new file mode 100644 index 0000000000..facb741114 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallBusyHandler.kt @@ -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) + } + } + + return isBusy + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt index 5e67e72752..176f513ee8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandler.kt @@ -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 @@ -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, @@ -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, diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/EventPropagationPolicyTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/EventPropagationPolicyTest.kt new file mode 100644 index 0000000000..3738f51ae2 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/EventPropagationPolicyTest.kt @@ -0,0 +1,73 @@ +/* + * 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 +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import kotlin.test.Test + +class EventPropagationPolicyTest { + private lateinit var callBusyHandler: CallBusyHandler + private lateinit var policy: EventPropagationPolicy + + @Before + fun setup() { + callBusyHandler = mockk(relaxed = true) + policy = EventPropagationPolicy(callBusyHandler) + } + + @Test + fun `shouldPropagate delegates to CallBusyHandler for CallRingEvent`() { + val event = mockk() + + every { callBusyHandler.shouldPropagateEvent(event) } returns false + + val result = policy.shouldPropagate(event) + + assertFalse(result) + verify(exactly = 1) { callBusyHandler.shouldPropagateEvent(event) } + } + + @Test + fun `shouldPropagate returns true when CallBusyHandler allows it`() { + val event = mockk() + + every { callBusyHandler.shouldPropagateEvent(event) } returns true + + val result = policy.shouldPropagate(event) + + assertTrue(result) + verify(exactly = 1) { callBusyHandler.shouldPropagateEvent(event) } + } + + @Test + fun `shouldPropagate returns true for non CallRingEvent`() { + val event = mockk() + + val result = policy.shouldPropagate(event) + + assertTrue(result) + verify(exactly = 0) { callBusyHandler.shouldPropagateEvent(any()) } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt new file mode 100644 index 0000000000..dda5ccaf58 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt @@ -0,0 +1,216 @@ +/* + * 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 android.content.Context +import androidx.lifecycle.Lifecycle +import io.getstream.android.video.generated.models.CallAcceptedEvent +import io.getstream.android.video.generated.models.CallSessionStartedEvent +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.video.android.core.events.VideoEventListener +import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule +import io.getstream.video.android.core.notifications.internal.StreamNotificationManager +import io.getstream.video.android.core.socket.common.token.TokenRepository +import io.getstream.video.android.core.sounds.RingingCallVibrationConfig +import io.getstream.video.android.core.sounds.Sounds +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StreamVideoClientTest { + private lateinit var client: StreamVideoClient + private lateinit var state: ClientState + private lateinit var policy: EventPropagationPolicy + + @Before + fun setup() { + val context = mockk(relaxed = true) + val lifecycle = mockk(relaxed = true) + val coordinator = mockk(relaxed = true) + val tokenRepo = mockk(relaxed = true) + val notificationManager = mockk(relaxed = true) + val sounds = mockk(relaxed = true) + val vibration = mockk(relaxed = true) + + client = spyk( + StreamVideoClient( + context = context, + user = mockk(relaxed = true), + apiKey = "apikey", + token = "token", + lifecycle = lifecycle, + coordinatorConnectionModule = coordinator, + tokenRepository = tokenRepo, + streamNotificationManager = notificationManager, + enableCallNotificationUpdates = false, + sounds = sounds, + vibrationConfig = vibration, + ), + recordPrivateCalls = true, + ) + + state = mockk(relaxed = true) + policy = mockk(relaxed = true) + + every { state.eventPropagationPolicy } returns policy + + // Inject mocked state via reflection + client::class.java.getDeclaredField("state").apply { + isAccessible = true + set(client, state) + } + } + + @Test + fun `shouldProcessEvent delegates to policy`() { + val event = mockk() + every { policy.shouldPropagate(event) } returns false + + val result = client.shouldProcessEvent(event) + + assertFalse(result) + } + + @Test + fun `resolveSelectedCid returns explicit cid when provided`() { + val event = mockk() + + val result = client.resolveSelectedCid(event, "video:123") + + assertEquals("video:123", result) + } + + @Test + fun `resolveSelectedCid extracts cid from WSCallEvent`() { + val event = mockk() + every { event.getCallCID() } returns "video:999" + + val result = client.resolveSelectedCid(event, "") + + assertEquals("video:999", result) + } + + @Test + fun `notifyClientSubscriptions triggers listener when no filter`() { + val event = mockk() + val listener = mockk>(relaxed = true) + + val sub = EventSubscription(listener) + + client::class.java.getDeclaredField("subscriptions").apply { + isAccessible = true + set(client, mutableSetOf(sub)) + } + + client.notifyClientSubscriptions(event) + + verify { listener.onEvent(event) } + } + + @Test + fun `notifyClientSubscriptions triggers only when filter matches`() { + val event = mockk() + val listener = mockk>(relaxed = true) + + val sub = EventSubscription(listener) { false } + + client::class.java.getDeclaredField("subscriptions").apply { + isAccessible = true + set(client, mutableSetOf(sub)) + } + + client.notifyClientSubscriptions(event) + + verify(exactly = 0) { listener.onEvent(any()) } + } + + @Test + fun `shouldProcessCallAcceptedEvent returns false when accepted event not for outgoing call`() { + val event = mockk() + every { event.callCid } returns "video:999" + + val ringingCall = mockk(relaxed = true) + every { ringingCall.cid } returns "video:123" + + val ringingStateFlow = MutableStateFlow(RingingState.Outgoing(false)) + + val callState = mockk(relaxed = true) + every { callState.ringingState } returns ringingStateFlow + every { ringingCall.state } returns callState + + every { state.ringingCall } returns MutableStateFlow(ringingCall) + + val result = client.shouldProcessCallAcceptedEvent(event) + + assertFalse(result) + } + + @Test + fun `shouldProcessCallAcceptedEvent returns true when same cid`() { + val event = mockk() + every { event.callCid } returns "video:123" + + val ringingCall = mockk(relaxed = true) + every { ringingCall.cid } returns "video:123" + + val ringingStateFlow = MutableStateFlow(RingingState.Outgoing(false)) + + val callState = mockk(relaxed = true) + every { callState.ringingState } returns ringingStateFlow + every { ringingCall.state } returns callState + + every { state.ringingCall } returns MutableStateFlow(ringingCall) + + val result = client.shouldProcessCallAcceptedEvent(event) + + assertTrue(result) + } + + @Test + fun `propagateEventToCall updates call components`() { + val event = mockk() + val call = mockk(relaxed = true) + + client::class.java.getDeclaredField("calls").apply { + isAccessible = true + set(client, mutableMapOf("video:123" to call)) + } + + client.propagateEventToCall("video:123", event) + + verify { call.state.handleEvent(event) } + verify { call.handleEvent(event) } + } + + @Test + fun `fireEvent full flow executes in order when allowed`() { + val event = mockk() + + every { policy.shouldPropagate(event) } returns true + + client.fireEvent(event) + + verify { state.handleEvent(event) } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/CallBusyHandlerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/CallBusyHandlerTest.kt new file mode 100644 index 0000000000..692bc1b609 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/CallBusyHandlerTest.kt @@ -0,0 +1,149 @@ +/* + * 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.Call +import io.getstream.video.android.core.ClientState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.model.RejectReason +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import kotlin.test.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CallBusyHandlerTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var streamVideo: StreamVideoClient + private lateinit var clientState: ClientState + private lateinit var call: Call + + private lateinit var handler: CallBusyHandler + private val activeCallFlow = MutableStateFlow(null) + private val ringingCallFlow = MutableStateFlow(null) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + + streamVideo = mockk(relaxed = true) + clientState = mockk(relaxed = true) { + every { rejectCallWhenBusy } returns true + every { activeCall } returns activeCallFlow + every { ringingCall } returns ringingCallFlow + } + call = mockk(relaxed = true) + + every { streamVideo.state } returns clientState + every { streamVideo.scope } returns testScope + every { streamVideo.call(any(), any()) } returns call + + handler = CallBusyHandler(streamVideo) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + private fun mockCall(id: String): Call { + return mockk { + every { this@mockk.id } returns id + } + } + + @Test + fun `returns false when rejectCallWhenBusy is disabled`() { + every { clientState.rejectCallWhenBusy } returns false + + val result = handler.isBusyWithAnotherCall("video:123") + + assertFalse(result) + } + + @Test + fun `returns false when same call is active`() { + every { clientState.rejectCallWhenBusy } returns true + every { clientState.activeCall } returns MutableStateFlow(mockCall("123")) + every { clientState.ringingCall } returns MutableStateFlow(null) + + val result = handler.isBusyWithAnotherCall("video:123") + + assertFalse(result) + } + + @Test + fun `returns true when busy with another active call but does not reject if rejectViaApi false`() { + every { clientState.rejectCallWhenBusy } returns true + every { clientState.activeCall } returns MutableStateFlow(mockCall("999")) + every { clientState.ringingCall } returns MutableStateFlow(null) + + val result = handler.isBusyWithAnotherCall("video:123", rejectViaApi = false) + + assertTrue(result) + verify(exactly = 0) { streamVideo.call(any(), any()) } + } + + @Test + fun `rejects call when busy and rejectViaApi true`() = runTest { + every { clientState.rejectCallWhenBusy } returns true + every { clientState.activeCall } returns MutableStateFlow(mockCall("999")) + every { clientState.ringingCall } returns MutableStateFlow(null) + + val callMock = mockk(relaxed = true) + every { streamVideo.call("video", "123") } returns callMock + + val result = handler.isBusyWithAnotherCall("video:123", rejectViaApi = true) + + advanceUntilIdle() + + assertTrue(result) + coVerify { callMock.reject(RejectReason.Busy) } + } + + @Test + fun `shouldPropagateEvent returns false when busy`() { + every { clientState.rejectCallWhenBusy } returns true + every { clientState.activeCall } returns MutableStateFlow(mockCall("999")) + every { clientState.ringingCall } returns MutableStateFlow(null) + + val event = mockk { + every { callCid } returns "video:123" + } + + val result = handler.shouldPropagateEvent(event) + + assertFalse(result) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt index 4fbeac0a58..e217dd1db7 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/handlers/StreamDefaultNotificationHandlerTest.kt @@ -30,6 +30,7 @@ import io.getstream.video.android.core.ClientState 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.notifications.NotificationType import io.getstream.video.android.core.notifications.StreamIntentResolver import io.getstream.video.android.core.notifications.dispatchers.NotificationDispatcher @@ -41,11 +42,13 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK import io.mockk.mockk import io.mockk.mockkConstructor import io.mockk.mockkObject import io.mockk.mockkStatic import io.mockk.unmockkAll +import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -97,6 +100,9 @@ class StreamDefaultNotificationHandlerTest { private lateinit var testCallId: StreamCallId private lateinit var testHandler: StreamDefaultNotificationHandler + @RelaxedMockK + internal lateinit var callBusyHandler: CallBusyHandler + @Before fun setUp2() { MockKAnnotations.init(this, relaxUnitFun = true, relaxed = true) @@ -114,6 +120,7 @@ class StreamDefaultNotificationHandlerTest { every { mockStreamVideo.state } returns mockState every { mockState.callConfigRegistry } returns mockCallConfigRegistry every { mockCallConfigRegistry.get(any()) } returns mockCallServiceConfig + every { mockState.callBusyHandler } returns callBusyHandler // Mock NotificationCompat.Builder to avoid Android framework issues mockkConstructor(NotificationCompat.Builder::class) @@ -180,12 +187,37 @@ class StreamDefaultNotificationHandlerTest { } @Test - fun `onRingingCall shows incoming call notification with comprehensive verification`() { + fun `onRingingCall does nothing when caller is busy`() { // Given val callDisplayName = "John Doe" - val mockkApp = mockk(relaxed = true) - val mockIncomingChannelInfo = mockk(relaxed = true) + every { callBusyHandler.isBusyWithAnotherCall(testCallId.cid) } returns true + + testHandler = StreamDefaultNotificationHandler( + application = mockApplication, + notificationManager = mockNotificationManager, + notificationPermissionHandler = mockNotificationPermissionHandler, + intentResolver = mockIntentResolver, + hideRingingNotificationInForeground = false, + initialNotificationBuilderInterceptor = mockInitialInterceptor, + updateNotificationBuilderInterceptor = mockUpdateInterceptor, + ) + + // When + testHandler.onRingingCall(testCallId, callDisplayName, payload) + + // Then — NOTHING downstream should execute + verify(exactly = 0) { + mockIntentResolver.searchIncomingCallPendingIntent(any(), any()) + } + unmockkObject(StreamVideo) + } + + @Test + fun `onRingingCall shows incoming call notification when caller is not busy with comprehensive verification`() { + // Given + val callDisplayName = "John Doe" + every { callBusyHandler.isBusyWithAnotherCall(testCallId.cid) } returns false // Mock intent resolver calls every { mockIntentResolver.searchIncomingCallPendingIntent(testCallId, payload = payload) @@ -205,7 +237,7 @@ class StreamDefaultNotificationHandlerTest { } returns mockk(relaxed = true) testHandler = StreamDefaultNotificationHandler( - application = mockkApp, + application = mockApplication, notificationManager = mockNotificationManager, notificationPermissionHandler = mockNotificationPermissionHandler, intentResolver = mockIntentResolver, diff --git a/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt b/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt index 5b4e537396..2f84abf21d 100644 --- a/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt +++ b/stream-video-android-ui-core/src/main/kotlin/io/getstream/video/android/ui/common/StreamCallActivity.kt @@ -294,7 +294,10 @@ public abstract class StreamCallActivity : ComponentActivity(), ActivityCallOper // Default implementation that rejects new calls when there's an ongoing call private val defaultCallHandler = object : IncomingCallHandlerDelegate { - override fun shouldAcceptNewCall(activeCall: Call, intent: Intent) = true + override fun shouldAcceptNewCall(activeCall: Call, intent: Intent): Boolean { + val clientState = StreamVideo.instanceOrNull()?.state ?: return false + return !clientState.rejectCallWhenBusy + } override fun onAcceptCall(intent: Intent) { finish()