From 6ef779f0b3e3abe01e31c5c7ab3cd2fbfbf1981d Mon Sep 17 00:00:00 2001 From: "sergei.bakhtiarov" <sbakhtiarov@gmail.com> Date: Thu, 19 Dec 2024 13:03:18 +0100 Subject: [PATCH] feat: send and receive in-call reactions [#WPB-14254] --- .../logic/data/call/InCallReactionMessage.kt | 26 ++++ .../wire/kalium/logic/data/message/Message.kt | 5 + .../logic/data/message/MessageContent.kt | 5 + .../data/message/MessageContentLogging.kt | 1 + .../data/call/InCallReactionsRepository.kt | 53 ++++++++ .../data/message/PersistMessageUseCase.kt | 4 +- .../logic/data/message/ProtoContentMapper.kt | 34 ++++- .../kalium/logic/feature/UserSessionScope.kt | 10 +- .../kalium/logic/feature/call/CallsScope.kt | 11 +- .../usecase/ObserveInCallReactionsUseCase.kt | 38 ++++++ .../SendInCallReactionUseCase.kt | 70 ++++++++++ .../logic/feature/message/MessageScope.kt | 10 ++ .../message/PersistMigratedMessagesUseCase.kt | 1 + .../message/ApplicationMessageHandler.kt | 14 +- .../call/InCallReactionsRepositoryTest.kt | 122 ++++++++++++++++++ .../data/message/ProtoContentMapperTest.kt | 20 ++- .../SendInCallReactionUseCaseTest.kt | 119 +++++++++++++++++ .../message/ApplicationMessageHandlerTest.kt | 40 ++++++ .../src/main/proto/messages.proto | 27 +++- 19 files changed, 588 insertions(+), 22 deletions(-) create mode 100644 data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionMessage.kt create mode 100644 logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepository.kt create mode 100644 logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/usecase/ObserveInCallReactionsUseCase.kt create mode 100644 logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCase.kt create mode 100644 logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepositoryTest.kt create mode 100644 logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCaseTest.kt diff --git a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionMessage.kt b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionMessage.kt new file mode 100644 index 00000000000..ca4e4ea63d2 --- /dev/null +++ b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionMessage.kt @@ -0,0 +1,26 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.data.call + +import com.wire.kalium.logic.data.id.QualifiedID + +data class InCallReactionMessage( + val emojis: Set<String>, + val senderUserId: QualifiedID, + val senderUserName: String? +) diff --git a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/Message.kt b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/Message.kt index 5ea75254462..5f9ae5f1577 100644 --- a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/Message.kt +++ b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/Message.kt @@ -248,6 +248,11 @@ sealed interface Message { typeKey to "dataTransfer", "content" to content.toLogMap(), ) + + is MessageContent.InCallEmoji -> mutableMapOf( + typeKey to "inCallEmoji", + "content" to content.emojis + ) } val standardProperties = mapOf( diff --git a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContent.kt b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContent.kt index b386f096073..e2a6eaf711a 100644 --- a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContent.kt +++ b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContent.kt @@ -395,6 +395,10 @@ sealed interface MessageContent { data object Disabled : ForConversation() } } + + data class InCallEmoji( + val emojis: Map<String, Int> + ) : Signaling } /** @@ -455,6 +459,7 @@ fun MessageContent?.getType() = when (this) { is MessageContent.LegalHold.ForMembers.Disabled -> "LegalHold.ForMembers.Disabled" is MessageContent.LegalHold.ForMembers.Enabled -> "LegalHold.ForMembers.Enabled" is MessageContent.DataTransfer -> "DataTransfer" + is MessageContent.InCallEmoji -> "InCallEmoji" null -> "null" } diff --git a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContentLogging.kt b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContentLogging.kt index 7cbfe0fbd57..d576fa6ddc5 100644 --- a/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContentLogging.kt +++ b/data/src/commonMain/kotlin/com/wire/kalium/logic/data/message/MessageContentLogging.kt @@ -41,4 +41,5 @@ inline fun MessageContent.FromProto.typeDescription(): String = when (this) { is MessageContent.Receipt -> "Receipt" is MessageContent.TextEdited -> "TextEdited" is MessageContent.DataTransfer -> "DataTransfer" + is MessageContent.InCallEmoji -> "InCallEmoji" } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepository.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepository.kt new file mode 100644 index 00000000000..7f02e5e73b7 --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepository.kt @@ -0,0 +1,53 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.data.call + +import com.wire.kalium.logic.data.user.UserId +import com.wire.kalium.logic.data.user.UserRepository +import com.wire.kalium.logic.functional.onSuccess +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow + +internal interface InCallReactionsRepository { + suspend fun addInCallReaction(emojis: Set<String>, senderUserId: UserId) + fun observeInCallReactions(): Flow<InCallReactionMessage> +} + +internal class InCallReactionsDataSource( + private val userRepository: UserRepository, +) : InCallReactionsRepository { + + private val inCallReactionsFlow: MutableSharedFlow<InCallReactionMessage> = + MutableSharedFlow(extraBufferCapacity = BUFFER_SIZE, onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override suspend fun addInCallReaction(emojis: Set<String>, senderUserId: UserId) { + userRepository.userById(senderUserId).onSuccess { user -> + inCallReactionsFlow.emit( + InCallReactionMessage(emojis, senderUserId, user.name) + ) + } + } + + override fun observeInCallReactions(): Flow<InCallReactionMessage> = inCallReactionsFlow.asSharedFlow() + + private companion object { + const val BUFFER_SIZE = 32 // drop after this threshold + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/PersistMessageUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/PersistMessageUseCase.kt index 0d0288de34f..69828375295 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/PersistMessageUseCase.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/PersistMessageUseCase.kt @@ -126,6 +126,7 @@ internal class PersistMessageUseCaseImpl( is MessageContent.MemberChange.RemovedFromTeam -> false is MessageContent.TeamMemberRemoved -> false is MessageContent.DataTransfer -> false + is MessageContent.InCallEmoji -> false } @Suppress("ComplexMethod") @@ -180,6 +181,7 @@ internal class PersistMessageUseCaseImpl( is MessageContent.LegalHold, is MessageContent.MemberChange.RemovedFromTeam, is MessageContent.TeamMemberRemoved, - is MessageContent.DataTransfer -> false + is MessageContent.DataTransfer, + is MessageContent.InCallEmoji -> false } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapper.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapper.kt index 31013c6e633..49f5dc765a2 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapper.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapper.kt @@ -45,6 +45,8 @@ import com.wire.kalium.protobuf.messages.DataTransfer import com.wire.kalium.protobuf.messages.Ephemeral import com.wire.kalium.protobuf.messages.External import com.wire.kalium.protobuf.messages.GenericMessage +import com.wire.kalium.protobuf.messages.GenericMessage.UnknownStrategy +import com.wire.kalium.protobuf.messages.InCallEmoji import com.wire.kalium.protobuf.messages.Knock import com.wire.kalium.protobuf.messages.LastRead import com.wire.kalium.protobuf.messages.LegalHoldStatus @@ -57,7 +59,6 @@ import com.wire.kalium.protobuf.messages.Quote import com.wire.kalium.protobuf.messages.Reaction import com.wire.kalium.protobuf.messages.Text import com.wire.kalium.protobuf.messages.TrackingIdentifier -import com.wire.kalium.protobuf.messages.UnknownStrategy import kotlinx.datetime.Instant import pbandk.ByteArr @@ -146,6 +147,7 @@ class ProtoContentMapperImpl( is MessageContent.Location -> packLocation(readableContent, expectsReadConfirmation, legalHoldStatus) is MessageContent.DataTransfer -> packDataTransfer(readableContent) + is MessageContent.InCallEmoji -> packInCallEmoji(readableContent) } } @@ -266,7 +268,8 @@ class ProtoContentMapperImpl( is MessageContent.ButtonAction, is MessageContent.ButtonActionConfirmation, is MessageContent.TextEdited, - is MessageContent.DataTransfer -> throw IllegalArgumentException( + is MessageContent.DataTransfer, + is MessageContent.InCallEmoji -> throw IllegalArgumentException( "Unexpected message content type: ${readableContent.getType()}" ) } @@ -377,6 +380,8 @@ class ProtoContentMapperImpl( MessageContent.Ignored } + is GenericMessage.Content.InCallEmoji -> unpackInCallEmoji(protoContent) + null -> { kaliumLogger.w( "Null content when parsing protobuf. Message UUID = ${genericMessage.messageId.obfuscateId()}" + @@ -390,6 +395,8 @@ class ProtoContentMapperImpl( null -> MessageContent.Ignored } } + + is GenericMessage.Content.InCallHandRaise -> MessageContent.Ignored } return readableContent } @@ -752,6 +759,29 @@ class ProtoContentMapperImpl( ) } + private fun unpackInCallEmoji(protoContent: GenericMessage.Content.InCallEmoji): MessageContent.InCallEmoji { + return MessageContent.InCallEmoji( + // Map of emoji to senderId + emojis = protoContent.value.emojis + .mapNotNull { + val key = it.key ?: return@mapNotNull null + val value = it.value ?: return@mapNotNull null + key to value + } + .associateBy({ it.first }, { it.second }) + ) + } + + private fun packInCallEmoji(content: MessageContent.InCallEmoji): GenericMessage.Content.InCallEmoji { + return GenericMessage.Content.InCallEmoji( + inCallEmoji = InCallEmoji( + emojis = content.emojis.map { entry -> + InCallEmoji.EmojisEntry(key = entry.key, value = entry.value) + } + ) + ) + } + private fun extractConversationId( qualifiedConversationID: QualifiedConversationId?, unqualifiedConversationID: String diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt index 438fbc5eda3..89f6f418f9e 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/UserSessionScope.kt @@ -40,6 +40,8 @@ import com.wire.kalium.logic.data.asset.KaliumFileSystem import com.wire.kalium.logic.data.asset.KaliumFileSystemImpl import com.wire.kalium.logic.data.call.CallDataSource import com.wire.kalium.logic.data.call.CallRepository +import com.wire.kalium.logic.data.call.InCallReactionsDataSource +import com.wire.kalium.logic.data.call.InCallReactionsRepository import com.wire.kalium.logic.data.call.VideoStateChecker import com.wire.kalium.logic.data.call.VideoStateCheckerImpl import com.wire.kalium.logic.data.call.mapper.CallMapper @@ -1387,7 +1389,8 @@ class UserSessionScope internal constructor( receiptMessageHandler, buttonActionConfirmationHandler, dataTransferEventHandler, - userId + inCallReactionsRepository, + userId, ) private val staleEpochVerifier: StaleEpochVerifier @@ -2066,7 +2069,8 @@ class UserSessionScope internal constructor( userConfigRepository = userConfigRepository, getCallConversationType = getCallConversationType, conversationClientsInCallUpdater = conversationClientsInCallUpdater, - kaliumConfigs = kaliumConfigs + kaliumConfigs = kaliumConfigs, + inCallReactionsRepository = inCallReactionsRepository, ) val connection: ConnectionScope @@ -2165,6 +2169,8 @@ class UserSessionScope internal constructor( ) } + private val inCallReactionsRepository: InCallReactionsRepository = InCallReactionsDataSource(userRepository) + /** * This will start subscribers of observable work per user session, as long as the user is logged in. * When the user logs out, this work will be canceled. diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/CallsScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/CallsScope.kt index a67589af772..9066eccd245 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/CallsScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/CallsScope.kt @@ -22,6 +22,7 @@ import com.wire.kalium.logic.configuration.UserConfigRepository import com.wire.kalium.logic.data.call.CallRepository import com.wire.kalium.logic.data.call.CallingParticipantsOrder import com.wire.kalium.logic.data.call.CallingParticipantsOrderImpl +import com.wire.kalium.logic.data.call.InCallReactionsRepository import com.wire.kalium.logic.data.call.ParticipantsFilterImpl import com.wire.kalium.logic.data.call.ParticipantsOrderByNameImpl import com.wire.kalium.logic.data.conversation.ConversationRepository @@ -43,8 +44,6 @@ import com.wire.kalium.logic.feature.call.usecase.GetAllCallsWithSortedParticipa import com.wire.kalium.logic.feature.call.usecase.GetCallConversationTypeProvider import com.wire.kalium.logic.feature.call.usecase.GetIncomingCallsUseCase import com.wire.kalium.logic.feature.call.usecase.GetIncomingCallsUseCaseImpl -import com.wire.kalium.logic.feature.call.usecase.ObserveConferenceCallingEnabledUseCase -import com.wire.kalium.logic.feature.call.usecase.ObserveConferenceCallingEnabledUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.IsCallRunningUseCase import com.wire.kalium.logic.feature.call.usecase.IsEligibleToStartCallUseCase import com.wire.kalium.logic.feature.call.usecase.IsEligibleToStartCallUseCaseImpl @@ -53,12 +52,16 @@ import com.wire.kalium.logic.feature.call.usecase.IsLastCallClosedUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCase import com.wire.kalium.logic.feature.call.usecase.MuteCallUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveAskCallFeedbackUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveConferenceCallingEnabledUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveConferenceCallingEnabledUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveEndCallDueToConversationDegradationUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEndCallDueToConversationDegradationUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallWithSortedParticipantsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallWithSortedParticipantsUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveEstablishedCallsUseCaseImpl +import com.wire.kalium.logic.feature.call.usecase.ObserveInCallReactionsUseCase +import com.wire.kalium.logic.feature.call.usecase.ObserveInCallReactionsUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCase import com.wire.kalium.logic.feature.call.usecase.ObserveOngoingCallsUseCaseImpl import com.wire.kalium.logic.feature.call.usecase.ObserveOutgoingCallUseCase @@ -103,6 +106,7 @@ class CallsScope internal constructor( private val conversationClientsInCallUpdater: ConversationClientsInCallUpdater, private val getCallConversationType: GetCallConversationTypeProvider, private val kaliumConfigs: KaliumConfigs, + private val inCallReactionsRepository: InCallReactionsRepository, internal val dispatcher: KaliumDispatcher = KaliumDispatcherImpl ) { @@ -239,4 +243,7 @@ class CallsScope internal constructor( get() = ObserveRecentlyEndedCallMetadataUseCaseImpl( callRepository = callRepository ) + + val observeInCallReactions: ObserveInCallReactionsUseCase + get() = ObserveInCallReactionsUseCaseImpl(inCallReactionsRepository) } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/usecase/ObserveInCallReactionsUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/usecase/ObserveInCallReactionsUseCase.kt new file mode 100644 index 00000000000..93e737acd9c --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/call/usecase/ObserveInCallReactionsUseCase.kt @@ -0,0 +1,38 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.call.usecase + +import com.wire.kalium.logic.data.call.InCallReactionMessage +import com.wire.kalium.logic.data.call.InCallReactionsRepository +import kotlinx.coroutines.flow.Flow + +/** + * Observe incoming in-call reactions + */ +interface ObserveInCallReactionsUseCase { + operator fun invoke(): Flow<InCallReactionMessage> +} + +internal class ObserveInCallReactionsUseCaseImpl( + private val inCallReactionsRepository: InCallReactionsRepository, +) : ObserveInCallReactionsUseCase { + + override fun invoke(): Flow<InCallReactionMessage> { + return inCallReactionsRepository.observeInCallReactions() + } +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCase.kt new file mode 100644 index 00000000000..8b57a9735ef --- /dev/null +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCase.kt @@ -0,0 +1,70 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.incallreaction + +import com.benasher44.uuid.uuid4 +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.CurrentClientIdProvider +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.message.Message +import com.wire.kalium.logic.data.message.MessageContent +import com.wire.kalium.logic.feature.message.MessageSender +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.functional.flatMap +import com.wire.kalium.util.KaliumDispatcher +import com.wire.kalium.util.KaliumDispatcherImpl +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.datetime.Clock + +/** + * Sends in-call reaction to the call with the conversationId + */ +class SendInCallReactionUseCase( + private val selfUserId: QualifiedID, + private val provideClientId: CurrentClientIdProvider, + private val messageSender: MessageSender, + private val dispatchers: KaliumDispatcher = KaliumDispatcherImpl, + private val scope: CoroutineScope +) { + + suspend operator fun invoke(conversationId: ConversationId, reaction: String): Either<CoreFailure, Unit> = + scope.async(dispatchers.io) { + + val generatedMessageUuid = uuid4().toString() + + provideClientId().flatMap { clientId -> + val message = Message.Signaling( + id = generatedMessageUuid, + content = MessageContent.InCallEmoji( + emojis = mapOf(reaction to 1) + ), + conversationId = conversationId, + date = Clock.System.now(), + senderUserId = selfUserId, + senderClientId = clientId, + status = Message.Status.Pending, + isSelfMessage = true, + expirationData = null, + ) + + messageSender.sendMessage(message) + } + }.await() +} diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt index bb49c7fda27..b2041cbef35 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/MessageScope.kt @@ -62,6 +62,7 @@ import com.wire.kalium.logic.feature.asset.UpdateAssetMessageTransferStatusUseCa import com.wire.kalium.logic.feature.asset.UpdateAssetMessageTransferStatusUseCaseImpl import com.wire.kalium.logic.feature.asset.ValidateAssetFileTypeUseCase import com.wire.kalium.logic.feature.asset.ValidateAssetFileTypeUseCaseImpl +import com.wire.kalium.logic.feature.incallreaction.SendInCallReactionUseCase import com.wire.kalium.logic.feature.message.composite.SendButtonActionConfirmationMessageUseCase import com.wire.kalium.logic.feature.message.composite.SendButtonActionMessageUseCase import com.wire.kalium.logic.feature.message.composite.SendButtonMessageUseCase @@ -453,4 +454,13 @@ class MessageScope internal constructor( val removeMessageDraftUseCase: RemoveMessageDraftUseCase get() = RemoveMessageDraftUseCaseImpl(messageDraftRepository) + + val sendInCallReactionUseCase: SendInCallReactionUseCase + get() = SendInCallReactionUseCase( + selfUserId = selfUserId, + provideClientId = currentClientIdProvider, + messageSender = messageSender, + dispatchers = dispatcher, + scope = scope, + ) } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/PersistMigratedMessagesUseCase.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/PersistMigratedMessagesUseCase.kt index ce4b80f2ee5..fd5b3509a21 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/PersistMigratedMessagesUseCase.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/feature/message/PersistMigratedMessagesUseCase.kt @@ -212,5 +212,6 @@ internal class PersistMigratedMessagesUseCaseImpl( is MessageContent.ButtonActionConfirmation -> MessageEntity.Visibility.HIDDEN is MessageContent.Location -> MessageEntity.Visibility.VISIBLE is MessageContent.DataTransfer -> MessageEntity.Visibility.HIDDEN + is MessageContent.InCallEmoji -> MessageEntity.Visibility.HIDDEN } } diff --git a/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandler.kt b/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandler.kt index 08f6fc47213..580e3763227 100644 --- a/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandler.kt +++ b/logic/src/commonMain/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandler.kt @@ -19,6 +19,7 @@ package com.wire.kalium.logic.sync.receiver.conversation.message import com.wire.kalium.logger.KaliumLogger.Companion.ApplicationFlow +import com.wire.kalium.logic.data.call.InCallReactionsRepository import com.wire.kalium.logic.data.conversation.ClientId import com.wire.kalium.logic.data.id.ConversationId import com.wire.kalium.logic.data.message.AssetContent @@ -90,7 +91,8 @@ internal class ApplicationMessageHandlerImpl( private val receiptMessageHandler: ReceiptMessageHandler, private val buttonActionConfirmationHandler: ButtonActionConfirmationHandler, private val dataTransferEventHandler: DataTransferEventHandler, - private val selfUserId: UserId + private val inCallReactionsRepository: InCallReactionsRepository, + private val selfUserId: UserId, ) : ApplicationMessageHandler { private val logger by lazy { kaliumLogger.withFeatureId(ApplicationFlow.EVENT_RECEIVER) } @@ -106,18 +108,12 @@ internal class ApplicationMessageHandlerImpl( when (val protoContent = content.messageContent) { is MessageContent.Regular -> { val visibility = when (protoContent) { - is MessageContent.DeleteMessage -> Message.Visibility.HIDDEN - is MessageContent.TextEdited -> Message.Visibility.HIDDEN - is MessageContent.DeleteForMe -> Message.Visibility.HIDDEN is MessageContent.Unknown -> if (protoContent.hidden) Message.Visibility.HIDDEN else Message.Visibility.VISIBLE is MessageContent.Text -> Message.Visibility.VISIBLE - is MessageContent.Calling -> Message.Visibility.VISIBLE is MessageContent.Asset -> Message.Visibility.VISIBLE is MessageContent.Knock -> Message.Visibility.VISIBLE is MessageContent.RestrictedAsset -> Message.Visibility.VISIBLE is MessageContent.FailedDecryption -> Message.Visibility.VISIBLE - is MessageContent.LastRead -> Message.Visibility.HIDDEN - is MessageContent.Cleared -> Message.Visibility.HIDDEN is MessageContent.Composite -> Message.Visibility.VISIBLE is MessageContent.Location -> Message.Visibility.VISIBLE } @@ -222,6 +218,10 @@ internal class ApplicationMessageHandlerImpl( ) is MessageContent.DataTransfer -> dataTransferEventHandler.handle(signaling, content) + is MessageContent.InCallEmoji -> inCallReactionsRepository.addInCallReaction( + emojis = content.emojis.keys, + senderUserId = signaling.senderUserId + ) } } diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepositoryTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepositoryTest.kt new file mode 100644 index 00000000000..7c5afc98736 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/call/InCallReactionsRepositoryTest.kt @@ -0,0 +1,122 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.data.call + +import app.cash.turbine.test +import com.wire.kalium.logic.CoreFailure +import com.wire.kalium.logic.data.id.QualifiedID +import com.wire.kalium.logic.data.user.ConnectionState +import com.wire.kalium.logic.data.user.OtherUser +import com.wire.kalium.logic.data.user.UserAvailabilityStatus +import com.wire.kalium.logic.data.user.UserRepository +import com.wire.kalium.logic.data.user.type.UserType +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.test_util.TestKaliumDispatcher +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.mock +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class InCallReactionsRepositoryTest { + + val sendingUserId: QualifiedID = QualifiedID("userId", "domain") + + @Test + fun whenNewReactionIsAdded_thenRepositoryEmitsNewReactionMessage() = runBlocking { + + // given + val (_, repository) = Arrangement() + .withUserAvailable() + .arrange() + + repository.observeInCallReactions().test { + + // when + repository.addInCallReaction(setOf("1"), sendingUserId) + + // then + assertEquals(InCallReactionMessage(setOf("1"), sendingUserId, "TestUserName"), awaitItem()) + } + } + + @Test + fun whenUserIsNotFound_thenReactionRepositoryDoesNotEmitNewReactionMessage() = runTest(TestKaliumDispatcher.default) { + + // given + val (_, repository) = Arrangement() + .withUserNotAvailable() + .arrange() + + repository.observeInCallReactions().test { + + // when + repository.addInCallReaction(setOf("1"), sendingUserId) + + // then + expectNoEvents() + } + } + + private class Arrangement { + + val user: OtherUser = OtherUser( + id = QualifiedID("userId", "domain"), + name = "TestUserName", + handle = null, + email = null, + phone = null, + accentId = 0, + teamId = null, + connectionStatus = ConnectionState.NOT_CONNECTED, + previewPicture = null, + completePicture = null, + availabilityStatus = UserAvailabilityStatus.AVAILABLE, + expiresAt = null, + supportedProtocols = null, + userType = UserType.INTERNAL, + botService = null, + deleted = false, + defederated = false, + isProteusVerified = false, + ) + + @Mock + val userRepository: UserRepository = mock(UserRepository::class) + + suspend fun withUserAvailable() = apply { + coEvery { + userRepository.userById(any()) + }.returns(Either.Right(user)) + } + + suspend fun withUserNotAvailable() = apply { + coEvery { + userRepository.userById(any()) + }.returns(Either.Left(CoreFailure.Unknown(IllegalStateException("Test user not found")))) + } + + fun arrange() = this to InCallReactionsDataSource( + userRepository = userRepository + ) + } + +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapperTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapperTest.kt index 9bdd5147807..16aa6bf4b26 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapperTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/data/message/ProtoContentMapperTest.kt @@ -29,9 +29,9 @@ import com.wire.kalium.protobuf.encodeToByteArray import com.wire.kalium.protobuf.messages.Asset import com.wire.kalium.protobuf.messages.Confirmation import com.wire.kalium.protobuf.messages.GenericMessage +import com.wire.kalium.protobuf.messages.GenericMessage.UnknownStrategy import com.wire.kalium.protobuf.messages.MessageEdit import com.wire.kalium.protobuf.messages.Text -import com.wire.kalium.protobuf.messages.UnknownStrategy import io.ktor.utils.io.core.toByteArray import kotlin.test.BeforeTest import kotlin.test.Test @@ -516,6 +516,24 @@ class ProtoContentMapperTest { assertEquals(decoded, protoContent) } + @Test + fun givenInCallEmojiContent_whenMappingToProtoDataAndBack_thenTheContentsShouldMatchTheOriginal() { + val messageContent = MessageContent.InCallEmoji( + emojis = mapOf("emoji" to 999) + ) + val protoContent = ProtoContent.Readable( + TEST_MESSAGE_UUID, + messageContent, + false, + legalHoldStatus = Conversation.LegalHoldStatus.UNKNOWN + ) + + val encoded = protoContentMapper.encodeToProtobuf(protoContent) + val decoded = protoContentMapper.decodeFromProtobuf(encoded) + + assertEquals(decoded, protoContent) + } + private companion object { const val TEST_MESSAGE_UUID = "testUuid" val TEST_CONVERSATION_ID = TestConversation.ID diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCaseTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCaseTest.kt new file mode 100644 index 00000000000..92c6fa085f9 --- /dev/null +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/feature/incallreaction/SendInCallReactionUseCaseTest.kt @@ -0,0 +1,119 @@ +/* + * Wire + * Copyright (C) 2024 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.kalium.logic.feature.incallreaction + +import com.wire.kalium.logic.NetworkFailure +import com.wire.kalium.logic.data.conversation.ClientId +import com.wire.kalium.logic.data.id.ConversationId +import com.wire.kalium.logic.data.id.CurrentClientIdProvider +import com.wire.kalium.logic.feature.message.MessageSender +import com.wire.kalium.logic.framework.TestClient +import com.wire.kalium.logic.framework.TestUser +import com.wire.kalium.logic.functional.Either +import com.wire.kalium.logic.test_util.testKaliumDispatcher +import com.wire.kalium.logic.util.shouldFail +import com.wire.kalium.logic.util.shouldSucceed +import io.mockative.Mock +import io.mockative.any +import io.mockative.coEvery +import io.mockative.coVerify +import io.mockative.mock +import io.mockative.once +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class SendInCallReactionUseCaseTest { + + @Mock + val messageSender = mock(MessageSender::class) + + @Test + fun `given established connection when sending should return success`() = runTest { + + // Given + val (arrangement, sendReactionUseCase) = Arrangement(this) + .withCurrentClientProviderSuccess() + .withSendMessageSuccess() + .arrange() + + // When + val result = sendReactionUseCase(ConversationId("id", "domain"), "reaction") + + // Then + result.shouldSucceed() + + coVerify { + arrangement.messageSender.sendMessage(any(), any()) + }.wasInvoked(once) + } + + @Test + fun `given no connection when sending should fail`() = runTest { + + // Given + val (arrangement, sendReactionUseCase) = Arrangement(this) + .withCurrentClientProviderSuccess() + .withSendMessageFailure() + .arrange() + + // When + val result = sendReactionUseCase(ConversationId("id", "domain"), "reaction") + + // Then + result.shouldFail() + + coVerify { + arrangement.messageSender.sendMessage(any(), any()) + }.wasInvoked(once) + } + + private class Arrangement(private val coroutineScope: CoroutineScope) { + @Mock + val messageSender = mock(MessageSender::class) + + @Mock + val currentClientIdProvider = mock(CurrentClientIdProvider::class) + + suspend fun withSendMessageSuccess() = apply { + coEvery { + messageSender.sendMessage(any(), any()) + }.returns(Either.Right(Unit)) + } + + suspend fun withSendMessageFailure() = apply { + coEvery { + messageSender.sendMessage(any(), any()) + }.returns(Either.Left(NetworkFailure.NoNetworkConnection(null))) + } + + suspend fun withCurrentClientProviderSuccess(clientId: ClientId = TestClient.CLIENT_ID) = apply { + coEvery { + currentClientIdProvider.invoke() + }.returns(Either.Right(clientId)) + } + + fun arrange() = this to SendInCallReactionUseCase( + selfUserId = TestUser.SELF.id, + provideClientId = currentClientIdProvider, + messageSender = messageSender, + dispatchers = coroutineScope.testKaliumDispatcher, + scope = coroutineScope, + ) + } +} diff --git a/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandlerTest.kt b/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandlerTest.kt index 6aaa4706e59..5ac65ce589d 100644 --- a/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandlerTest.kt +++ b/logic/src/commonTest/kotlin/com/wire/kalium/logic/sync/receiver/conversation/message/ApplicationMessageHandlerTest.kt @@ -22,6 +22,7 @@ import com.wire.kalium.logic.CoreFailure import com.wire.kalium.logic.StorageFailure import com.wire.kalium.logic.configuration.FileSharingStatus import com.wire.kalium.logic.configuration.UserConfigRepository +import com.wire.kalium.logic.data.call.InCallReactionsRepository import com.wire.kalium.logic.data.conversation.Conversation import com.wire.kalium.logic.data.message.AssetContent import com.wire.kalium.logic.data.message.MessageContent @@ -172,6 +173,41 @@ class ApplicationMessageHandlerTest { }.wasInvoked(exactly = once) } + @Test + fun givenInCallReactionReceived_whenHandling_thenCorrectHandlerIsInvoked() = runTest { + // given + val messageId = "messageId" + val inCallReactionContent = MessageContent.InCallEmoji( + emojis = mapOf("1" to 1) + ) + val protoContent = ProtoContent.Readable( + messageId, + inCallReactionContent, + false, + Conversation.LegalHoldStatus.DISABLED + ) + + val (arrangement, messageHandler) = Arrangement() + .arrange() + + val encodedEncryptedContent = Base64.encodeToBase64("Hello".encodeToByteArray()) + val messageEvent = TestEvent.newMessageEvent(encodedEncryptedContent.decodeToString()) + + // when + messageHandler.handleContent( + messageEvent.conversationId, + messageEvent.messageInstant, + messageEvent.senderUserId, + messageEvent.senderClientId, + protoContent + ) + + // then + coVerify { + arrangement.inCallReactionsRepository.addInCallReaction(setOf("1"), messageEvent.senderUserId) + }.wasInvoked(exactly = once) + } + private class Arrangement { @Mock val persistMessage = mock(PersistMessageUseCase::class) @@ -215,6 +251,9 @@ class ApplicationMessageHandlerTest { @Mock val buttonActionConfirmationHandler = mock(ButtonActionConfirmationHandler::class) + @Mock + val inCallReactionsRepository = mock(InCallReactionsRepository::class) + @Mock val dataTransferEventHandler = mock(DataTransferEventHandler::class) @@ -234,6 +273,7 @@ class ApplicationMessageHandlerTest { receiptMessageHandler, buttonActionConfirmationHandler, dataTransferEventHandler, + inCallReactionsRepository, TestUser.SELF.id ) diff --git a/protobuf-codegen/src/main/proto/messages.proto b/protobuf-codegen/src/main/proto/messages.proto index 9a08423e27f..ea5b30656e9 100644 --- a/protobuf-codegen/src/main/proto/messages.proto +++ b/protobuf-codegen/src/main/proto/messages.proto @@ -45,8 +45,19 @@ message GenericMessage { ButtonAction buttonAction = 21; ButtonActionConfirmation buttonActionConfirmation = 22; DataTransfer dataTransfer = 23; // client-side synchronization across devices of the same user + InCallEmoji inCallEmoji = 24; + // UnknownStrategy unknownStrategy = 25; -- Defined outside the oneof + // Next field should be 26 ↓ + InCallHandRaise inCallHandRaise = 26; + } + optional UnknownStrategy unknownStrategy = 25 [default = IGNORE]; + + // See internal RFC: "2024-07-18 RFC Improve future-proofing for new OTR message types" + enum UnknownStrategy { + IGNORE = 0; // Ignore the message completely. Trash. Bye + DISCARD_AND_WARN = 1; // Warn the user, but discard the message, as it won't be helpful in the future. + WARN_USER_ALLOW_RETRY = 2; // Warn the user. Client has freedom to store it and retry in the future. } - optional UnknownStrategy unknownStrategy = 24 [default = IGNORE]; } message QualifiedUserId { @@ -332,6 +343,14 @@ message Reaction { optional LegalHoldStatus legal_hold_status = 3 [default = UNKNOWN]; // whether this message was sent to legal hold } +message InCallEmoji { + map<string, int32> emojis = 1; +} + +message InCallHandRaise { + required bool is_hand_up = 1; // true if the hand is raised, false if lowered +} + message Calling { required string content = 1; optional QualifiedConversationId qualified_conversation_id = 2; @@ -362,9 +381,3 @@ enum LegalHoldStatus { DISABLED = 1; ENABLED = 2; } - -enum UnknownStrategy { - IGNORE = 0; // Ignore the message completely. - DISCARD_AND_WARN = 1; // Warn the user, but discard the message, as it may not be helpful in the future - WARN_USER_ALLOW_RETRY = 2; // Warn the user. Client has freedom to store it and retry in the future. -}