Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions stream-video-android-core/api/stream-video-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -9659,7 +9659,13 @@ public final class io/getstream/video/android/core/RingingState$Outgoing : io/ge
public fun <init> ()V
public fun <init> (Z)V
public synthetic fun <init> (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun copy (Z)Lio/getstream/video/android/core/RingingState$Outgoing;
public static synthetic fun copy$default (Lio/getstream/video/android/core/RingingState$Outgoing;ZILjava/lang/Object;)Lio/getstream/video/android/core/RingingState$Outgoing;
public fun equals (Ljava/lang/Object;)Z
public final fun getAcceptedByCallee ()Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public final class io/getstream/video/android/core/RingingState$RejectedByAll : io/getstream/video/android/core/RingingState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
public sealed interface RingingState {
public data object Idle : RingingState
public data class Incoming(val acceptedByMe: Boolean = false) : RingingState
public class Outgoing(val acceptedByCallee: Boolean = false) : RingingState
public data class Outgoing(val acceptedByCallee: Boolean = false) : RingingState
public data object Active : RingingState
public data object RejectedByAll : RingingState
public data object TimeoutNoAnswer : RingingState
Expand Down Expand Up @@ -171,7 +171,7 @@
/**
* Transition incoming/outgoing call to active on the same service
*/
fun setActiveCall(call: Call) {

Check failure on line 174 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaAzGMCfD93ruGQeVCQD&open=AaAzGMCfD93ruGQeVCQD&pullRequest=1777
this._activeCall.value = call
val serviceTransitionDelayMs = 500L
val ringingState = call.state.ringingState.value
Expand All @@ -196,7 +196,7 @@
val serviceClass = callServiceConfig.serviceClass
val isServiceRunning = ServiceIntentBuilder()
.isServiceRunning(this.client.context, serviceClass)
if (callServiceConfig.runCallServiceInForeground) {

Check warning on line 199 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Merge this "if" statement with the nested one.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaAzGMCfD93ruGQeVCQE&open=AaAzGMCfD93ruGQeVCQE&pullRequest=1777
if (!isServiceRunning) {
logger.e { "Outgoing call service should already be running" }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import java.util.concurrent.ConcurrentHashMap
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals

/**
* Structural equality of [RingingState] is load-bearing: the ringing state is published through a
* [MutableStateFlow], and every consumer relies on the flow conflating unchanged values. Without
* it, each recomputation publishes a fresh value and restarts side effects that are meant to run
* once per transition — the auto-cancel ring timer, the outgoing ringtone, and the ongoing-call
* notification.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RingingStateTest {
Comment thread
PratimMallick marked this conversation as resolved.

@Test
fun `outgoing states with the same acceptance are equal`() {
assertEquals(
RingingState.Outgoing(acceptedByCallee = false),
RingingState.Outgoing(acceptedByCallee = false),
)
assertEquals(
RingingState.Outgoing(acceptedByCallee = false).hashCode(),
RingingState.Outgoing(acceptedByCallee = false).hashCode(),
)
}

@Test
fun `outgoing states with different acceptance are not equal`() {
assertNotEquals(
RingingState.Outgoing(acceptedByCallee = false),
RingingState.Outgoing(acceptedByCallee = true),
)
}

@Test
fun `incoming states with the same acceptance are equal`() {
assertEquals(
RingingState.Incoming(acceptedByMe = false),
RingingState.Incoming(acceptedByMe = false),
)
assertNotEquals(
RingingState.Incoming(acceptedByMe = false),
RingingState.Incoming(acceptedByMe = true),
)
}

@Test
fun `outgoing and incoming with the same acceptance are not equal`() {
assertNotEquals<RingingState>(
RingingState.Outgoing(acceptedByCallee = true),
RingingState.Incoming(acceptedByMe = true),
)
}

/**
* `CallState.previousRingingStates` is a hash set. Without structural equality every
* recomputation adds a new member and the set grows for the lifetime of the ring.
*/
@Test
fun `identical outgoing states collapse in a hash set`() {
val states = ConcurrentHashMap.newKeySet<RingingState>()

repeat(50) { states.add(RingingState.Outgoing(acceptedByCallee = false)) }
states.add(RingingState.Outgoing(acceptedByCallee = true))

assertEquals(2, states.size)
}

/**
* The mechanism every side effect depends on: repeated identical outgoing states must not be
* republished. `CallState.updateRingingState` allocates a new instance on every run, so
* identity equality here would re-emit on each recomputation.
*/
@Test
fun `state flow does not republish identical outgoing states`() = runTest {
val ringingState = MutableStateFlow<RingingState>(RingingState.Idle)
val emissions = mutableListOf<RingingState>()

// The collector has to observe every published value, so it runs eagerly on assignment.
// A queued collector would only ever see the latest value and the assertion below would
// hold even without structural equality.
val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) {
ringingState.collect { emissions.add(it) }
}

// Three recomputations that all resolve to "outgoing, not yet accepted".
repeat(3) { ringingState.value = RingingState.Outgoing(acceptedByCallee = false) }
// A genuine transition must still be published.
ringingState.value = RingingState.Outgoing(acceptedByCallee = true)

collectJob.cancel()

assertEquals(
listOf(
RingingState.Idle,
RingingState.Outgoing(acceptedByCallee = false),
RingingState.Outgoing(acceptedByCallee = true),
),
emissions,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,37 @@ class CallServiceRingingStateObserverTest {
verify { soundPlayer.stopCallSound() }
}

@Test
fun `repeated identical outgoing states do not restart the outgoing sound`() = runTest {
observer.observe { }
advanceUntilIdle()

// Every call state recomputation resolves to the same outgoing state. Restarting the
// ringtone on each one makes the caller hear it from the beginning over and over.
repeat(3) {
ringingStateFlow.value = RingingState.Outgoing(acceptedByCallee = false)
advanceUntilIdle()
}

verify(exactly = 1) { soundPlayer.playCallSound(any(), true) }
}

@Test
fun `outgoing acceptance still handled after repeated identical states`() = runTest {
observer.observe { }
advanceUntilIdle()

repeat(3) {
ringingStateFlow.value = RingingState.Outgoing(acceptedByCallee = false)
advanceUntilIdle()
}
ringingStateFlow.value = RingingState.Outgoing(acceptedByCallee = true)
advanceUntilIdle()

verify(exactly = 1) { soundPlayer.playCallSound(any(), true) }
verify { soundPlayer.stopCallSound() }
Comment thread
PratimMallick marked this conversation as resolved.
}

@Test
fun `active call stops sound`() = runTest {
observer.observe { }
Expand Down
Loading