From 35db03ba4f54274888049b4f77f8bf01ca272787 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Fri, 13 Feb 2026 18:17:46 +0530 Subject: [PATCH 1/3] fix: Correctly update from RingingState.Outgoing is to RingingState.Active when we perform joinAndRing and WS is not connected To set WS connection to null set the return of waitForConnectionId to null --- .../ui/outgoing/DirectCallJoinScreen.kt | 19 +++++++++++---- .../getstream/video/android/core/CallState.kt | 23 ++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt index 2052ce2061..00e4e8bebc 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt @@ -26,11 +26,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Checkbox +import androidx.compose.material.CheckboxDefaults import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults @@ -135,7 +137,7 @@ private fun Body( toggleUserSelection: (Int) -> Unit, onStartCallClick: (cid: StreamCallId, membersList: String, joinAndRing: Boolean) -> Unit, ) { - var callerJoinsFirst by rememberSaveable { mutableStateOf(false) } + var callerJoinsFirst by rememberSaveable { mutableStateOf(true) } Box( modifier = Modifier @@ -164,9 +166,18 @@ private fun Body( horizontalArrangement = Arrangement.SpaceBetween, ) { Text("Join First", color = Color.White) - Checkbox(callerJoinsFirst, onCheckedChange = { - callerJoinsFirst = !callerJoinsFirst - }) + Checkbox( + callerJoinsFirst, + modifier = Modifier.offset(x = 10.dp), + colors = CheckboxDefaults.colors( + uncheckedColor = Color.White, // Border color when unchecked + checkedColor = Color.White, // Fill color when checked + checkmarkColor = VideoTheme.colors.buttonBrandDefault, // Tick color + ), + onCheckedChange = { + callerJoinsFirst = !callerJoinsFirst + }, + ) } UserList( entries = users, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 1779715ca8..6c4bf04053 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -1252,15 +1252,34 @@ public class CallState( val userIsParticipant = _session.value?.participants?.find { it.user.id == client.userId } != null val outgoingMembersCount = _members.value.filter { it.value.user.id != client.userId }.size + val createdBySelf = createdBy?.id == client.userId Log.d("RingingState", "Current: ${_ringingState.value}, call_id: ${call.cid}") + + val ringingStateLogs = arrayListOf( + ("call.id: ${call.id}"), + ("acceptedBy: ${acceptedBy.joinToString()}"), + ("client.state.activeCall.id: ${client.state.activeCall.value?.id}"), + ("acceptedByMe: $isAcceptedByMe"), + ("isRejectedByMe: $isRejectedByMe"), + ("rejectReason: $rejectReason"), + ("hasActiveCall: $hasActiveCall"), + ("hasRingingCall: $hasRingingCall"), + ("userIsParticipant: $userIsParticipant"), + ).joinToString("") { it + "\n" } + Log.d( "RingingState", - "call_id: ${call.cid}, Flags: [\n" + "acceptedByMe: $isAcceptedByMe,\n" + "rejectedByMe: $isRejectedByMe,\n" + "rejectReason: $rejectReason,\n" + "hasActiveCall: $hasActiveCall\n" + "hasRingingCall: $hasRingingCall\n" + "userIsParticipant: $userIsParticipant,\n" + "]", + "call_id: ${call.cid}, Flags: $ringingStateLogs", ) // no members - call is empty, we can join val state: RingingState = if (hasActiveCall && !ringingStateUpdatesStopped) { + logger.d { "RingingState.Active source 1" } + cancelTimeout() + RingingState.Active + } else if (hasActiveCall && createdBySelf && acceptedBy.isNotEmpty() && !isAcceptedByMe) { // for joinAndRing + logger.d { "RingingState.Active source 4" } cancelTimeout() RingingState.Active } else if ((rejectedBy.isNotEmpty() && rejectedBy.size >= outgoingMembersCount) || @@ -1279,6 +1298,7 @@ public class CallState( // If it's already accepted by me then we are in an Active call if (userIsParticipant) { cancelTimeout() + logger.d { "RingingState.Active source 2" } RingingState.Active } else { RingingState.Incoming(acceptedByMe = isAcceptedByMe) @@ -1296,6 +1316,7 @@ public class CallState( // call is accepted and we are already in the call ringingStateUpdatesStopped = false cancelTimeout() + logger.d { "RingingState.Active source 3" } RingingState.Active } } else { From c620f4df873f1c2e8023d0efafdfc743847a8487 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 18 Feb 2026 18:45:08 +0530 Subject: [PATCH 2/3] chore: refactor --- .../main/kotlin/io/getstream/video/android/core/CallState.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 6aa3cae6de..1ffbbb6a40 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -1281,8 +1281,7 @@ public class CallState( } else if (hasActiveCall && createdBySelf && acceptedBy.isNotEmpty() && !isAcceptedByMe) { // for joinAndRing cancelTimeout() RingingState.Active - } - else if (isRejectedByMe) { + } else if (isRejectedByMe) { call.leave("updateRingingState-rejected-self") cancelTimeout() RingingState.RejectedByAll From ebcdf799fb7d2b5eb24b65f5d870c135a93dfa9c Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 18 Feb 2026 20:18:38 +0530 Subject: [PATCH 3/3] chore: atomically update ringingStateUpdatesStopped --- .../getstream/video/android/core/CallState.kt | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 6c4bf04053..e7da94135c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -18,7 +18,6 @@ package io.getstream.video.android.core import android.app.Notification import android.os.Bundle -import android.util.Log import androidx.compose.runtime.Stable import androidx.core.app.NotificationManagerCompat import io.getstream.android.video.generated.models.BlockedUserEvent @@ -160,6 +159,7 @@ import java.util.Locale import java.util.SortedMap import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import kotlin.time.Duration import kotlin.time.DurationUnit @@ -1023,7 +1023,7 @@ public class CallState( is JoinCallResponseEvent -> { // time to update call state based on the join response updateFromJoinResponse(event) - if (!ringingStateUpdatesStopped) { + if (!ringingStateUpdatesStopped.get()) { updateRingingState() } else { _ringingState.value = RingingState.Outgoing(acceptedByCallee = true) @@ -1239,6 +1239,11 @@ public class CallState( } private fun updateRingingState(rejectReason: RejectReason? = null) { + val ringingStateLogger by taggedLogger("RingingState") + if (ringingState.value == RingingState.RejectedByAll) { + return + } + // this is only true when we are in the session (we have accepted/joined the call) val rejectedBy = _rejectedBy.value val isRejectedByMe = _rejectedBy.value.contains(client.userId) @@ -1254,12 +1259,9 @@ public class CallState( val outgoingMembersCount = _members.value.filter { it.value.user.id != client.userId }.size val createdBySelf = createdBy?.id == client.userId - Log.d("RingingState", "Current: ${_ringingState.value}, call_id: ${call.cid}") + ringingStateLogger.d { "Current: ${_ringingState.value}, call_id: ${call.cid}" } val ringingStateLogs = arrayListOf( - ("call.id: ${call.id}"), - ("acceptedBy: ${acceptedBy.joinToString()}"), - ("client.state.activeCall.id: ${client.state.activeCall.value?.id}"), ("acceptedByMe: $isAcceptedByMe"), ("isRejectedByMe: $isRejectedByMe"), ("rejectReason: $rejectReason"), @@ -1268,16 +1270,16 @@ public class CallState( ("userIsParticipant: $userIsParticipant"), ).joinToString("") { it + "\n" } - Log.d( - "RingingState", - "call_id: ${call.cid}, Flags: $ringingStateLogs", - ) + ringingStateLogger.d { "call_id: ${call.cid}, Flags: $ringingStateLogs" } // no members - call is empty, we can join - val state: RingingState = if (hasActiveCall && !ringingStateUpdatesStopped) { - logger.d { "RingingState.Active source 1" } + val state: RingingState = if (hasActiveCall && !ringingStateUpdatesStopped.get()) { cancelTimeout() RingingState.Active + } else if (isRejectedByMe) { + call.leave("updateRingingState-rejected-self") + cancelTimeout() + RingingState.RejectedByAll } else if (hasActiveCall && createdBySelf && acceptedBy.isNotEmpty() && !isAcceptedByMe) { // for joinAndRing logger.d { "RingingState.Active source 4" } cancelTimeout() @@ -1298,14 +1300,13 @@ public class CallState( // If it's already accepted by me then we are in an Active call if (userIsParticipant) { cancelTimeout() - logger.d { "RingingState.Active source 2" } RingingState.Active } else { RingingState.Incoming(acceptedByMe = isAcceptedByMe) } } else if (hasRingingCall && createdBy?.id == client.userId) { // The call is created by us - logger.d { "acceptedBy: $acceptedBy, userIsParticipant: $userIsParticipant" } + ringingStateLogger.d { "acceptedBy: $acceptedBy, userIsParticipant: $userIsParticipant" } if (acceptedBy.isEmpty()) { // no one accepted the call RingingState.Outgoing(acceptedByCallee = false) @@ -1314,9 +1315,8 @@ public class CallState( RingingState.Outgoing(acceptedByCallee = true) } else { // call is accepted and we are already in the call - ringingStateUpdatesStopped = false + ringingStateUpdatesStopped.set(false) cancelTimeout() - logger.d { "RingingState.Active source 3" } RingingState.Active } } else { @@ -1328,7 +1328,7 @@ public class CallState( } if (_ringingState.value != state) { - logger.d { "Updating ringing state ${_ringingState.value} -> $state" } + ringingStateLogger.d { "Updating ringing state ${_ringingState.value} -> $state" } // handle the auto-cancel for outgoing ringing calls if (state is RingingState.Outgoing && !state.acceptedByCallee) { @@ -1341,7 +1341,7 @@ public class CallState( // stop the call ringing timer if it's running } - Log.d("RingingState", "Update: $state") + ringingStateLogger.d { "Update: $state" } _ringingState.value = state } @@ -1397,7 +1397,7 @@ public class CallState( // double check that we are still in Outgoing call state and call is not active if (_ringingState.value is RingingState.Outgoing || _ringingState.value is RingingState.Incoming && client.state.activeCall.value == null) { - ringingStateUpdatesStopped = false + ringingStateUpdatesStopped.set(false) call.reject(reason = RejectReason.Custom(alias = REJECT_REASON_TIMEOUT)) call.leave("start-ringing-timeout") } @@ -1646,10 +1646,10 @@ public class CallState( _broadcasting.value = true } - private var ringingStateUpdatesStopped = false + private var ringingStateUpdatesStopped = AtomicBoolean(false) internal fun toggleRingingStateUpdates(stopped: Boolean) { - ringingStateUpdatesStopped = stopped + ringingStateUpdatesStopped.set(stopped) } /**