Skip to content
Merged
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
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 @@ -28,6 +28,7 @@ import io.getstream.video.android.core.sounds.MutedRingingConfig
import io.getstream.video.android.core.sounds.RingingCallVibrationConfig
import io.getstream.video.android.core.sounds.RingingConfig
import io.getstream.video.android.core.sounds.Sounds
import io.mockk.clearMocks
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
Expand Down Expand Up @@ -170,6 +171,43 @@ 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()
}
verify(exactly = 1) { soundPlayer.playCallSound(any(), true) }

// The initial Idle emission already stopped the sound, so drop the recorded calls before
// asserting that the acceptance itself is what stops it.
clearMocks(soundPlayer, answers = false)

ringingStateFlow.value = RingingState.Outgoing(acceptedByCallee = true)
advanceUntilIdle()

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

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