From 06c284a4df3d5024f634a960978ac3f61367d98e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Zag=C3=B3rski?= Date: Wed, 4 Dec 2024 09:25:32 +0100 Subject: [PATCH] feat: Call sharing metadata [WPB-14605] (#3142) * feat: Hold call sharing metadata [WPB-14605] * Adjust names --- .../logic/data/call/CallMetadataProfile.kt | 15 +- .../kalium/logic/data/call/CallRepository.kt | 46 +++++- .../logic/data/call/CallRepositoryTest.kt | 154 ++++++++++++++++++ 3 files changed, 213 insertions(+), 2 deletions(-) diff --git a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallMetadataProfile.kt b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallMetadataProfile.kt index 96fd1f2d914..dd306c7b382 100644 --- a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallMetadataProfile.kt +++ b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallMetadataProfile.kt @@ -24,6 +24,7 @@ import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.user.OtherUserMinimized import com.wire.kalium.logic.data.user.UserId import com.wire.kalium.logic.data.user.type.UserType +import kotlinx.datetime.Instant data class CallMetadataProfile( val data: Map @@ -46,7 +47,8 @@ data class CallMetadata( val maxParticipants: Int = 0, // Was used for tracking val protocol: Conversation.ProtocolInfo, val activeSpeakers: Map> = mapOf(), - val users: List = listOf() + val users: List = listOf(), + val screenShareMetadata: CallScreenSharingMetadata = CallScreenSharingMetadata() ) { fun getFullParticipants(): List = participants.map { participant -> val user = users.firstOrNull { it.id == participant.userId } @@ -66,3 +68,14 @@ data class CallMetadata( ) } } + +/** + * [activeScreenShares] - map of user ids that share screen with the start timestamp + * [completedScreenShareDurationInMillis] - total time of already ended screen shares in milliseconds + * [uniqueSharingUsers] - set of users that were sharing a screen at least once + */ +data class CallScreenSharingMetadata( + val activeScreenShares: Map = emptyMap(), + val completedScreenShareDurationInMillis: Long = 0L, + val uniqueSharingUsers: Set = emptySet() +) diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallRepository.kt index e5f8bc07ec4..f45ce704881 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallRepository.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/CallRepository.kt @@ -40,6 +40,7 @@ import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.id.FederatedIdMapper import com.wire.kalium.logic.data.id.GroupID import com.wire.kalium.logic.data.id.QualifiedClientID +import com.wire.kalium.logic.data.id.QualifiedID import com.wire.kalium.logic.data.id.QualifiedIdMapper import com.wire.kalium.logic.data.id.SubconversationId import com.wire.kalium.logic.data.id.toCrypto @@ -441,6 +442,8 @@ internal class CallDataSource( val currentParticipantIds = call.participants.map { it.userId }.toSet() val newParticipantIds = participants.map { it.userId }.toSet() + val sharingScreenParticipantIds = participants.filter { it.isSharingScreen } + .map { participant -> participant.id } val updatedUsers = call.users.toMutableList() @@ -456,7 +459,11 @@ internal class CallDataSource( this[conversationId] = call.copy( participants = participants, maxParticipants = max(call.maxParticipants, participants.size + 1), - users = updatedUsers + users = updatedUsers, + screenShareMetadata = updateScreenSharingMetadata( + metadata = call.screenShareMetadata, + usersCurrentlySharingScreen = sharingScreenParticipantIds + ) ) } @@ -479,6 +486,43 @@ internal class CallDataSource( } } + /** + * Manages call sharing metadata for analytical purposes by tracking the following: + * - **Active Screen Shares**: Maintains a record of currently active screen shares with their start times (local to the device). + * - **Completed Screen Share Duration**: Accumulates the total duration of screen shares that have already ended. + * - **Unique Sharing Users**: Keeps a unique list of all users who have shared their screen during the call. + * + * To update the metadata, the following steps are performed: + * 1. **Calculate Ended Screen Share Time**: Determine the total time for users who stopped sharing since the last update. + * 2. **Update Active Shares**: Filter out inactive shares and add any new ones, associating them with the current start time. + * 3. **Track Unique Users**: Append ids to current set in order to keep track of unique users. + */ + private fun updateScreenSharingMetadata( + metadata: CallScreenSharingMetadata, + usersCurrentlySharingScreen: List + ): CallScreenSharingMetadata { + val now = DateTimeUtil.currentInstant() + + val alreadyEndedScreenSharesTimeInMillis = metadata.activeScreenShares + .filterKeys { id -> id !in usersCurrentlySharingScreen } + .values + .sumOf { startTime -> DateTimeUtil.calculateMillisDifference(startTime, now) } + + val updatedShares = metadata.activeScreenShares + .filterKeys { id -> id in usersCurrentlySharingScreen } + .plus( + usersCurrentlySharingScreen + .filterNot { id -> metadata.activeScreenShares.containsKey(id) } + .associateWith { now } + ) + + return metadata.copy( + activeScreenShares = updatedShares, + completedScreenShareDurationInMillis = metadata.completedScreenShareDurationInMillis + alreadyEndedScreenSharesTimeInMillis, + uniqueSharingUsers = metadata.uniqueSharingUsers.plus(usersCurrentlySharingScreen.map { id -> id.toString() }) + ) + } + private fun clearStaleParticipantTimeout(participant: ParticipantMinimized) { callingLogger.i("Clear stale participant timer") val qualifiedClient = QualifiedClientID(ClientId(participant.clientId), participant.id) diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/CallRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/CallRepositoryTest.kt index d3751f62295..e1c9cb91886 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/CallRepositoryTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/CallRepositoryTest.kt @@ -23,6 +23,7 @@ import com.wire.kalium.cryptography.CryptoQualifiedClientId import com.wire.kalium.cryptography.MLSClient import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.data.call.CallRepositoryTest.Arrangement.Companion.callerId +import com.wire.kalium.logic.data.call.CallRepositoryTest.Arrangement.Companion.participant import com.wire.kalium.logic.data.call.mapper.CallMapperImpl import com.wire.kalium.logic.data.client.MLSClientProvider import com.wire.kalium.logic.data.conversation.ClientId @@ -84,6 +85,7 @@ import kotlinx.datetime.Clock import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -1509,6 +1511,158 @@ class CallRepositoryTest { ) } + @Test + fun givenCallWithParticipantsNotSharingScreen_whenOneStartsToShare_thenSharingMetadataHasProperValues() = runTest { + // given + val otherParticipant = participant.copy(id = QualifiedID("anotherParticipantId", "participantDomain")) + val participantsList = listOf(participant, otherParticipant) + val (_, callRepository) = Arrangement() + .givenGetKnownUserMinimizedSucceeds() + .arrange() + callRepository.updateCallMetadataProfileFlow( + callMetadataProfile = CallMetadataProfile( + data = mapOf(Arrangement.conversationId to createCallMetadata().copy(participants = participantsList)) + ) + ) + + // when + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, otherParticipant.copy(isSharingScreen = true)) + ) + val callMetadata = callRepository.getCallMetadataProfile()[Arrangement.conversationId] + + // then + assertNotNull(callMetadata) + assertEquals(0L, callMetadata.screenShareMetadata.completedScreenShareDurationInMillis) + assertTrue(callMetadata.screenShareMetadata.activeScreenShares.containsKey(otherParticipant.id)) + } + + @Test + fun givenCallWithParticipantsNotSharingScreen_whenTwoStartsAndOneStops_thenSharingMetadataHasProperValues() = runTest { + // given + val (_, callRepository) = Arrangement() + .arrange() + val secondParticipant = participant.copy(id = QualifiedID("secondParticipantId", "participantDomain")) + val thirdParticipant = participant.copy(id = QualifiedID("thirdParticipantId", "participantDomain")) + val participantsList = listOf(participant, secondParticipant, thirdParticipant) + callRepository.updateCallMetadataProfileFlow( + callMetadataProfile = CallMetadataProfile( + data = mapOf(Arrangement.conversationId to createCallMetadata().copy(participants = participantsList)) + ) + ) + + // when + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, secondParticipant.copy(isSharingScreen = true), thirdParticipant.copy(isSharingScreen = true)) + ) + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, secondParticipant, thirdParticipant.copy(isSharingScreen = true)) + ) + val callMetadata = callRepository.getCallMetadataProfile()[Arrangement.conversationId] + + // then + assertNotNull(callMetadata) + assertTrue(callMetadata.screenShareMetadata.activeScreenShares.size == 1) + assertTrue(callMetadata.screenShareMetadata.activeScreenShares.containsKey(thirdParticipant.id)) + } + + @Test + fun givenCallWithParticipantsSharingScreen_whenOneStopsToShare_thenSharingMetadataHasProperValues() = runTest { + // given + val (_, callRepository) = Arrangement() + .arrange() + val otherParticipant = participant.copy(id = QualifiedID("anotherParticipantId", "participantDomain")) + val participantsList = listOf(participant, otherParticipant) + callRepository.updateCallMetadataProfileFlow( + callMetadataProfile = CallMetadataProfile( + data = mapOf(Arrangement.conversationId to createCallMetadata().copy(participants = participantsList)) + ) + ) + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, otherParticipant.copy(isSharingScreen = true)) + ) + + // when + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, otherParticipant.copy(isSharingScreen = false)) + ) + val callMetadata = callRepository.getCallMetadataProfile()[Arrangement.conversationId] + + // then + assertNotNull(callMetadata) + assertTrue(callMetadata.screenShareMetadata.activeScreenShares.isEmpty()) + } + + @Test + fun givenCallWithParticipantsSharingScreen_whenTheSameParticipantIsSharingMultipleTime_thenSharingMetadataHasUserIdOnlyOnce() = + runTest { + // given + val (_, callRepository) = Arrangement() + .arrange() + val otherParticipant = participant.copy(id = QualifiedID("anotherParticipantId", "participantDomain")) + val participantsList = listOf(participant, otherParticipant) + callRepository.updateCallMetadataProfileFlow( + callMetadataProfile = CallMetadataProfile( + data = mapOf(Arrangement.conversationId to createCallMetadata().copy(participants = participantsList)) + ) + ) + + // when + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, otherParticipant.copy(isSharingScreen = true)) + ) + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, otherParticipant.copy(isSharingScreen = false)) + ) + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, otherParticipant.copy(isSharingScreen = true)) + ) + val callMetadata = callRepository.getCallMetadataProfile()[Arrangement.conversationId] + + // then + assertNotNull(callMetadata) + assertTrue(callMetadata.screenShareMetadata.uniqueSharingUsers.size == 1) + assertTrue(callMetadata.screenShareMetadata.uniqueSharingUsers.contains(otherParticipant.id.toString())) + } + + @Test + fun givenCallWithParticipantsSharingScreen_whenTwoParticipantsAreSharing_thenSharingMetadataHasBothOfUsersIds() = runTest { + // given + val (_, callRepository) = Arrangement() + .arrange() + val secondParticipant = participant.copy(id = QualifiedID("secondParticipantId", "participantDomain")) + val thirdParticipant = participant.copy(id = QualifiedID("thirdParticipantId", "participantDomain")) + val participantsList = listOf(participant, secondParticipant, thirdParticipant) + callRepository.updateCallMetadataProfileFlow( + callMetadataProfile = CallMetadataProfile( + data = mapOf(Arrangement.conversationId to createCallMetadata().copy(participants = participantsList)) + ) + ) + + // when + callRepository.updateCallParticipants( + Arrangement.conversationId, + listOf(participant, secondParticipant.copy(isSharingScreen = true), thirdParticipant.copy(isSharingScreen = true)) + ) + val callMetadata = callRepository.getCallMetadataProfile()[Arrangement.conversationId] + + // then + assertNotNull(callMetadata) + assertTrue(callMetadata.screenShareMetadata.uniqueSharingUsers.size == 2) + assertEquals( + setOf(secondParticipant.id.toString(), thirdParticipant.id.toString()), + callMetadata.screenShareMetadata.uniqueSharingUsers + ) + } + private fun provideCall(id: ConversationId, status: CallStatus) = Call( conversationId = id, status = status,