diff --git a/changelog.d/6074.bugfix b/changelog.d/6074.bugfix new file mode 100644 index 0000000000..692dce28d7 --- /dev/null +++ b/changelog.d/6074.bugfix @@ -0,0 +1 @@ +Poll refactoring with unit tests diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt index b5f49d7f9c..9208ff219b 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/SessionModule.kt @@ -87,6 +87,8 @@ import org.matrix.android.sdk.internal.session.integrationmanager.IntegrationMan import org.matrix.android.sdk.internal.session.openid.DefaultOpenIdService import org.matrix.android.sdk.internal.session.permalinks.DefaultPermalinkService import org.matrix.android.sdk.internal.session.room.EventRelationsAggregationProcessor +import org.matrix.android.sdk.internal.session.room.aggregation.poll.DefaultPollAggregationProcessor +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor import org.matrix.android.sdk.internal.session.room.create.RoomCreateEventProcessor import org.matrix.android.sdk.internal.session.room.prune.RedactionEventProcessor import org.matrix.android.sdk.internal.session.room.send.queue.EventSenderProcessor @@ -385,4 +387,7 @@ internal abstract class SessionModule { @Binds abstract fun bindEventSenderProcessor(processor: EventSenderProcessorCoroutine): EventSenderProcessor + + @Binds + abstract fun bindPollAggregationProcessor(processor: DefaultPollAggregationProcessor): PollAggregationProcessor } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index af9c0071fe..c44f88b93d 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -16,7 +16,6 @@ package org.matrix.android.sdk.internal.session.room import io.realm.Realm -import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.crypto.verification.VerificationState import org.matrix.android.sdk.api.session.events.model.AggregatedAnnotation @@ -28,23 +27,16 @@ import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventCon import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.room.getTimelineEvent -import org.matrix.android.sdk.api.session.room.model.PollSummaryContent import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent -import org.matrix.android.sdk.api.session.room.model.VoteInfo -import org.matrix.android.sdk.api.session.room.model.VoteSummary import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent -import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.internal.SessionManager import org.matrix.android.sdk.internal.crypto.verification.toState import org.matrix.android.sdk.internal.database.helper.findRootThreadEvent @@ -55,7 +47,6 @@ import org.matrix.android.sdk.internal.database.model.EditionOfEvent import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType -import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntity import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntityFields import org.matrix.android.sdk.internal.database.model.ReferencesAggregatedSummaryEntity @@ -68,6 +59,7 @@ import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource import org.matrix.android.sdk.internal.util.time.Clock import timber.log.Timber @@ -79,6 +71,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( @SessionId private val sessionId: String, private val sessionManager: SessionManager, private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor, + private val pollAggregationProcessor: PollAggregationProcessor, private val clock: Clock, ) : EventInsertLiveProcessor { @@ -162,9 +155,8 @@ internal class EventRelationsAggregationProcessor @Inject constructor( // A replace! handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) } else if (event.getClearType() in EventType.POLL_RESPONSE) { - event.getClearContent().toModel(catchError = true)?.let { pollResponseContent -> - Timber.v("###RESPONSE in room $roomId for event ${event.eventId}") - handleResponse(realm, event, pollResponseContent, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId) + sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> + pollAggregationProcessor.handlePollResponseEvent(session, realm, event) } } } @@ -184,12 +176,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } in EventType.POLL_RESPONSE -> { event.getClearContent().toModel(catchError = true)?.let { - handleResponse(realm, event, it, roomId, isLocalEcho, event.getRelationContent()?.eventId) + sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> + pollAggregationProcessor.handlePollResponseEvent(session, realm, event) + } } } in EventType.POLL_END -> { - event.content.toModel(catchError = true)?.let { - handleEndPoll(realm, event, it, roomId, isLocalEcho) + sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> + getPowerLevelsHelper(event.roomId)?.let { + pollAggregationProcessor.handlePollEndEvent(session, it, realm, event) + } } } in EventType.BEACON_LOCATION_DATA -> { @@ -245,12 +241,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } in EventType.POLL_RESPONSE -> { event.content.toModel(catchError = true)?.let { - handleResponse(realm, event, it, roomId, isLocalEcho) + sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> + pollAggregationProcessor.handlePollResponseEvent(session, realm, event) + } } } in EventType.POLL_END -> { - event.content.toModel(catchError = true)?.let { - handleEndPoll(realm, event, it, roomId, isLocalEcho) + sessionManager.getSessionComponent(sessionId)?.session()?.let { session -> + getPowerLevelsHelper(event.roomId)?.let { + pollAggregationProcessor.handlePollEndEvent(session, it, realm, event) + } } } in EventType.STATE_ROOM_BEACON_INFO -> { @@ -318,22 +318,6 @@ internal class EventRelationsAggregationProcessor @Inject constructor( return } - ContentMapper - .map(eventAnnotationsSummaryEntity.pollResponseSummary?.aggregatedContent) - ?.toModel() - ?.let { existingPollSummaryContent -> - eventAnnotationsSummaryEntity.pollResponseSummary?.aggregatedContent = ContentMapper.map( - PollSummaryContent( - myVote = existingPollSummaryContent.myVote, - votes = emptyList(), - votesSummary = emptyMap(), - totalVotes = 0, - winnerVoteCount = 0, - ) - .toContent() - ) - } - val txId = event.unsignedData?.transactionId // is it a remote echo? if (!isLocalEcho && existingSummary.editions.any { it.eventId == txId }) { @@ -363,6 +347,10 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } } + if (event.getClearType() in EventType.POLL_START) { + pollAggregationProcessor.handlePollStartEvent(realm, event) + } + if (!isLocalEcho) { val replaceEvent = TimelineEventEntity .where(realm, roomId, eventId) @@ -392,173 +380,10 @@ internal class EventRelationsAggregationProcessor @Inject constructor( } } - private fun handleResponse(realm: Realm, - event: Event, - content: MessagePollResponseContent, - roomId: String, - isLocalEcho: Boolean, - relatedEventId: String? = null) { - val eventId = event.eventId ?: return - val senderId = event.senderId ?: return - val targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return - val eventTimestamp = event.originServerTs ?: return - - val targetPollContent = getPollContent(roomId, targetEventId) ?: return - - // ok, this is a poll response - var existing = EventAnnotationsSummaryEntity.where(realm, roomId, targetEventId).findFirst() - if (existing == null) { - Timber.v("## POLL creating new relation summary for $targetEventId") - existing = EventAnnotationsSummaryEntity.create(realm, roomId, targetEventId) - } - - // we have it - val existingPollSummary = existing.pollResponseSummary - ?: realm.createObject(PollResponseAggregatedSummaryEntity::class.java).also { - existing.pollResponseSummary = it - } - - val closedTime = existingPollSummary.closedTime - if (closedTime != null && eventTimestamp > closedTime) { - Timber.v("## POLL is closed ignore event poll:$targetEventId, event :${event.eventId}") - return - } - - val currentModel = ContentMapper.map(existingPollSummary.aggregatedContent).toModel() - - if (existingPollSummary.sourceEvents.contains(eventId)) { - // ignore this event, we already know it (??) - Timber.v("## POLL ignoring event for summary, it's known eventId:$eventId") - return - } - val txId = event.unsignedData?.transactionId - // is it a remote echo? - if (!isLocalEcho && existingPollSummary.sourceLocalEchoEvents.contains(txId)) { - // ok it has already been managed - Timber.v("## POLL Receiving remote echo of response eventId:$eventId") - existingPollSummary.sourceLocalEchoEvents.remove(txId) - existingPollSummary.sourceEvents.add(event.eventId) - return - } - - val option = content.getBestResponse()?.answers?.first() ?: return Unit.also { - Timber.d("## POLL Ignoring malformed response no option eventId:$eventId content: ${event.content}") - } - - // Check if this option is in available options - if (!targetPollContent.getBestPollCreationInfo()?.answers?.map { it.id }?.contains(option).orFalse()) { - Timber.v("## POLL $targetEventId doesn't contain option $option") - return - } - - val votes = currentModel?.votes.orEmpty().toMutableList() - - var myVote: String? = null - val existingVoteIndex = votes.indexOfFirst { it.userId == senderId } - if (existingVoteIndex != -1) { - // Is the vote newer? - val existingVote = votes[existingVoteIndex] - if (existingVote.voteTimestamp < eventTimestamp) { - // Take the new one - votes[existingVoteIndex] = VoteInfo(senderId, option, eventTimestamp) - if (userId == senderId) { - myVote = option - } - Timber.v("## POLL adding vote $option for user $senderId in poll :$targetEventId ") - } else { - Timber.v("## POLL Ignoring vote (older than known one) eventId:$eventId ") - } - } else { - votes.add(VoteInfo(senderId, option, eventTimestamp)) - if (userId == senderId) { - myVote = option - } - Timber.v("## POLL adding vote $option for user $senderId in poll :$targetEventId ") - } - - // Precompute the percentage of votes for all options - val totalVotes = votes.size - val newVotesSummary = votes - .groupBy({ it.option }, { it.userId }) - .mapValues { - VoteSummary( - total = it.value.size, - percentage = if (totalVotes == 0 && it.value.isEmpty()) 0.0 else it.value.size.toDouble() / totalVotes - ) - } - val newWinnerVoteCount = newVotesSummary.maxOf { it.value.total } - - if (isLocalEcho) { - existingPollSummary.sourceLocalEchoEvents.add(eventId) - } else { - existingPollSummary.sourceEvents.add(eventId) - } - - val newSumModel = PollSummaryContent( - myVote = myVote, - votes = votes, - votesSummary = newVotesSummary, - totalVotes = totalVotes, - winnerVoteCount = newWinnerVoteCount - ) - - existingPollSummary.aggregatedContent = ContentMapper.map(newSumModel.toContent()) - } - - private fun handleEndPoll(realm: Realm, - event: Event, - content: MessageEndPollContent, - roomId: String, - isLocalEcho: Boolean) { - val pollEventId = content.relatesTo?.eventId ?: return - val pollOwnerId = getPollEvent(roomId, pollEventId)?.root?.senderId - val isPollOwner = pollOwnerId == event.senderId - val powerLevelsHelper = stateEventDataSource.getStateEvent(roomId, EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.NoCondition) + private fun getPowerLevelsHelper(roomId: String): PowerLevelsHelper? { + return stateEventDataSource.getStateEvent(roomId, EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.NoCondition) ?.content?.toModel() ?.let { PowerLevelsHelper(it) } - - if (!isPollOwner && !powerLevelsHelper?.isUserAbleToRedact(event.senderId ?: "").orFalse()) { - Timber.v("## Received poll.end event $pollEventId but user ${event.senderId} doesn't have enough power level in room $roomId") - return - } - - var existingPoll = EventAnnotationsSummaryEntity.where(realm, roomId, pollEventId).findFirst() - if (existingPoll == null) { - Timber.v("## POLL creating new relation summary for $pollEventId") - existingPoll = EventAnnotationsSummaryEntity.create(realm, roomId, pollEventId) - } - - // we have it - val existingPollSummary = existingPoll.pollResponseSummary - ?: realm.createObject(PollResponseAggregatedSummaryEntity::class.java).also { - existingPoll.pollResponseSummary = it - } - - val txId = event.unsignedData?.transactionId - existingPollSummary.closedTime = event.originServerTs - - // is it a remote echo? - if (!isLocalEcho && existingPollSummary.sourceLocalEchoEvents.contains(txId)) { - // ok it has already been managed - Timber.v("## POLL Receiving remote echo of response eventId:$pollEventId") - existingPollSummary.sourceLocalEchoEvents.remove(txId) - existingPollSummary.sourceEvents.add(event.eventId) - } - } - - private fun getPollEvent(roomId: String, eventId: String): TimelineEvent? { - val session = sessionManager.getSessionComponent(sessionId)?.session() - return session?.roomService()?.getRoom(roomId)?.getTimelineEvent(eventId) ?: return null.also { - Timber.v("## POLL target poll event $eventId not found in room $roomId") - } - } - - private fun getPollContent(roomId: String, eventId: String): MessagePollContent? { - val pollEvent = getPollEvent(roomId, eventId) ?: return null - - return pollEvent.getLastMessageContent() as? MessagePollContent ?: return null.also { - Timber.v("## POLL target poll event $eventId content is malformed") - } } private fun handleInitialAggregatedRelations(realm: Realm, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt new file mode 100644 index 0000000000..d4b414aaea --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.matrix.android.sdk.internal.session.room.aggregation.poll + +import io.realm.Realm +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.LocalEcho +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.getRelationContent +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.getTimelineEvent +import org.matrix.android.sdk.api.session.room.model.PollSummaryContent +import org.matrix.android.sdk.api.session.room.model.VoteInfo +import org.matrix.android.sdk.api.session.room.model.VoteSummary +import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent +import org.matrix.android.sdk.internal.database.mapper.ContentMapper +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity +import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.query.create +import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.where +import javax.inject.Inject + +class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationProcessor { + + override fun handlePollStartEvent(realm: Realm, event: Event): Boolean { + val content = event.getClearContent()?.toModel() + if (content?.relatesTo?.type != RelationType.REPLACE) { + return false + } + + val roomId = event.roomId ?: return false + val targetEventId = content.relatesTo.eventId ?: return false + + EventAnnotationsSummaryEntity.getOrCreate(realm, roomId, targetEventId).let { eventAnnotationsSummaryEntity -> + ContentMapper + .map(eventAnnotationsSummaryEntity.pollResponseSummary?.aggregatedContent) + ?.toModel() + ?.let { existingPollSummaryContent -> + eventAnnotationsSummaryEntity.pollResponseSummary?.aggregatedContent = ContentMapper.map( + PollSummaryContent( + myVote = existingPollSummaryContent.myVote, + votes = emptyList(), + votesSummary = emptyMap(), + totalVotes = 0, + winnerVoteCount = 0, + ) + .toContent() + ) + } + } + return true + } + + override fun handlePollResponseEvent(session: Session, realm: Realm, event: Event): Boolean { + val content = event.getClearContent()?.toModel() ?: return false + val roomId = event.roomId ?: return false + val senderId = event.senderId ?: return false + val targetEventId = (event.getRelationContent() ?: content.relatesTo)?.eventId ?: return false + val targetPollContent = getPollContent(session, roomId, targetEventId) ?: return false + + val annotationsSummaryEntity = getAnnotationsSummaryEntity(realm, roomId, targetEventId) + val aggregatedPollSummaryEntity = getAggregatedPollSummaryEntity(realm, annotationsSummaryEntity) + + val closedTime = aggregatedPollSummaryEntity.closedTime + val responseTime = event.originServerTs ?: return false + if (closedTime != null && responseTime > closedTime) { + return false + } + + if (aggregatedPollSummaryEntity.sourceEvents.contains(event.eventId)) { + return false + } + + val txId = event.unsignedData?.transactionId + val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "") + if (!isLocalEcho && aggregatedPollSummaryEntity.sourceLocalEchoEvents.contains(txId)) { + aggregatedPollSummaryEntity.sourceLocalEchoEvents.remove(txId) + aggregatedPollSummaryEntity.sourceEvents.add(event.eventId) + return false + } + + val vote = content.getBestResponse()?.answers?.first() ?: return false + if (!targetPollContent.getBestPollCreationInfo()?.answers?.map { it.id }?.contains(vote).orFalse()) { + return false + } + + val pollSummaryModel = ContentMapper.map(aggregatedPollSummaryEntity.aggregatedContent).toModel() + val existingVotes = pollSummaryModel?.votes.orEmpty().toMutableList() + val existingVoteIndex = existingVotes.indexOfFirst { it.userId == senderId } + + if (existingVoteIndex != -1) { + val existingVote = existingVotes[existingVoteIndex] + if (existingVote.voteTimestamp > responseTime) { + return false + } + existingVotes[existingVoteIndex] = VoteInfo(senderId, vote, responseTime) + } else { + existingVotes.add(VoteInfo(senderId, vote, responseTime)) + } + + // Precompute the percentage of votes for all options + val totalVotes = existingVotes.size + val newVotesSummary = existingVotes + .groupBy({ it.option }, { it.userId }) + .mapValues { + VoteSummary( + total = it.value.size, + percentage = if (totalVotes == 0 && it.value.isEmpty()) 0.0 else it.value.size.toDouble() / totalVotes + ) + } + val newWinnerVoteCount = newVotesSummary.maxOf { it.value.total } + + if (isLocalEcho) { + aggregatedPollSummaryEntity.sourceLocalEchoEvents.add(event.eventId) + } else { + aggregatedPollSummaryEntity.sourceEvents.add(event.eventId) + } + + val myVote = existingVotes.find { it.userId == session.myUserId }?.option + + val newSumModel = PollSummaryContent( + myVote = myVote, + votes = existingVotes, + votesSummary = newVotesSummary, + totalVotes = totalVotes, + winnerVoteCount = newWinnerVoteCount + ) + aggregatedPollSummaryEntity.aggregatedContent = ContentMapper.map(newSumModel.toContent()) + + return true + } + + override fun handlePollEndEvent(session: Session, powerLevelsHelper: PowerLevelsHelper, realm: Realm, event: Event): Boolean { + val content = event.getClearContent()?.toModel() ?: return false + val roomId = event.roomId ?: return false + val pollEventId = content.relatesTo?.eventId ?: return false + val pollOwnerId = getPollEvent(session, roomId, pollEventId)?.root?.senderId + val isPollOwner = pollOwnerId == event.senderId + + if (!isPollOwner && !powerLevelsHelper.isUserAbleToRedact(event.senderId ?: "")) { + return false + } + + val annotationsSummaryEntity = getAnnotationsSummaryEntity(realm, roomId, pollEventId) + val aggregatedPollSummaryEntity = getAggregatedPollSummaryEntity(realm, annotationsSummaryEntity) + + val txId = event.unsignedData?.transactionId + aggregatedPollSummaryEntity.closedTime = event.originServerTs + + val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "") + if (!isLocalEcho && aggregatedPollSummaryEntity.sourceLocalEchoEvents.contains(txId)) { + aggregatedPollSummaryEntity.sourceLocalEchoEvents.remove(txId) + aggregatedPollSummaryEntity.sourceEvents.add(event.eventId) + } + + return true + } + + private fun getPollEvent(session: Session, roomId: String, eventId: String): TimelineEvent? { + return session.roomService().getRoom(roomId)?.getTimelineEvent(eventId) + } + + private fun getPollContent(session: Session, roomId: String, eventId: String): MessagePollContent? { + val pollEvent = getPollEvent(session, roomId, eventId) + return pollEvent?.getLastMessageContent() as? MessagePollContent + } + + private fun getAnnotationsSummaryEntity(realm: Realm, roomId: String, eventId: String): EventAnnotationsSummaryEntity { + return EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst() + ?: EventAnnotationsSummaryEntity.create(realm, roomId, eventId) + } + + private fun getAggregatedPollSummaryEntity(realm: Realm, + eventAnnotationsSummaryEntity: EventAnnotationsSummaryEntity): PollResponseAggregatedSummaryEntity { + return eventAnnotationsSummaryEntity.pollResponseSummary + ?: realm.createObject(PollResponseAggregatedSummaryEntity::class.java).also { + eventAnnotationsSummaryEntity.pollResponseSummary = it + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessor.kt new file mode 100644 index 0000000000..848643b435 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessor.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.matrix.android.sdk.internal.session.room.aggregation.poll + +import io.realm.Realm +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper + +interface PollAggregationProcessor { + /** + * Poll start events don't need to be processed by the aggregator. + * This function will only handle if the poll is edited and will update the poll summary entity. + * Returns true if the event is aggregated. + */ + fun handlePollStartEvent( + realm: Realm, + event: Event + ): Boolean + + /** + * Aggregates poll response event after many conditional checks like if the poll is ended, if the user is changing his/her vote etc. + * Returns true if the event is aggregated. + */ + fun handlePollResponseEvent( + session: Session, + realm: Realm, + event: Event + ): Boolean + + /** + * Updates poll summary entity and mark it is ended after many conditional checks like if the poll is already ended etc. + * Returns true if the event is aggregated. + */ + fun handlePollEndEvent( + session: Session, + powerLevelsHelper: PowerLevelsHelper, + realm: Realm, + event: Event + ): Boolean +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt new file mode 100644 index 0000000000..837bbeea26 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.matrix.android.sdk.internal.session.room.aggregation.poll + +import io.mockk.every +import io.mockk.mockk +import io.realm.RealmList +import io.realm.RealmModel +import io.realm.RealmQuery +import org.amshove.kluent.shouldBeFalse +import org.amshove.kluent.shouldBeTrue +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.getTimelineEvent +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntityFields +import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntity +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.AN_EVENT_ID +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.AN_INVALID_POLL_RESPONSE_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_BROKEN_POLL_REPLACE_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_END_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_REFERENCE_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_REPLACE_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_RESPONSE_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_RESPONSE_EVENT_WITH_A_WRONG_REFERENCE +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_START_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_ROOM_ID +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_TIMELINE_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_USER_ID_1 +import org.matrix.android.sdk.test.fakes.FakeRealm + +class PollAggregationProcessorTest { + + private val pollAggregationProcessor: PollAggregationProcessor = DefaultPollAggregationProcessor() + private val realm = FakeRealm() + private val session = mockk() + + @Before + fun setup() { + mockEventAnnotationsSummaryEntity() + mockRoom(A_ROOM_ID, AN_EVENT_ID) + every { session.myUserId } returns A_USER_ID_1 + } + + @Test + fun `given a poll start event, when processing, then is ignored and returns false`() { + pollAggregationProcessor.handlePollStartEvent(realm.instance, A_POLL_START_EVENT).shouldBeFalse() + } + + @Test + fun `given a poll start event with a reference, when processing, then is ignored and returns false`() { + pollAggregationProcessor.handlePollStartEvent(realm.instance, A_POLL_REFERENCE_EVENT).shouldBeFalse() + } + + @Test + fun `given a poll start event with a replace relation but without a target event id, when processing, then is ignored and returns false`() { + pollAggregationProcessor.handlePollStartEvent(realm.instance, A_BROKEN_POLL_REPLACE_EVENT).shouldBeFalse() + } + + @Test + fun `given a poll start event with a replace, when processing, then is processed and returns true`() { + pollAggregationProcessor.handlePollStartEvent(realm.instance, A_POLL_REPLACE_EVENT).shouldBeTrue() + } + + @Test + fun `given a poll response event with a broken reference, when processing, then is ignored and returns false`() { + pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT_WITH_A_WRONG_REFERENCE).shouldBeFalse() + } + + @Test + fun `given a poll response event with a reference, when processing, then is processed and returns true`() { + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT).shouldBeTrue() + } + + @Test + fun `given a poll response event after poll is closed, when processing, then is ignored and returns false`() { + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity().apply { + closedTime = (A_POLL_RESPONSE_EVENT.originServerTs ?: 0) - 1 + } + pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT).shouldBeFalse() + } + + @Test + fun `given a poll response event which is already processed, when processing, then is ignored and returns false`() { + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity().apply { + sourceEvents = RealmList(A_POLL_RESPONSE_EVENT.eventId) + } + pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT).shouldBeFalse() + } + + @Test + fun `given a poll response event which is not one of the options, when processing, then is ignored and returns false`() { + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, AN_INVALID_POLL_RESPONSE_EVENT).shouldBeFalse() + } + + @Test + fun `given a poll end event, when processing, then is processed and return true`() { + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true) + pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() + } + + @Test + fun `given a poll end event for my own poll without enough redaction power level, when processing, then is processed and returns true`() { + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, false) + pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() + } + + @Test + fun `given a poll end event without enough redaction power level, when is processed, then is ignored and return false`() { + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + val powerLevelsHelper = mockRedactionPowerLevels("another-sender-id", false) + val event = A_POLL_END_EVENT.copy(senderId = "another-sender-id") + pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, event).shouldBeFalse() + } + + private inline fun RealmQuery.givenEqualTo(fieldName: String, value: String, result: RealmQuery) { + every { equalTo(fieldName, value) } returns result + } + + private fun mockEventAnnotationsSummaryEntity() { + val queryResult = realm.givenWhereReturns(result = EventAnnotationsSummaryEntity()) + queryResult.givenEqualTo(EventAnnotationsSummaryEntityFields.ROOM_ID, A_POLL_REPLACE_EVENT.roomId!!, queryResult) + queryResult.givenEqualTo(EventAnnotationsSummaryEntityFields.EVENT_ID, A_POLL_REPLACE_EVENT.eventId!!, queryResult) + } + + private fun mockRoom( + roomId: String, + eventId: String + ) { + val room = mockk() + every { session.getRoom(roomId) } returns room + every { room.getTimelineEvent(eventId) } returns A_TIMELINE_EVENT + } + + private fun mockRedactionPowerLevels(userId: String, isAbleToRedact: Boolean): PowerLevelsHelper { + val powerLevelsHelper = mockk() + every { powerLevelsHelper.isUserAbleToRedact(userId) } returns isAbleToRedact + return powerLevelsHelper + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt new file mode 100644 index 0000000000..129d49633e --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt @@ -0,0 +1,171 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.matrix.android.sdk.internal.session.room.aggregation.poll + +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent +import org.matrix.android.sdk.api.session.room.model.message.PollAnswer +import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo +import org.matrix.android.sdk.api.session.room.model.message.PollQuestion +import org.matrix.android.sdk.api.session.room.model.message.PollResponse +import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +object PollEventsTestData { + internal const val A_USER_ID_1 = "@user_1:matrix.org" + internal const val A_ROOM_ID = "!sUeOGZKsBValPTUMax:matrix.org" + internal const val AN_EVENT_ID = "\$vApgexcL8Vfh-WxYKsFKCDooo67ttbjm3TiVKXaWijU" + + internal val A_POLL_CONTENT = MessagePollContent( + unstablePollCreationInfo = PollCreationInfo( + question = PollQuestion( + unstableQuestion = "What is your favourite coffee?" + ), + maxSelections = 1, + answers = listOf( + PollAnswer( + id = "5ef5f7b0-c9a1-49cf-a0b3-374729a43e76", + unstableAnswer = "Double Espresso" + ), + PollAnswer( + id = "ec1a4db0-46d8-4d7a-9bb6-d80724715938", + unstableAnswer = "Macchiato" + ), + PollAnswer( + id = "3677ca8e-061b-40ab-bffe-b22e4e88fcad", + unstableAnswer = "Iced Coffee" + ) + ) + ) + ) + + internal val A_POLL_RESPONSE_CONTENT = MessagePollResponseContent( + unstableResponse = PollResponse( + answers = listOf("5ef5f7b0-c9a1-49cf-a0b3-374729a43e76") + ), + relatesTo = RelationDefaultContent( + type = RelationType.REFERENCE, + eventId = AN_EVENT_ID + ) + ) + + internal val A_POLL_END_CONTENT = MessageEndPollContent( + relatesTo = RelationDefaultContent( + type = RelationType.REFERENCE, + eventId = AN_EVENT_ID + ) + ) + + internal val AN_INVALID_POLL_RESPONSE_CONTENT = MessagePollResponseContent( + unstableResponse = PollResponse( + answers = listOf("fake-option-id") + ), + relatesTo = RelationDefaultContent( + type = RelationType.REFERENCE, + eventId = AN_EVENT_ID + ) + ) + + internal val A_POLL_START_EVENT = Event( + type = EventType.POLL_START.first(), + eventId = AN_EVENT_ID, + originServerTs = 1652435922563, + senderId = A_USER_ID_1, + roomId = A_ROOM_ID, + content = A_POLL_CONTENT.toContent() + ) + + internal val A_POLL_RESPONSE_EVENT = Event( + type = EventType.POLL_RESPONSE.first(), + eventId = AN_EVENT_ID, + originServerTs = 1652435922563, + senderId = A_USER_ID_1, + roomId = A_ROOM_ID, + content = A_POLL_RESPONSE_CONTENT.toContent() + ) + + internal val A_POLL_END_EVENT = Event( + type = EventType.POLL_END.first(), + eventId = AN_EVENT_ID, + originServerTs = 1652435922563, + senderId = A_USER_ID_1, + roomId = A_ROOM_ID, + content = A_POLL_END_CONTENT.toContent() + ) + + internal val A_TIMELINE_EVENT = TimelineEvent( + root = A_POLL_START_EVENT, + localId = 1234, + eventId = AN_EVENT_ID, + displayIndex = 0, + senderInfo = SenderInfo(A_USER_ID_1, "A_USER_ID_1", true, null) + ) + + internal val A_POLL_RESPONSE_EVENT_WITH_A_WRONG_REFERENCE = A_POLL_RESPONSE_EVENT.copy( + content = A_POLL_RESPONSE_CONTENT + .copy( + relatesTo = RelationDefaultContent( + type = RelationType.REPLACE, + eventId = null + ) + ) + .toContent() + ) + + internal val A_POLL_REPLACE_EVENT = A_POLL_START_EVENT.copy( + content = A_POLL_CONTENT + .copy( + relatesTo = RelationDefaultContent( + type = RelationType.REPLACE, + eventId = AN_EVENT_ID + ) + ) + .toContent() + ) + + internal val A_BROKEN_POLL_REPLACE_EVENT = A_POLL_START_EVENT.copy( + content = A_POLL_CONTENT + .copy( + relatesTo = RelationDefaultContent( + type = RelationType.REPLACE, + eventId = null + ) + ) + .toContent() + ) + + internal val A_POLL_REFERENCE_EVENT = A_POLL_START_EVENT.copy( + content = A_POLL_CONTENT + .copy( + relatesTo = RelationDefaultContent( + type = RelationType.REFERENCE, + eventId = AN_EVENT_ID + ) + ) + .toContent() + ) + + internal val AN_INVALID_POLL_RESPONSE_EVENT = A_POLL_RESPONSE_EVENT.copy( + content = AN_INVALID_POLL_RESPONSE_CONTENT.toContent() + ) +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt new file mode 100644 index 0000000000..c07f8e1873 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.every +import io.mockk.mockk +import io.realm.Realm +import io.realm.RealmModel +import io.realm.RealmQuery +import io.realm.kotlin.where + +internal class FakeRealm { + + val instance = mockk(relaxed = true) + + inline fun givenWhereReturns(result: T?): RealmQuery { + val queryResult = mockk>() + every { queryResult.findFirst() } returns result + every { instance.where() } returns queryResult + return queryResult + } +}