diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyScreen.kt index 91e0c44805..3633c583f2 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyScreen.kt @@ -73,6 +73,7 @@ import io.getstream.video.android.compose.ui.components.call.lobby.CallLobby import io.getstream.video.android.core.Call import io.getstream.video.android.core.call.state.ToggleCamera import io.getstream.video.android.core.call.state.ToggleMicrophone +import io.getstream.video.android.core.events.ParticipantCount import io.getstream.video.android.mock.StreamPreviewDataUtils import io.getstream.video.android.mock.previewCall import io.getstream.video.android.mock.previewUsers @@ -270,10 +271,9 @@ private fun CallLobbyBody( private fun LobbyDescription( callLobbyViewModel: CallLobbyViewModel, ) { - val session by callLobbyViewModel.call.state.session.collectAsState() - val participantsSize = session?.participants?.size ?: 0 + val participantCounts by callLobbyViewModel.call.state.participantCounts.collectAsState() - LobbyDescriptionContent(participantsSize = participantsSize) { + LobbyDescriptionContent(participantCounts = participantCounts) { callLobbyViewModel.handleUiEvent( CallLobbyEvent.JoinCall, ) @@ -281,12 +281,16 @@ private fun LobbyDescription( } @Composable -private fun LobbyDescriptionContent(participantsSize: Int, onClick: () -> Unit) { - val text = if (participantsSize > 0) { +private fun LobbyDescriptionContent(participantCounts: ParticipantCount?, onClick: () -> Unit) { + val totalParticipants = participantCounts?.total ?: 0 + val anonParticipants = participantCounts?.anonymous ?: 0 + + val text = if (totalParticipants != 0) { Pair( stringResource( id = R.string.join_call_description, - participantsSize, + totalParticipants, + anonParticipants, ), stringResource(id = R.string.join_call), ) @@ -384,8 +388,7 @@ private fun CallLobbyBodyPreview() { onToggleMicrophone = {}, onToggleCamera = {}, ) { - LobbyDescriptionContent(participantsSize = 0) { - } + LobbyDescriptionContent(participantCounts = ParticipantCount(1, 1)) {} } } } diff --git a/demo-app/src/main/res/values/strings.xml b/demo-app/src/main/res/values/strings.xml index eb8dd6e050..d0edf9b6db 100644 --- a/demo-app/src/main/res/values/strings.xml +++ b/demo-app/src/main/res/values/strings.xml @@ -31,7 +31,7 @@ Start a new call, join a meeting by \nentering the call ID or by scanning \na QR code. Join Call Scan QR meeting code - You are about to join a call. %d more people are in the call. + You are about to join a call. %d more people are in the call (%d anonymous). Don\'t have a Call ID? Call ID Start a New Call 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 caa2c7366d..3659f4d257 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7197,6 +7197,30 @@ public final class org/openapitools/client/models/CallSessionEndedEvent : org/op public fun toString ()Ljava/lang/String; } +public final class org/openapitools/client/models/CallSessionParticipantCountsUpdatedEvent : org/openapitools/client/models/VideoEvent, org/openapitools/client/models/WSCallEvent { + public fun (ILjava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (ILjava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lorg/threeten/bp/OffsetDateTime; + public final fun component4 ()Ljava/util/Map; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun copy (ILjava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;)Lorg/openapitools/client/models/CallSessionParticipantCountsUpdatedEvent; + public static synthetic fun copy$default (Lorg/openapitools/client/models/CallSessionParticipantCountsUpdatedEvent;ILjava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/util/Map;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lorg/openapitools/client/models/CallSessionParticipantCountsUpdatedEvent; + public fun equals (Ljava/lang/Object;)Z + public final fun getAnonymousParticipantCount ()I + public fun getCallCID ()Ljava/lang/String; + public final fun getCallCid ()Ljava/lang/String; + public final fun getCreatedAt ()Lorg/threeten/bp/OffsetDateTime; + public fun getEventType ()Ljava/lang/String; + public final fun getParticipantsCountByRole ()Ljava/util/Map; + public final fun getSessionId ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/openapitools/client/models/CallSessionParticipantJoinedEvent : org/openapitools/client/models/VideoEvent, org/openapitools/client/models/WSCallEvent { public fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lorg/openapitools/client/models/CallParticipantResponse;Ljava/lang/String;Ljava/lang/String;)V public synthetic fun (Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Lorg/openapitools/client/models/CallParticipantResponse;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -7242,23 +7266,25 @@ public final class org/openapitools/client/models/CallSessionParticipantLeftEven } public final class org/openapitools/client/models/CallSessionResponse { - public fun (Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;)V - public synthetic fun (Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/Map;ILjava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;)V + public synthetic fun (Ljava/util/Map;ILjava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/Map; public final fun component10 ()Lorg/threeten/bp/OffsetDateTime; public final fun component11 ()Lorg/threeten/bp/OffsetDateTime; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/util/Map; - public final fun component4 ()Ljava/util/List; - public final fun component5 ()Ljava/util/Map; + public final fun component12 ()Lorg/threeten/bp/OffsetDateTime; + public final fun component2 ()I + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/util/Map; + public final fun component5 ()Ljava/util/List; public final fun component6 ()Ljava/util/Map; - public final fun component7 ()Lorg/threeten/bp/OffsetDateTime; + public final fun component7 ()Ljava/util/Map; public final fun component8 ()Lorg/threeten/bp/OffsetDateTime; public final fun component9 ()Lorg/threeten/bp/OffsetDateTime; - public final fun copy (Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;)Lorg/openapitools/client/models/CallSessionResponse; - public static synthetic fun copy$default (Lorg/openapitools/client/models/CallSessionResponse;Ljava/util/Map;Ljava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;ILjava/lang/Object;)Lorg/openapitools/client/models/CallSessionResponse; + public final fun copy (Ljava/util/Map;ILjava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;)Lorg/openapitools/client/models/CallSessionResponse; + public static synthetic fun copy$default (Lorg/openapitools/client/models/CallSessionResponse;Ljava/util/Map;ILjava/lang/String;Ljava/util/Map;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;Lorg/threeten/bp/OffsetDateTime;ILjava/lang/Object;)Lorg/openapitools/client/models/CallSessionResponse; public fun equals (Ljava/lang/Object;)Z public final fun getAcceptedBy ()Ljava/util/Map; + public final fun getAnonymousParticipantCount ()I public final fun getEndedAt ()Lorg/threeten/bp/OffsetDateTime; public final fun getId ()Ljava/lang/String; public final fun getLiveEndedAt ()Lorg/threeten/bp/OffsetDateTime; 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 aef7bd876b..cc1f49bbe7 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 @@ -87,6 +87,7 @@ import org.openapitools.client.models.CallRejectedEvent import org.openapitools.client.models.CallResponse import org.openapitools.client.models.CallRingEvent import org.openapitools.client.models.CallSessionEndedEvent +import org.openapitools.client.models.CallSessionParticipantCountsUpdatedEvent import org.openapitools.client.models.CallSessionParticipantJoinedEvent import org.openapitools.client.models.CallSessionParticipantLeftEvent import org.openapitools.client.models.CallSessionResponse @@ -772,7 +773,7 @@ public class CallState( } is SFUHealthCheckEvent -> { - call.state._participantCounts.value = event.participantCount + updateParticipantCounts(sfuHealthCheckEvent = event) } is ICETrickleEvent -> { @@ -868,14 +869,34 @@ public class CallState( _session.value = event.call.session } + is CallSessionParticipantCountsUpdatedEvent -> { + _session.value?.let { + _session.value = it.copy( + participantsCountByRole = event.participantsCountByRole, + anonymousParticipantCount = event.anonymousParticipantCount, + ) + } + + updateParticipantCounts(session = session.value) + } + is CallSessionParticipantLeftEvent -> { _session.value?.let { callSessionResponse -> val newList = callSessionResponse.participants.toMutableList() newList.removeIf { it.userSessionId == event.participant.userSessionId } + + val newMap = callSessionResponse.participantsCountByRole.toMutableMap() + newMap + .computeIfPresent(event.participant.role) { _, v -> maxOf(v - 1, 0) } + .also { if (it == 0) newMap.remove(event.participant.role) } + _session.value = callSessionResponse.copy( - participants = newList.toList(), + participants = newList, + participantsCountByRole = newMap, ) } + + updateParticipantCounts(session = session.value) } is CallSessionParticipantJoinedEvent -> { @@ -884,26 +905,37 @@ public class CallState( val participant = CallParticipantResponse( user = event.participant.user, joinedAt = event.createdAt, - role = "user", + role = event.participant.user.role, userSessionId = event.participant.userSessionId, ) + val newMap = callSessionResponse.participantsCountByRole.toMutableMap() + newMap.merge(event.participant.role, 1, Int::plus) + + // It could happen that the backend delivers the same participant more than once. + // Once with the call.session_started event and once again with the + // call.session_participant_joined event. In this case, + // we should update the existing participant and prevent duplicating it. val index = newList.indexOfFirst { user.id == event.participant.user.id } if (index == -1) { newList.add(participant) } else { newList[index] = participant } + _session.value = callSessionResponse.copy( - participants = newList.toList(), + participants = newList, + participantsCountByRole = newMap, ) } + + updateParticipantCounts(session = session.value) updateRingingState() } } } private fun updateServerSidePins(pins: List) { - // Update particioants that are still in the call + // Update participants that are still in the call val pinnedInCall = pins.filter { _participants.value.containsKey(it.sessionId) } @@ -1027,6 +1059,19 @@ public class CallState( upsertParticipants(participantStates) } + private fun updateParticipantCounts(session: CallSessionResponse? = null, sfuHealthCheckEvent: SFUHealthCheckEvent? = null) { + // When in JOINED state, we should use the participant from SFU health check event, as it's more accurate. + + if (sfuHealthCheckEvent != null) { + _participantCounts.value = sfuHealthCheckEvent.participantCount + } else if (session != null && connection.value !is RealtimeConnection.Joined) { + _participantCounts.value = ParticipantCount( + total = session.anonymousParticipantCount + session.participantsCountByRole.values.sum(), + anonymous = session.anonymousParticipantCount, + ) + } + } + fun markSpeakingAsMuted() { _speakingWhileMuted.value = true speakingWhileMutedResetJob?.cancel() diff --git a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallSessionParticipantCountsUpdatedEvent.kt b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallSessionParticipantCountsUpdatedEvent.kt new file mode 100644 index 0000000000..2487c30568 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallSessionParticipantCountsUpdatedEvent.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2014-2024 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. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport" +) + +package org.openapitools.client.models + + + + + +import com.squareup.moshi.FromJson +import com.squareup.moshi.Json +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import org.openapitools.client.infrastructure.Serializer + +/** + * This event is sent when the participant counts in a call session are updated + * + * @param anonymousParticipantCount + * @param callCid + * @param createdAt + * @param participantsCountByRole + * @param sessionId Call session ID + * @param type The type of event: \"call.session_participant_count_updated\" in this case + */ + + +data class CallSessionParticipantCountsUpdatedEvent ( + + @Json(name = "anonymous_participant_count") + val anonymousParticipantCount: kotlin.Int, + + @Json(name = "call_cid") + val callCid: kotlin.String, + + @Json(name = "created_at") + val createdAt: org.threeten.bp.OffsetDateTime, + + @Json(name = "participants_count_by_role") + val participantsCountByRole: kotlin.collections.Map, + + /* Call session ID */ + @Json(name = "session_id") + val sessionId: kotlin.String, + + /* The type of event: \"call.session_participant_count_updated\" in this case */ + @Json(name = "type") + val type: kotlin.String = "call.session_participant_count_updated" + +) : VideoEvent(), WSCallEvent { + + override fun getCallCID(): String { + return callCid + } + + override fun getEventType(): String { + return type + } +} diff --git a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallSessionResponse.kt b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallSessionResponse.kt index f238f0c453..5eef60e403 100644 --- a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallSessionResponse.kt +++ b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/CallSessionResponse.kt @@ -40,6 +40,7 @@ import org.openapitools.client.infrastructure.Serializer * * * @param acceptedBy + * @param anonymousParticipantCount * @param id * @param missedBy * @param participants @@ -58,6 +59,9 @@ data class CallSessionResponse ( @Json(name = "accepted_by") val acceptedBy: kotlin.collections.Map, + @Json(name = "anonymous_participant_count") + val anonymousParticipantCount: kotlin.Int, + @Json(name = "id") val id: kotlin.String, diff --git a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/VideoEvent.kt b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/VideoEvent.kt index caaa237fda..480557f5d4 100644 --- a/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/VideoEvent.kt +++ b/stream-video-android-core/src/main/kotlin/org/openapitools/client/models/VideoEvent.kt @@ -141,6 +141,7 @@ class VideoEventAdapter : JsonAdapter() { "call.rejected" -> CallRejectedEvent::class.java "call.ring" -> CallRingEvent::class.java "call.session_ended" -> CallSessionEndedEvent::class.java + "call.session_participant_count_updated" -> CallSessionParticipantCountsUpdatedEvent::class.java "call.session_participant_joined" -> CallSessionParticipantJoinedEvent::class.java "call.session_participant_left" -> CallSessionParticipantLeftEvent::class.java "call.session_started" -> CallSessionStartedEvent::class.java