Merge pull request #8003 from vector-im/feature/mna/fetch-poll-history-timeline

[Poll] Unmock poll history timeline (PSG-1045, PSG-1095)
This commit is contained in:
Maxime NATUREL 2023-01-26 17:18:56 +01:00 committed by GitHub
commit 3ab465ea93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 2793 additions and 546 deletions

1
changelog.d/7864.sdk Normal file
View file

@ -0,0 +1 @@
[Poll] Adding PollHistoryService

1
changelog.d/7864.wip Normal file
View file

@ -0,0 +1 @@
[Poll] History list: unmock data

View file

@ -28,6 +28,7 @@ import org.matrix.android.sdk.api.session.room.model.LocalRoomSummary
import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.relation.RelationService import org.matrix.android.sdk.api.session.room.model.relation.RelationService
import org.matrix.android.sdk.api.session.room.notification.RoomPushRuleService import org.matrix.android.sdk.api.session.room.notification.RoomPushRuleService
import org.matrix.android.sdk.api.session.room.poll.PollHistoryService
import org.matrix.android.sdk.api.session.room.read.ReadService import org.matrix.android.sdk.api.session.room.read.ReadService
import org.matrix.android.sdk.api.session.room.reporting.ReportingService import org.matrix.android.sdk.api.session.room.reporting.ReportingService
import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.DraftService
@ -181,4 +182,9 @@ interface Room {
* Get the LocationSharingService associated to this Room. * Get the LocationSharingService associated to this Room.
*/ */
fun locationSharingService(): LocationSharingService fun locationSharingService(): LocationSharingService
/**
* Get the PollHistoryService associated to this Room.
*/
fun pollHistoryService(): PollHistoryService
} }

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2023 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.api.session.room.poll
/**
* Represent the status of the loaded polls for a room.
*/
data class LoadedPollsStatus(
/**
* Indicate whether more polls can be loaded from timeline.
* A false value would mean the start of the timeline has been reached.
*/
val canLoadMore: Boolean,
/**
* Number of days of timeline events currently synced (fetched and stored in local).
*/
val daysSynced: Int,
/**
* Indicate whether a sync of timeline events has been completely done in backward. It would
* mean timeline events have been synced for at least a number of days defined by [PollHistoryService.loadingPeriodInDays].
*/
val hasCompletedASyncBackward: Boolean,
)

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2023 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.api.session.room.poll
import androidx.lifecycle.LiveData
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* Expose methods to get history of polls in rooms.
*/
interface PollHistoryService {
/**
* The number of days covered when requesting to load more polls.
*/
val loadingPeriodInDays: Int
/**
* This must be called when you don't need the service anymore.
* It ensures the underlying database get closed.
*/
fun dispose()
/**
* Ask to load more polls starting from last loaded polls for a period defined by
* [loadingPeriodInDays].
*/
suspend fun loadMore(): LoadedPollsStatus
/**
* Get the current status of the loaded polls.
*/
suspend fun getLoadedPollsStatus(): LoadedPollsStatus
/**
* Sync polls from last loaded polls until now.
*/
suspend fun syncPolls()
/**
* Get currently loaded list of poll events. See [loadMore].
*/
fun getPollEvents(): LiveData<List<TimelineEvent>>
}

View file

@ -66,6 +66,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo046
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo048 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo048
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo049 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo049
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo050
import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.Normalizer
import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
import javax.inject.Inject import javax.inject.Inject
@ -74,7 +75,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
private val normalizer: Normalizer private val normalizer: Normalizer
) : MatrixRealmMigration( ) : MatrixRealmMigration(
dbName = "Session", dbName = "Session",
schemaVersion = 49L, schemaVersion = 50L,
) { ) {
/** /**
* Forces all RealmSessionStoreMigration instances to be equal. * Forces all RealmSessionStoreMigration instances to be equal.
@ -133,5 +134,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 47) MigrateSessionTo047(realm).perform() if (oldVersion < 47) MigrateSessionTo047(realm).perform()
if (oldVersion < 48) MigrateSessionTo048(realm).perform() if (oldVersion < 48) MigrateSessionTo048(realm).perform()
if (oldVersion < 49) MigrateSessionTo049(realm).perform() if (oldVersion < 49) MigrateSessionTo049(realm).perform()
if (oldVersion < 50) MigrateSessionTo050(realm).perform()
} }
} }

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 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.database.migration
import io.realm.DynamicRealm
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields
import org.matrix.android.sdk.internal.util.database.RealmMigrator
/**
* Adding new entity PollHistoryStatusEntity.
*/
internal class MigrateSessionTo050(realm: DynamicRealm) : RealmMigrator(realm, 50) {
override fun doMigrate(realm: DynamicRealm) {
realm.schema.create("PollHistoryStatusEntity")
.addField(PollHistoryStatusEntityFields.ROOM_ID, String::class.java)
.addPrimaryKey(PollHistoryStatusEntityFields.ROOM_ID)
.setRequired(PollHistoryStatusEntityFields.ROOM_ID, true)
.addField(PollHistoryStatusEntityFields.CURRENT_TIMESTAMP_TARGET_BACKWARD_MS, Long::class.java)
.setNullable(PollHistoryStatusEntityFields.CURRENT_TIMESTAMP_TARGET_BACKWARD_MS, true)
.addField(PollHistoryStatusEntityFields.OLDEST_TIMESTAMP_TARGET_REACHED_MS, Long::class.java)
.setNullable(PollHistoryStatusEntityFields.OLDEST_TIMESTAMP_TARGET_REACHED_MS, true)
.addField(PollHistoryStatusEntityFields.OLDEST_EVENT_ID_REACHED, String::class.java)
.addField(PollHistoryStatusEntityFields.MOST_RECENT_EVENT_ID_REACHED, String::class.java)
.addField(PollHistoryStatusEntityFields.IS_END_OF_POLLS_BACKWARD, Boolean::class.java)
}
}

View file

@ -0,0 +1,105 @@
/*
* Copyright (c) 2023 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.database.model
import io.realm.RealmObject
import io.realm.annotations.PrimaryKey
import org.matrix.android.sdk.internal.session.room.poll.PollConstants
/**
* Keeps track of the loading process of the poll history.
*/
internal open class PollHistoryStatusEntity(
/**
* The related room id.
*/
@PrimaryKey
var roomId: String = "",
/**
* Timestamp of the in progress poll sync target in backward direction in milliseconds.
*/
var currentTimestampTargetBackwardMs: Long? = null,
/**
* Timestamp of the oldest event synced once target has been reached in milliseconds.
*/
var oldestTimestampTargetReachedMs: Long? = null,
/**
* Id of the oldest event synced.
*/
var oldestEventIdReached: String? = null,
/**
* Id of the most recent event synced.
*/
var mostRecentEventIdReached: String? = null,
/**
* Indicate whether all polls in a room have been synced in backward direction.
*/
var isEndOfPollsBackward: Boolean = false,
) : RealmObject() {
companion object
/**
* Create a new instance of the entity with the same content.
*/
fun copy(): PollHistoryStatusEntity {
return PollHistoryStatusEntity(
roomId = roomId,
currentTimestampTargetBackwardMs = currentTimestampTargetBackwardMs,
oldestTimestampTargetReachedMs = oldestTimestampTargetReachedMs,
oldestEventIdReached = oldestEventIdReached,
mostRecentEventIdReached = mostRecentEventIdReached,
isEndOfPollsBackward = isEndOfPollsBackward,
)
}
/**
* Indicate whether at least one poll sync has been fully completed backward for the given room.
*/
val hasCompletedASyncBackward: Boolean
get() = oldestTimestampTargetReachedMs != null
/**
* Indicate whether all polls in a room have been synced for the current timestamp target in backward direction.
*/
val currentTimestampTargetBackwardReached: Boolean
get() = checkIfCurrentTimestampTargetBackwardIsReached()
private fun checkIfCurrentTimestampTargetBackwardIsReached(): Boolean {
val currentTarget = currentTimestampTargetBackwardMs
val lastTarget = oldestTimestampTargetReachedMs
// last timestamp target should be older or equal to the current target
return currentTarget != null && lastTarget != null && lastTarget <= currentTarget
}
/**
* Compute the number of days of history currently synced.
*/
fun getNbSyncedDays(currentMs: Long): Int {
val oldestTimestamp = oldestTimestampTargetReachedMs
return if (oldestTimestamp == null) {
0
} else {
((currentMs - oldestTimestamp).coerceAtLeast(0) / PollConstants.MILLISECONDS_PER_DAY).toInt()
}
}
}

View file

@ -36,7 +36,4 @@ internal open class PollResponseAggregatedSummaryEntity(
var sourceLocalEchoEvents: RealmList<String> = RealmList(), var sourceLocalEchoEvents: RealmList<String> = RealmList(),
// list of related event ids which are encrypted due to decryption failure // list of related event ids which are encrypted due to decryption failure
var encryptedRelatedEventIds: RealmList<String> = RealmList(), var encryptedRelatedEventIds: RealmList<String> = RealmList(),
) : RealmObject() { ) : RealmObject()
companion object
}

View file

@ -73,6 +73,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit
UserPresenceEntity::class, UserPresenceEntity::class,
ThreadSummaryEntity::class, ThreadSummaryEntity::class,
ThreadListPageEntity::class, ThreadListPageEntity::class,
PollHistoryStatusEntity::class,
] ]
) )
internal class SessionRealmModule internal class SessionRealmModule

View file

@ -0,0 +1,31 @@
/*
* Copyright (c) 2023 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.database.query
import io.realm.Realm
import io.realm.kotlin.createObject
import io.realm.kotlin.where
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields
internal fun PollHistoryStatusEntity.Companion.get(realm: Realm, roomId: String): PollHistoryStatusEntity? {
return realm.where<PollHistoryStatusEntity>().equalTo(PollHistoryStatusEntityFields.ROOM_ID, roomId).findFirst()
}
internal fun PollHistoryStatusEntity.Companion.getOrCreate(realm: Realm, roomId: String): PollHistoryStatusEntity {
return get(realm, roomId) ?: realm.createObject(roomId)
}

View file

@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary
import org.matrix.android.sdk.api.session.room.model.RoomType import org.matrix.android.sdk.api.session.room.model.RoomType
import org.matrix.android.sdk.api.session.room.model.relation.RelationService import org.matrix.android.sdk.api.session.room.model.relation.RelationService
import org.matrix.android.sdk.api.session.room.notification.RoomPushRuleService import org.matrix.android.sdk.api.session.room.notification.RoomPushRuleService
import org.matrix.android.sdk.api.session.room.poll.PollHistoryService
import org.matrix.android.sdk.api.session.room.read.ReadService import org.matrix.android.sdk.api.session.room.read.ReadService
import org.matrix.android.sdk.api.session.room.reporting.ReportingService import org.matrix.android.sdk.api.session.room.reporting.ReportingService
import org.matrix.android.sdk.api.session.room.send.DraftService import org.matrix.android.sdk.api.session.room.send.DraftService
@ -72,6 +73,7 @@ internal class DefaultRoom(
private val roomVersionService: RoomVersionService, private val roomVersionService: RoomVersionService,
private val viaParameterFinder: ViaParameterFinder, private val viaParameterFinder: ViaParameterFinder,
private val locationSharingService: LocationSharingService, private val locationSharingService: LocationSharingService,
private val pollHistoryService: PollHistoryService,
override val coroutineDispatchers: MatrixCoroutineDispatchers override val coroutineDispatchers: MatrixCoroutineDispatchers
) : Room { ) : Room {
@ -116,4 +118,5 @@ internal class DefaultRoom(
override fun roomAccountDataService() = roomAccountDataService override fun roomAccountDataService() = roomAccountDataService
override fun roomVersionService() = roomVersionService override fun roomVersionService() = roomVersionService
override fun locationSharingService() = locationSharingService override fun locationSharingService() = locationSharingService
override fun pollHistoryService() = pollHistoryService
} }

View file

@ -28,6 +28,7 @@ import org.matrix.android.sdk.internal.session.room.draft.DefaultDraftService
import org.matrix.android.sdk.internal.session.room.location.DefaultLocationSharingService import org.matrix.android.sdk.internal.session.room.location.DefaultLocationSharingService
import org.matrix.android.sdk.internal.session.room.membership.DefaultMembershipService import org.matrix.android.sdk.internal.session.room.membership.DefaultMembershipService
import org.matrix.android.sdk.internal.session.room.notification.DefaultRoomPushRuleService import org.matrix.android.sdk.internal.session.room.notification.DefaultRoomPushRuleService
import org.matrix.android.sdk.internal.session.room.poll.DefaultPollHistoryService
import org.matrix.android.sdk.internal.session.room.read.DefaultReadService import org.matrix.android.sdk.internal.session.room.read.DefaultReadService
import org.matrix.android.sdk.internal.session.room.relation.DefaultRelationService import org.matrix.android.sdk.internal.session.room.relation.DefaultRelationService
import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportingService import org.matrix.android.sdk.internal.session.room.reporting.DefaultReportingService
@ -71,15 +72,17 @@ internal class DefaultRoomFactory @Inject constructor(
private val roomAccountDataServiceFactory: DefaultRoomAccountDataService.Factory, private val roomAccountDataServiceFactory: DefaultRoomAccountDataService.Factory,
private val viaParameterFinder: ViaParameterFinder, private val viaParameterFinder: ViaParameterFinder,
private val locationSharingServiceFactory: DefaultLocationSharingService.Factory, private val locationSharingServiceFactory: DefaultLocationSharingService.Factory,
private val pollHistoryServiceFactory: DefaultPollHistoryService.Factory,
private val coroutineDispatchers: MatrixCoroutineDispatchers private val coroutineDispatchers: MatrixCoroutineDispatchers
) : RoomFactory { ) : RoomFactory {
override fun create(roomId: String): Room { override fun create(roomId: String): Room {
val timelineService = timelineServiceFactory.create(roomId)
return DefaultRoom( return DefaultRoom(
roomId = roomId, roomId = roomId,
roomSummaryDataSource = roomSummaryDataSource, roomSummaryDataSource = roomSummaryDataSource,
roomCryptoService = roomCryptoServiceFactory.create(roomId), roomCryptoService = roomCryptoServiceFactory.create(roomId),
timelineService = timelineServiceFactory.create(roomId), timelineService = timelineService,
threadsService = threadsServiceFactory.create(roomId), threadsService = threadsServiceFactory.create(roomId),
threadsLocalService = threadsLocalServiceFactory.create(roomId), threadsLocalService = threadsLocalServiceFactory.create(roomId),
sendService = sendServiceFactory.create(roomId), sendService = sendServiceFactory.create(roomId),
@ -99,6 +102,7 @@ internal class DefaultRoomFactory @Inject constructor(
roomVersionService = roomVersionServiceFactory.create(roomId), roomVersionService = roomVersionServiceFactory.create(roomId),
viaParameterFinder = viaParameterFinder, viaParameterFinder = viaParameterFinder,
locationSharingService = locationSharingServiceFactory.create(roomId), locationSharingService = locationSharingServiceFactory.create(roomId),
pollHistoryService = pollHistoryServiceFactory.create(roomId, timelineService),
coroutineDispatchers = coroutineDispatchers coroutineDispatchers = coroutineDispatchers
) )
} }

View file

@ -59,6 +59,8 @@ import org.matrix.android.sdk.internal.session.room.directory.DefaultSetRoomDire
import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask import org.matrix.android.sdk.internal.session.room.directory.GetPublicRoomTask
import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.GetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask import org.matrix.android.sdk.internal.session.room.directory.SetRoomDirectoryVisibilityTask
import org.matrix.android.sdk.internal.session.room.event.DefaultFilterAndStoreEventsTask
import org.matrix.android.sdk.internal.session.room.event.FilterAndStoreEventsTask
import org.matrix.android.sdk.internal.session.room.location.CheckIfExistingActiveLiveTask import org.matrix.android.sdk.internal.session.room.location.CheckIfExistingActiveLiveTask
import org.matrix.android.sdk.internal.session.room.location.DefaultCheckIfExistingActiveLiveTask import org.matrix.android.sdk.internal.session.room.location.DefaultCheckIfExistingActiveLiveTask
import org.matrix.android.sdk.internal.session.room.location.DefaultGetActiveBeaconInfoForUserTask import org.matrix.android.sdk.internal.session.room.location.DefaultGetActiveBeaconInfoForUserTask
@ -89,6 +91,12 @@ import org.matrix.android.sdk.internal.session.room.peeking.DefaultPeekRoomTask
import org.matrix.android.sdk.internal.session.room.peeking.DefaultResolveRoomStateTask import org.matrix.android.sdk.internal.session.room.peeking.DefaultResolveRoomStateTask
import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask import org.matrix.android.sdk.internal.session.room.peeking.PeekRoomTask
import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask import org.matrix.android.sdk.internal.session.room.peeking.ResolveRoomStateTask
import org.matrix.android.sdk.internal.session.room.poll.DefaultGetLoadedPollsStatusTask
import org.matrix.android.sdk.internal.session.room.poll.DefaultLoadMorePollsTask
import org.matrix.android.sdk.internal.session.room.poll.DefaultSyncPollsTask
import org.matrix.android.sdk.internal.session.room.poll.GetLoadedPollsStatusTask
import org.matrix.android.sdk.internal.session.room.poll.LoadMorePollsTask
import org.matrix.android.sdk.internal.session.room.poll.SyncPollsTask
import org.matrix.android.sdk.internal.session.room.read.DefaultMarkAllRoomsReadTask import org.matrix.android.sdk.internal.session.room.read.DefaultMarkAllRoomsReadTask
import org.matrix.android.sdk.internal.session.room.read.DefaultSetReadMarkersTask import org.matrix.android.sdk.internal.session.room.read.DefaultSetReadMarkersTask
import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask
@ -359,4 +367,16 @@ internal abstract class RoomModule {
@Binds @Binds
abstract fun bindFetchPollResponseEventsTask(task: DefaultFetchPollResponseEventsTask): FetchPollResponseEventsTask abstract fun bindFetchPollResponseEventsTask(task: DefaultFetchPollResponseEventsTask): FetchPollResponseEventsTask
@Binds
abstract fun bindLoadMorePollsTask(task: DefaultLoadMorePollsTask): LoadMorePollsTask
@Binds
abstract fun bindGetLoadedPollsStatusTask(task: DefaultGetLoadedPollsStatusTask): GetLoadedPollsStatusTask
@Binds
abstract fun bindFilterAndStoreEventsTask(task: DefaultFilterAndStoreEventsTask): FilterAndStoreEventsTask
@Binds
abstract fun bindSyncPollsTask(task: DefaultSyncPollsTask): SyncPollsTask
} }

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2023 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.event
import com.zhuinden.monarchy.Monarchy
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.room.send.SendState
import org.matrix.android.sdk.internal.crypto.EventDecryptor
import org.matrix.android.sdk.internal.database.mapper.toEntity
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.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import org.matrix.android.sdk.internal.util.time.Clock
import javax.inject.Inject
internal interface FilterAndStoreEventsTask : Task<FilterAndStoreEventsTask.Params, Unit> {
data class Params(
val roomId: String,
val events: List<Event>,
val filterPredicate: (Event) -> Boolean,
)
}
internal class DefaultFilterAndStoreEventsTask @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
private val clock: Clock,
private val eventDecryptor: EventDecryptor,
) : FilterAndStoreEventsTask {
override suspend fun execute(params: FilterAndStoreEventsTask.Params) {
val filteredEvents = params.events
.map { decryptEventIfNeeded(it) }
// we also filter in the encrypted events since it means there was decryption error for them
// and they may be decrypted later
.filter { params.filterPredicate(it) || it.getClearType() == EventType.ENCRYPTED }
addMissingEventsInDB(params.roomId, filteredEvents)
}
private suspend fun addMissingEventsInDB(roomId: String, events: List<Event>) {
monarchy.awaitTransaction { realm ->
val eventIdsToCheck = events.mapNotNull { it.eventId }.filter { it.isNotEmpty() }
if (eventIdsToCheck.isNotEmpty()) {
val existingIds = EventEntity.where(realm, eventIdsToCheck).findAll().toList().map { it.eventId }
events.filterNot { it.eventId in existingIds }
.map { it.toEntity(roomId = roomId, sendState = SendState.SYNCED, ageLocalTs = computeLocalTs(it)) }
.forEach { it.copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) }
}
}
}
private suspend fun decryptEventIfNeeded(event: Event): Event {
if (event.isEncrypted()) {
eventDecryptor.decryptEventAndSaveResult(event, timeline = "")
}
event.ageLocalTs = computeLocalTs(event)
return event
}
private fun computeLocalTs(event: Event) = clock.epochMillis() - (event.unsignedData?.age ?: 0)
}

View file

@ -0,0 +1,153 @@
/*
* Copyright (c) 2023 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.poll
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import com.zhuinden.monarchy.Monarchy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.realm.kotlin.where
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.poll.PollHistoryService
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineService
import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings
import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields
import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
import org.matrix.android.sdk.internal.database.model.TimelineEventEntityFields
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.util.time.Clock
private const val LOADING_PERIOD_IN_DAYS = 30
private const val EVENTS_PAGE_SIZE = 250
internal class DefaultPollHistoryService @AssistedInject constructor(
@Assisted private val roomId: String,
@Assisted private val timelineService: TimelineService,
@SessionDatabase private val monarchy: Monarchy,
private val clock: Clock,
private val loadMorePollsTask: LoadMorePollsTask,
private val getLoadedPollsStatusTask: GetLoadedPollsStatusTask,
private val syncPollsTask: SyncPollsTask,
private val timelineEventMapper: TimelineEventMapper,
) : PollHistoryService {
@AssistedFactory
interface Factory {
fun create(roomId: String, timelineService: TimelineService): DefaultPollHistoryService
}
override val loadingPeriodInDays: Int
get() = LOADING_PERIOD_IN_DAYS
private val timeline by lazy {
val settings = TimelineSettings(
initialSize = EVENTS_PAGE_SIZE,
buildReadReceipts = false,
rootThreadEventId = null,
useLiveSenderInfo = false,
)
timelineService.createTimeline(eventId = null, settings = settings).also { it.start() }
}
private val timelineMutex = Mutex()
override fun dispose() {
timeline.dispose()
}
override suspend fun loadMore(): LoadedPollsStatus {
return timelineMutex.withLock {
val params = LoadMorePollsTask.Params(
timeline = timeline,
roomId = roomId,
currentTimestampMs = clock.epochMillis(),
loadingPeriodInDays = loadingPeriodInDays,
eventsPageSize = EVENTS_PAGE_SIZE,
)
loadMorePollsTask.execute(params)
}
}
override suspend fun getLoadedPollsStatus(): LoadedPollsStatus {
val params = GetLoadedPollsStatusTask.Params(
roomId = roomId,
currentTimestampMs = clock.epochMillis(),
)
return getLoadedPollsStatusTask.execute(params)
}
override suspend fun syncPolls() {
timelineMutex.withLock {
val params = SyncPollsTask.Params(
timeline = timeline,
roomId = roomId,
currentTimestampMs = clock.epochMillis(),
eventsPageSize = EVENTS_PAGE_SIZE,
)
syncPollsTask.execute(params)
}
}
override fun getPollEvents(): LiveData<List<TimelineEvent>> {
val pollHistoryStatusLiveData = getPollHistoryStatus()
return Transformations.switchMap(pollHistoryStatusLiveData) { results ->
val oldestTimestamp = results.firstOrNull()?.oldestTimestampTargetReachedMs ?: clock.epochMillis()
getPollStartEventsAfter(oldestTimestamp)
}
}
private fun getPollStartEventsAfter(timestampMs: Long): LiveData<List<TimelineEvent>> {
val eventsLiveData = monarchy.findAllMappedWithChanges(
{ realm ->
val pollTypes = (EventType.POLL_START.values + EventType.ENCRYPTED).toTypedArray()
realm.where<TimelineEventEntity>()
.equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
.`in`(TimelineEventEntityFields.ROOT.TYPE, pollTypes)
.greaterThan(TimelineEventEntityFields.ROOT.ORIGIN_SERVER_TS, timestampMs)
},
{ result ->
timelineEventMapper.map(result, buildReadReceipts = false)
}
)
return Transformations.map(eventsLiveData) { events ->
events.filter { it.root.getClearType() in EventType.POLL_START.values }
.distinctBy { it.eventId }
}
}
private fun getPollHistoryStatus(): LiveData<List<PollHistoryStatusEntity>> {
return monarchy.findAllMappedWithChanges(
{ realm ->
realm.where<PollHistoryStatusEntity>()
.equalTo(PollHistoryStatusEntityFields.ROOM_ID, roomId)
},
{ result ->
// make a copy of the Realm object since it will be used in another transformations
result.copy()
}
)
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2023 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.poll
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import javax.inject.Inject
internal interface GetLoadedPollsStatusTask : Task<GetLoadedPollsStatusTask.Params, LoadedPollsStatus> {
data class Params(
val roomId: String,
val currentTimestampMs: Long,
)
}
internal class DefaultGetLoadedPollsStatusTask @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
) : GetLoadedPollsStatusTask {
override suspend fun execute(params: GetLoadedPollsStatusTask.Params): LoadedPollsStatus {
return monarchy.awaitTransaction { realm ->
val status = PollHistoryStatusEntity
.getOrCreate(realm, params.roomId)
.copy()
LoadedPollsStatus(
canLoadMore = status.isEndOfPollsBackward.not(),
daysSynced = status.getNbSyncedDays(params.currentTimestampMs),
hasCompletedASyncBackward = status.hasCompletedASyncBackward,
)
}
}
}

View file

@ -0,0 +1,144 @@
/*
* Copyright (c) 2023 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.poll
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.room.poll.PollConstants.MILLISECONDS_PER_DAY
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import javax.inject.Inject
internal interface LoadMorePollsTask : Task<LoadMorePollsTask.Params, LoadedPollsStatus> {
data class Params(
val timeline: Timeline,
val roomId: String,
val currentTimestampMs: Long,
val loadingPeriodInDays: Int,
val eventsPageSize: Int,
)
}
internal class DefaultLoadMorePollsTask @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
) : LoadMorePollsTask {
override suspend fun execute(params: LoadMorePollsTask.Params): LoadedPollsStatus {
var currentPollHistoryStatus = updatePollHistoryStatus(params)
params.timeline.restartWithEventId(eventId = currentPollHistoryStatus.oldestEventIdReached)
while (shouldFetchMoreEventsBackward(currentPollHistoryStatus)) {
currentPollHistoryStatus = fetchMorePollEventsBackward(params)
}
return LoadedPollsStatus(
canLoadMore = currentPollHistoryStatus.isEndOfPollsBackward.not(),
daysSynced = currentPollHistoryStatus.getNbSyncedDays(params.currentTimestampMs),
hasCompletedASyncBackward = currentPollHistoryStatus.hasCompletedASyncBackward,
)
}
private fun shouldFetchMoreEventsBackward(status: PollHistoryStatusEntity): Boolean {
return status.currentTimestampTargetBackwardReached.not() && status.isEndOfPollsBackward.not()
}
private suspend fun updatePollHistoryStatus(params: LoadMorePollsTask.Params): PollHistoryStatusEntity {
return monarchy.awaitTransaction { realm ->
val status = PollHistoryStatusEntity.getOrCreate(realm, params.roomId)
val currentTargetTimestampMs = status.currentTimestampTargetBackwardMs
val lastTargetTimestampMs = status.oldestTimestampTargetReachedMs
val loadingPeriodMs: Long = MILLISECONDS_PER_DAY * params.loadingPeriodInDays.toLong()
if (currentTargetTimestampMs == null) {
// first load, compute the target timestamp
status.currentTimestampTargetBackwardMs = params.currentTimestampMs - loadingPeriodMs
} else if (lastTargetTimestampMs != null && status.currentTimestampTargetBackwardReached) {
// previous load has finished, update the target timestamp
status.currentTimestampTargetBackwardMs = lastTargetTimestampMs - loadingPeriodMs
}
// return a copy of the Realm object
status.copy()
}
}
private suspend fun fetchMorePollEventsBackward(params: LoadMorePollsTask.Params): PollHistoryStatusEntity {
val events = params.timeline.awaitPaginate(
direction = Timeline.Direction.BACKWARDS,
count = params.eventsPageSize,
)
val paginationState = params.timeline.getPaginationState(direction = Timeline.Direction.BACKWARDS)
return updatePollHistoryStatus(
roomId = params.roomId,
events = events,
paginationState = paginationState,
)
}
private suspend fun updatePollHistoryStatus(
roomId: String,
events: List<TimelineEvent>,
paginationState: Timeline.PaginationState,
): PollHistoryStatusEntity {
return monarchy.awaitTransaction { realm ->
val status = PollHistoryStatusEntity.getOrCreate(realm, roomId)
val mostRecentEventIdReached = status.mostRecentEventIdReached
if (mostRecentEventIdReached == null) {
// save it for next forward pagination
val mostRecentEvent = events
.maxByOrNull { it.root.originServerTs ?: Long.MIN_VALUE }
?.root
status.mostRecentEventIdReached = mostRecentEvent?.eventId
}
val oldestEvent = events
.minByOrNull { it.root.originServerTs ?: Long.MAX_VALUE }
?.root
val oldestEventTimestamp = oldestEvent?.originServerTs
val oldestEventId = oldestEvent?.eventId
val currentTargetTimestamp = status.currentTimestampTargetBackwardMs
if (paginationState.hasMoreToLoad.not()) {
// start of the timeline is reached, there are no more events
status.isEndOfPollsBackward = true
if (oldestEventTimestamp != null && oldestEventTimestamp > 0) {
status.oldestTimestampTargetReachedMs = oldestEventTimestamp
}
} else if (oldestEventTimestamp != null && currentTargetTimestamp != null && oldestEventTimestamp <= currentTargetTimestamp) {
// target has been reached
status.oldestTimestampTargetReachedMs = oldestEventTimestamp
}
if (oldestEventId != null) {
// save it for next backward pagination
status.oldestEventIdReached = oldestEventId
}
// return a copy of the Realm object
status.copy()
}
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 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.poll
object PollConstants {
const val MILLISECONDS_PER_DAY = 24 * 60 * 60_000
}

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2023 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.poll
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.query.getOrCreate
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import javax.inject.Inject
internal interface SyncPollsTask : Task<SyncPollsTask.Params, Unit> {
data class Params(
val timeline: Timeline,
val roomId: String,
val currentTimestampMs: Long,
val eventsPageSize: Int,
)
}
internal class DefaultSyncPollsTask @Inject constructor(
@SessionDatabase private val monarchy: Monarchy,
) : SyncPollsTask {
override suspend fun execute(params: SyncPollsTask.Params) {
val currentPollHistoryStatus = getCurrentPollHistoryStatus(params.roomId)
params.timeline.restartWithEventId(currentPollHistoryStatus.mostRecentEventIdReached)
var loadStatus = LoadStatus(shouldLoadMore = true)
while (loadStatus.shouldLoadMore) {
loadStatus = fetchMorePollEventsForward(params)
}
params.timeline.restartWithEventId(currentPollHistoryStatus.oldestEventIdReached)
}
private suspend fun getCurrentPollHistoryStatus(roomId: String): PollHistoryStatusEntity {
return monarchy.awaitTransaction { realm ->
PollHistoryStatusEntity
.getOrCreate(realm, roomId)
.copy()
}
}
private suspend fun fetchMorePollEventsForward(params: SyncPollsTask.Params): LoadStatus {
val events = params.timeline.awaitPaginate(
direction = Timeline.Direction.FORWARDS,
count = params.eventsPageSize,
)
val paginationState = params.timeline.getPaginationState(direction = Timeline.Direction.FORWARDS)
return updatePollHistoryStatus(
roomId = params.roomId,
currentTimestampMs = params.currentTimestampMs,
events = events,
paginationState = paginationState,
)
}
private suspend fun updatePollHistoryStatus(
roomId: String,
currentTimestampMs: Long,
events: List<TimelineEvent>,
paginationState: Timeline.PaginationState,
): LoadStatus {
return monarchy.awaitTransaction { realm ->
val status = PollHistoryStatusEntity.getOrCreate(realm, roomId)
val mostRecentEvent = events
.maxByOrNull { it.root.originServerTs ?: Long.MIN_VALUE }
?.root
val mostRecentEventIdReached = mostRecentEvent?.eventId
if (mostRecentEventIdReached != null) {
// save it for next forward pagination
status.mostRecentEventIdReached = mostRecentEventIdReached
}
val mostRecentTimestamp = mostRecentEvent?.originServerTs
val shouldLoadMore = paginationState.hasMoreToLoad &&
(mostRecentTimestamp == null || mostRecentTimestamp < currentTimestampMs)
LoadStatus(shouldLoadMore = shouldLoadMore)
}
}
private class LoadStatus(
val shouldLoadMore: Boolean,
)
}

View file

@ -17,25 +17,14 @@
package org.matrix.android.sdk.internal.session.room.relation.poll package org.matrix.android.sdk.internal.session.room.relation.poll
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.isPollResponse import org.matrix.android.sdk.api.session.events.model.isPollResponse
import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.internal.crypto.EventDecryptor
import org.matrix.android.sdk.internal.database.mapper.toEntity
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.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.database.query.where
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.room.RoomAPI import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.session.room.event.FilterAndStoreEventsTask
import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import org.matrix.android.sdk.internal.util.time.Clock
import javax.inject.Inject import javax.inject.Inject
@VisibleForTesting @VisibleForTesting
@ -54,10 +43,9 @@ internal interface FetchPollResponseEventsTask : Task<FetchPollResponseEventsTas
internal class DefaultFetchPollResponseEventsTask @Inject constructor( internal class DefaultFetchPollResponseEventsTask @Inject constructor(
private val roomAPI: RoomAPI, private val roomAPI: RoomAPI,
private val globalErrorReceiver: GlobalErrorReceiver, private val globalErrorReceiver: GlobalErrorReceiver,
@SessionDatabase private val monarchy: Monarchy, private val filterAndStoreEventsTask: FilterAndStoreEventsTask,
private val clock: Clock,
private val eventDecryptor: EventDecryptor, ) : FetchPollResponseEventsTask {
) : FetchPollResponseEventsTask {
override suspend fun execute(params: FetchPollResponseEventsTask.Params): Result<Unit> = runCatching { override suspend fun execute(params: FetchPollResponseEventsTask.Params): Result<Unit> = runCatching {
var nextBatch: String? = fetchAndProcessRelatedEventsFrom(params) var nextBatch: String? = fetchAndProcessRelatedEventsFrom(params)
@ -70,11 +58,12 @@ internal class DefaultFetchPollResponseEventsTask @Inject constructor(
private suspend fun fetchAndProcessRelatedEventsFrom(params: FetchPollResponseEventsTask.Params, from: String? = null): String? { private suspend fun fetchAndProcessRelatedEventsFrom(params: FetchPollResponseEventsTask.Params, from: String? = null): String? {
val response = getRelatedEvents(params, from) val response = getRelatedEvents(params, from)
val filteredEvents = response.chunks val filterTaskParams = FilterAndStoreEventsTask.Params(
.map { decryptEventIfNeeded(it) } roomId = params.roomId,
.filter { it.isPollResponse() } events = response.chunks,
filterPredicate = { it.isPollResponse() }
addMissingEventsInDB(params.roomId, filteredEvents) )
filterAndStoreEventsTask.execute(filterTaskParams)
return response.nextBatch return response.nextBatch
} }
@ -90,29 +79,4 @@ internal class DefaultFetchPollResponseEventsTask @Inject constructor(
) )
} }
} }
private suspend fun addMissingEventsInDB(roomId: String, events: List<Event>) {
monarchy.awaitTransaction { realm ->
val eventIdsToCheck = events.mapNotNull { it.eventId }.filter { it.isNotEmpty() }
if (eventIdsToCheck.isNotEmpty()) {
val existingIds = EventEntity.where(realm, eventIdsToCheck).findAll().toList().map { it.eventId }
events.filterNot { it.eventId in existingIds }
.map { it.toEntity(roomId = roomId, sendState = SendState.SYNCED, ageLocalTs = computeLocalTs(it)) }
.forEach { it.copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) }
}
}
}
private suspend fun decryptEventIfNeeded(event: Event): Event {
if (event.isEncrypted()) {
eventDecryptor.decryptEventAndSaveResult(event, timeline = "")
}
event.ageLocalTs = computeLocalTs(event)
return event
}
private fun computeLocalTs(event: Event) = clock.epochMillis() - (event.unsignedData?.age ?: 0)
} }

View file

@ -0,0 +1,128 @@
/*
* Copyright (c) 2023 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.event
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
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.room.send.SendState
import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventEntityFields
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.test.fakes.FakeClock
import org.matrix.android.sdk.test.fakes.FakeEventDecryptor
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.givenFindAll
import org.matrix.android.sdk.test.fakes.givenIn
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultFilterAndStoreEventsTaskTest {
private val fakeMonarchy = FakeMonarchy()
private val fakeClock = FakeClock()
private val fakeEventDecryptor = FakeEventDecryptor()
private val defaultFilterAndStoreEventsTask = DefaultFilterAndStoreEventsTask(
monarchy = fakeMonarchy.instance,
clock = fakeClock,
eventDecryptor = fakeEventDecryptor.instance,
)
@Before
fun setup() {
mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt")
mockkStatic("org.matrix.android.sdk.internal.database.mapper.EventMapperKt")
mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt")
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given a room and list of events when execute then filter in using given predicate and store them in local if needed`() = runTest {
// Given
val aRoomId = "roomId"
val anEventId1 = "eventId1"
val anEventId2 = "eventId2"
val anEventId3 = "eventId3"
val anEventId4 = "eventId4"
val event1 = givenAnEvent(eventId = anEventId1, isEncrypted = true, clearType = EventType.ENCRYPTED)
val event2 = givenAnEvent(eventId = anEventId2, isEncrypted = true, clearType = EventType.MESSAGE)
val event3 = givenAnEvent(eventId = anEventId3, isEncrypted = false, clearType = EventType.MESSAGE)
val event4 = givenAnEvent(eventId = anEventId4, isEncrypted = false, clearType = EventType.MESSAGE)
val events = listOf(event1, event2, event3, event4)
val filterPredicate = { event: Event -> event == event2 }
val params = givenTaskParams(roomId = aRoomId, events = events, predicate = filterPredicate)
fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event1)
fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event2)
fakeClock.givenEpoch(123)
givenExistingEventEntities(eventIdsToCheck = listOf(anEventId1, anEventId2), existingIds = listOf(anEventId1))
val eventEntityToSave = EventEntity(eventId = anEventId2)
every { event2.toEntity(any(), any(), any()) } returns eventEntityToSave
every { eventEntityToSave.copyToRealmOrIgnore(any(), any()) } returns eventEntityToSave
// When
defaultFilterAndStoreEventsTask.execute(params)
// Then
fakeEventDecryptor.verifyDecryptEventAndSaveResult(event1, timeline = "")
fakeEventDecryptor.verifyDecryptEventAndSaveResult(event2, timeline = "")
// Check we save in DB the event2 which is a non stored poll response
verify {
event2.toEntity(aRoomId, SendState.SYNCED, any())
eventEntityToSave.copyToRealmOrIgnore(fakeMonarchy.fakeRealm.instance, EventInsertType.PAGINATION)
}
}
private fun givenTaskParams(roomId: String, events: List<Event>, predicate: (Event) -> Boolean) = FilterAndStoreEventsTask.Params(
roomId = roomId,
events = events,
filterPredicate = predicate,
)
private fun givenAnEvent(
eventId: String,
isEncrypted: Boolean,
clearType: String,
): Event {
val event = mockk<Event>(relaxed = true)
every { event.eventId } returns eventId
every { event.isEncrypted() } returns isEncrypted
every { event.getClearType() } returns clearType
return event
}
private fun givenExistingEventEntities(eventIdsToCheck: List<String>, existingIds: List<String>) {
val eventEntities = existingIds.map { EventEntity(eventId = it) }
fakeMonarchy.givenWhere<EventEntity>()
.givenIn(EventEntityFields.EVENT_ID, eventIdsToCheck)
.givenFindAll(eventEntities)
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2023 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.poll
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
private const val A_ROOM_ID = "room-id"
/**
* Timestamp in milliseconds corresponding to 2023/01/26.
*/
private const val A_CURRENT_TIMESTAMP = 1674737619290L
/**
* Timestamp in milliseconds corresponding to 2023/01/20.
*/
private const val AN_EVENT_TIMESTAMP = 1674169200000L
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultGetLoadedPollsStatusTaskTest {
private val fakeMonarchy = FakeMonarchy()
private val defaultGetLoadedPollsStatusTask = DefaultGetLoadedPollsStatusTask(
monarchy = fakeMonarchy.instance,
)
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given poll history status exists in db with an oldestTimestamp reached when execute then the computed status is returned`() = runTest {
// Given
val params = givenTaskParams()
val pollHistoryStatus = aPollHistoryStatusEntity(
isEndOfPollsBackward = false,
oldestTimestampReached = AN_EVENT_TIMESTAMP,
)
fakeMonarchy.fakeRealm
.givenWhere<PollHistoryStatusEntity>()
.givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID)
.givenFindFirst(pollHistoryStatus)
val expectedStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 6,
hasCompletedASyncBackward = true,
)
// When
val result = defaultGetLoadedPollsStatusTask.execute(params)
// Then
result shouldBeEqualTo expectedStatus
}
@Test
fun `given poll history status exists in db and no oldestTimestamp reached when execute then the computed status is returned`() = runTest {
// Given
val params = givenTaskParams()
val pollHistoryStatus = aPollHistoryStatusEntity(
isEndOfPollsBackward = false,
oldestTimestampReached = null,
)
fakeMonarchy.fakeRealm
.givenWhere<PollHistoryStatusEntity>()
.givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID)
.givenFindFirst(pollHistoryStatus)
val expectedStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 0,
hasCompletedASyncBackward = false,
)
// When
val result = defaultGetLoadedPollsStatusTask.execute(params)
// Then
result shouldBeEqualTo expectedStatus
}
private fun givenTaskParams(): GetLoadedPollsStatusTask.Params {
return GetLoadedPollsStatusTask.Params(
roomId = A_ROOM_ID,
currentTimestampMs = A_CURRENT_TIMESTAMP,
)
}
private fun aPollHistoryStatusEntity(
isEndOfPollsBackward: Boolean,
oldestTimestampReached: Long?,
): PollHistoryStatusEntity {
return PollHistoryStatusEntity(
roomId = A_ROOM_ID,
isEndOfPollsBackward = isEndOfPollsBackward,
oldestTimestampTargetReachedMs = oldestTimestampReached,
)
}
}

View file

@ -0,0 +1,192 @@
/*
* Copyright (c) 2023 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.poll
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.FakeTimeline
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
private const val A_ROOM_ID = "room-id"
/**
* Timestamp in milliseconds corresponding to 2023/01/26.
*/
private const val A_CURRENT_TIMESTAMP = 1674737619290L
/**
* Timestamp in milliseconds corresponding to 2023/01/20.
*/
private const val AN_EVENT_TIMESTAMP = 1674169200000L
private const val A_PERIOD_IN_DAYS = 3
private const val A_PAGE_SIZE = 200
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultLoadMorePollsTaskTest {
private val fakeMonarchy = FakeMonarchy()
private val fakeTimeline = FakeTimeline()
private val defaultLoadMorePollsTask = DefaultLoadMorePollsTask(
monarchy = fakeMonarchy.instance,
)
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given timeline when execute then more events are fetched in backward direction until has no more to load`() = runTest {
// Given
val params = givenTaskParams()
val oldestEventId = "oldest"
val pollHistoryStatus = aPollHistoryStatusEntity(
oldestEventIdReached = oldestEventId,
)
fakeMonarchy.fakeRealm
.givenWhere<PollHistoryStatusEntity>()
.givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID)
.givenFindFirst(pollHistoryStatus)
fakeTimeline.givenRestartWithEventIdSuccess(oldestEventId)
val anEventId = "event-id"
val aTimelineEvent = aTimelineEvent(anEventId, AN_EVENT_TIMESTAMP)
fakeTimeline.givenAwaitPaginateReturns(
events = listOf(aTimelineEvent),
direction = Timeline.Direction.BACKWARDS,
count = params.eventsPageSize,
)
val aPaginationState = aPaginationState(hasMoreToLoad = false)
fakeTimeline.givenGetPaginationStateReturns(
paginationState = aPaginationState,
direction = Timeline.Direction.BACKWARDS,
)
val expectedLoadStatus = LoadedPollsStatus(
canLoadMore = false,
daysSynced = 6,
hasCompletedASyncBackward = true,
)
// When
val result = defaultLoadMorePollsTask.execute(params)
// Then
coVerifyOrder {
fakeTimeline.instance.restartWithEventId(oldestEventId)
fakeTimeline.instance.awaitPaginate(direction = Timeline.Direction.BACKWARDS, count = params.eventsPageSize)
fakeTimeline.instance.getPaginationState(direction = Timeline.Direction.BACKWARDS)
}
pollHistoryStatus.mostRecentEventIdReached shouldBeEqualTo anEventId
pollHistoryStatus.oldestEventIdReached shouldBeEqualTo anEventId
pollHistoryStatus.isEndOfPollsBackward shouldBeEqualTo true
pollHistoryStatus.oldestTimestampTargetReachedMs shouldBeEqualTo AN_EVENT_TIMESTAMP
result shouldBeEqualTo expectedLoadStatus
}
@Test
fun `given timeline when execute then more events are fetched in backward direction until current target is reached`() = runTest {
// Given
val params = givenTaskParams()
val oldestEventId = "oldest"
val pollHistoryStatus = aPollHistoryStatusEntity(
oldestEventIdReached = oldestEventId,
)
fakeMonarchy.fakeRealm
.givenWhere<PollHistoryStatusEntity>()
.givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID)
.givenFindFirst(pollHistoryStatus)
fakeTimeline.givenRestartWithEventIdSuccess(oldestEventId)
val anEventId = "event-id"
val aTimelineEvent = aTimelineEvent(anEventId, AN_EVENT_TIMESTAMP)
fakeTimeline.givenAwaitPaginateReturns(
events = listOf(aTimelineEvent),
direction = Timeline.Direction.BACKWARDS,
count = params.eventsPageSize,
)
val aPaginationState = aPaginationState(hasMoreToLoad = true)
fakeTimeline.givenGetPaginationStateReturns(
paginationState = aPaginationState,
direction = Timeline.Direction.BACKWARDS,
)
val expectedLoadStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 6,
hasCompletedASyncBackward = true,
)
// When
val result = defaultLoadMorePollsTask.execute(params)
// Then
coVerifyOrder {
fakeTimeline.instance.restartWithEventId(oldestEventId)
fakeTimeline.instance.awaitPaginate(direction = Timeline.Direction.BACKWARDS, count = params.eventsPageSize)
fakeTimeline.instance.getPaginationState(direction = Timeline.Direction.BACKWARDS)
}
pollHistoryStatus.mostRecentEventIdReached shouldBeEqualTo anEventId
pollHistoryStatus.oldestEventIdReached shouldBeEqualTo anEventId
pollHistoryStatus.isEndOfPollsBackward shouldBeEqualTo false
pollHistoryStatus.oldestTimestampTargetReachedMs shouldBeEqualTo AN_EVENT_TIMESTAMP
result shouldBeEqualTo expectedLoadStatus
}
private fun givenTaskParams(): LoadMorePollsTask.Params {
return LoadMorePollsTask.Params(
timeline = fakeTimeline.instance,
roomId = A_ROOM_ID,
currentTimestampMs = A_CURRENT_TIMESTAMP,
loadingPeriodInDays = A_PERIOD_IN_DAYS,
eventsPageSize = A_PAGE_SIZE,
)
}
private fun aPollHistoryStatusEntity(
oldestEventIdReached: String,
): PollHistoryStatusEntity {
return PollHistoryStatusEntity(
roomId = A_ROOM_ID,
oldestEventIdReached = oldestEventIdReached,
)
}
private fun aTimelineEvent(eventId: String, timestamp: Long): TimelineEvent {
val event = mockk<TimelineEvent>()
every { event.root.originServerTs } returns timestamp
every { event.root.eventId } returns eventId
return event
}
private fun aPaginationState(hasMoreToLoad: Boolean): Timeline.PaginationState {
return Timeline.PaginationState(
hasMoreToLoad = hasMoreToLoad,
)
}
}

View file

@ -0,0 +1,129 @@
/*
* Copyright (c) 2023 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.poll
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Test
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntity
import org.matrix.android.sdk.internal.database.model.PollHistoryStatusEntityFields
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.FakeTimeline
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
private const val A_ROOM_ID = "room-id"
private const val A_TIMESTAMP = 123L
private const val A_PAGE_SIZE = 200
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultSyncPollsTaskTest {
private val fakeMonarchy = FakeMonarchy()
private val fakeTimeline = FakeTimeline()
private val defaultSyncPollsTask = DefaultSyncPollsTask(
monarchy = fakeMonarchy.instance,
)
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given timeline when execute then more events are fetched in forward direction after the most recent event id reached`() = runTest {
// Given
val params = givenTaskParams()
val mostRecentEventId = "most-recent"
val oldestEventId = "oldest"
val pollHistoryStatus = aPollHistoryStatusEntity(
mostRecentEventIdReached = mostRecentEventId,
oldestEventIdReached = oldestEventId,
)
fakeMonarchy.fakeRealm
.givenWhere<PollHistoryStatusEntity>()
.givenEqualTo(PollHistoryStatusEntityFields.ROOM_ID, A_ROOM_ID)
.givenFindFirst(pollHistoryStatus)
fakeTimeline.givenRestartWithEventIdSuccess(mostRecentEventId)
fakeTimeline.givenRestartWithEventIdSuccess(oldestEventId)
val anEventId = "event-id"
val aTimelineEvent = aTimelineEvent(anEventId)
fakeTimeline.givenAwaitPaginateReturns(
events = listOf(aTimelineEvent),
direction = Timeline.Direction.FORWARDS,
count = params.eventsPageSize,
)
fakeTimeline.givenGetPaginationStateReturns(
paginationState = aPaginationState(),
direction = Timeline.Direction.FORWARDS,
)
// When
defaultSyncPollsTask.execute(params)
// Then
coVerifyOrder {
fakeTimeline.instance.restartWithEventId(mostRecentEventId)
fakeTimeline.instance.awaitPaginate(direction = Timeline.Direction.FORWARDS, count = params.eventsPageSize)
fakeTimeline.instance.getPaginationState(direction = Timeline.Direction.FORWARDS)
fakeTimeline.instance.restartWithEventId(oldestEventId)
}
pollHistoryStatus.mostRecentEventIdReached shouldBeEqualTo anEventId
}
private fun givenTaskParams(): SyncPollsTask.Params {
return SyncPollsTask.Params(
timeline = fakeTimeline.instance,
roomId = A_ROOM_ID,
currentTimestampMs = A_TIMESTAMP,
eventsPageSize = A_PAGE_SIZE,
)
}
private fun aPollHistoryStatusEntity(
mostRecentEventIdReached: String,
oldestEventIdReached: String,
): PollHistoryStatusEntity {
return PollHistoryStatusEntity(
roomId = A_ROOM_ID,
mostRecentEventIdReached = mostRecentEventIdReached,
oldestEventIdReached = oldestEventIdReached,
)
}
private fun aTimelineEvent(eventId: String): TimelineEvent {
val event = mockk<TimelineEvent>()
every { event.root.originServerTs } returns 123L
every { event.root.eventId } returns eventId
return event
}
private fun aPaginationState(): Timeline.PaginationState {
return Timeline.PaginationState(
hasMoreToLoad = false,
)
}
}

View file

@ -16,11 +16,12 @@
package org.matrix.android.sdk.internal.session.room.relation.poll package org.matrix.android.sdk.internal.session.room.relation.poll
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.unmockkAll import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
@ -29,41 +30,28 @@ import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.isPollResponse import org.matrix.android.sdk.api.session.events.model.isPollResponse
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.internal.session.room.event.FilterAndStoreEventsTask
import org.matrix.android.sdk.internal.database.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventEntityFields
import org.matrix.android.sdk.internal.database.model.EventInsertType
import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
import org.matrix.android.sdk.test.fakes.FakeClock
import org.matrix.android.sdk.test.fakes.FakeEventDecryptor
import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver
import org.matrix.android.sdk.test.fakes.FakeMonarchy
import org.matrix.android.sdk.test.fakes.FakeRoomApi import org.matrix.android.sdk.test.fakes.FakeRoomApi
import org.matrix.android.sdk.test.fakes.givenFindAll
import org.matrix.android.sdk.test.fakes.givenIn
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultFetchPollResponseEventsTaskTest { internal class DefaultFetchPollResponseEventsTaskTest {
private val fakeRoomAPI = FakeRoomApi() private val fakeRoomAPI = FakeRoomApi()
private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver() private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver()
private val fakeMonarchy = FakeMonarchy() private val filterAndStoreEventsTask = mockk<FilterAndStoreEventsTask>()
private val fakeClock = FakeClock()
private val fakeEventDecryptor = FakeEventDecryptor()
private val defaultFetchPollResponseEventsTask = DefaultFetchPollResponseEventsTask( private val defaultFetchPollResponseEventsTask = DefaultFetchPollResponseEventsTask(
roomAPI = fakeRoomAPI.instance, roomAPI = fakeRoomAPI.instance,
globalErrorReceiver = fakeGlobalErrorReceiver, globalErrorReceiver = fakeGlobalErrorReceiver,
monarchy = fakeMonarchy.instance, filterAndStoreEventsTask = filterAndStoreEventsTask,
clock = fakeClock,
eventDecryptor = fakeEventDecryptor.instance,
) )
@Before @Before
fun setup() { fun setup() {
mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt") mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt")
mockkStatic("org.matrix.android.sdk.internal.database.mapper.EventMapperKt") mockkStatic("org.matrix.android.sdk.internal.database.mapper.EventMapperKt")
mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt") mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt")
} }
@ -74,7 +62,7 @@ internal class DefaultFetchPollResponseEventsTaskTest {
} }
@Test @Test
fun `given a room and a poll when execute then fetch related events and store them in local if needed`() = runTest { fun `given a room and a poll when execute then fetch related events and store them in local`() = runTest {
// Given // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val aPollEventId = "eventId" val aPollEventId = "eventId"
@ -94,13 +82,7 @@ internal class DefaultFetchPollResponseEventsTaskTest {
fakeRoomAPI.givenGetRelationsReturns(from = null, relationsResponse = firstResponse) fakeRoomAPI.givenGetRelationsReturns(from = null, relationsResponse = firstResponse)
val secondResponse = givenARelationsResponse(events = secondEvents, nextBatch = null) val secondResponse = givenARelationsResponse(events = secondEvents, nextBatch = null)
fakeRoomAPI.givenGetRelationsReturns(from = aNextBatchToken, relationsResponse = secondResponse) fakeRoomAPI.givenGetRelationsReturns(from = aNextBatchToken, relationsResponse = secondResponse)
fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event1) coJustRun { filterAndStoreEventsTask.execute(any()) }
fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event2)
fakeClock.givenEpoch(123)
givenExistingEventEntities(eventIdsToCheck = listOf(anEventId1, anEventId2), existingIds = listOf(anEventId1))
val eventEntityToSave = EventEntity(eventId = anEventId2)
every { event2.toEntity(any(), any(), any()) } returns eventEntityToSave
every { eventEntityToSave.copyToRealmOrIgnore(any(), any()) } returns eventEntityToSave
// When // When
defaultFetchPollResponseEventsTask.execute(params) defaultFetchPollResponseEventsTask.execute(params)
@ -111,21 +93,22 @@ internal class DefaultFetchPollResponseEventsTaskTest {
eventId = params.startPollEventId, eventId = params.startPollEventId,
relationType = RelationType.REFERENCE, relationType = RelationType.REFERENCE,
from = null, from = null,
limit = FETCH_RELATED_EVENTS_LIMIT limit = FETCH_RELATED_EVENTS_LIMIT,
) )
fakeRoomAPI.verifyGetRelations( fakeRoomAPI.verifyGetRelations(
roomId = params.roomId, roomId = params.roomId,
eventId = params.startPollEventId, eventId = params.startPollEventId,
relationType = RelationType.REFERENCE, relationType = RelationType.REFERENCE,
from = aNextBatchToken, from = aNextBatchToken,
limit = FETCH_RELATED_EVENTS_LIMIT limit = FETCH_RELATED_EVENTS_LIMIT,
) )
fakeEventDecryptor.verifyDecryptEventAndSaveResult(event1, timeline = "") coVerify {
fakeEventDecryptor.verifyDecryptEventAndSaveResult(event2, timeline = "") filterAndStoreEventsTask.execute(match {
// Check we save in DB the event2 which is a non stored poll response it.roomId == aRoomId && it.events == firstEvents
verify { })
event2.toEntity(aRoomId, SendState.SYNCED, any()) filterAndStoreEventsTask.execute(match {
eventEntityToSave.copyToRealmOrIgnore(fakeMonarchy.fakeRealm.instance, EventInsertType.PAGINATION) it.roomId == aRoomId && it.events == secondEvents
})
} }
} }
@ -153,11 +136,4 @@ internal class DefaultFetchPollResponseEventsTaskTest {
every { event.isEncrypted() } returns isEncrypted every { event.isEncrypted() } returns isEncrypted
return event return event
} }
private fun givenExistingEventEntities(eventIdsToCheck: List<String>, existingIds: List<String>) {
val eventEntities = existingIds.map { EventEntity(eventId = it) }
fakeMonarchy.givenWhere<EventEntity>()
.givenIn(EventEntityFields.EVENT_ID, eventIdsToCheck)
.givenFindAll(eventEntities)
}
} }

View file

@ -0,0 +1,40 @@
/*
* Copyright (c) 2023 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.coEvery
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
class FakeTimeline {
val instance: Timeline = mockk()
fun givenRestartWithEventIdSuccess(eventId: String) {
justRun { instance.restartWithEventId(eventId) }
}
fun givenAwaitPaginateReturns(events: List<TimelineEvent>, direction: Timeline.Direction, count: Int) {
coEvery { instance.awaitPaginate(direction, count) } returns events
}
fun givenGetPaginationStateReturns(paginationState: Timeline.PaginationState, direction: Timeline.Direction) {
every { instance.getPaginationState(direction) } returns paginationState
}
}

View file

@ -19,7 +19,6 @@ package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.poll.PollViewState import im.vector.app.features.poll.PollViewState
import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.extensions.orFalse
@ -29,6 +28,7 @@ import javax.inject.Inject
class PollItemViewStateFactory @Inject constructor( class PollItemViewStateFactory @Inject constructor(
private val stringProvider: StringProvider, private val stringProvider: StringProvider,
private val pollOptionViewStateFactory: PollOptionViewStateFactory,
) { ) {
fun create( fun create(
@ -40,7 +40,6 @@ class PollItemViewStateFactory @Inject constructor(
val question = pollCreationInfo?.question?.getBestQuestion().orEmpty() val question = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val pollResponseSummary = informationData.pollResponseAggregatedSummary val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val totalVotes = pollResponseSummary?.totalVotes ?: 0 val totalVotes = pollResponseSummary?.totalVotes ?: 0
return when { return when {
@ -48,7 +47,7 @@ class PollItemViewStateFactory @Inject constructor(
createSendingPollViewState(question, pollCreationInfo) createSendingPollViewState(question, pollCreationInfo)
} }
informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> { informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> {
createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes, winnerVoteCount) createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
} }
pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> { pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> {
createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary) createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary)
@ -67,12 +66,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = stringProvider.getString(R.string.poll_no_votes_cast), votesStatus = stringProvider.getString(R.string.poll_no_votes_cast),
canVote = false, canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollSendingOptions(pollCreationInfo),
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
) )
} }
@ -81,7 +75,6 @@ class PollItemViewStateFactory @Inject constructor(
pollCreationInfo: PollCreationInfo?, pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?, pollResponseSummary: PollResponseData?,
totalVotes: Int, totalVotes: Int,
winnerVoteCount: Int?,
): PollViewState { ): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) { val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll) stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
@ -92,16 +85,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = totalVotesText, votesStatus = totalVotesText,
canVote = false, canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseSummary),
val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "")
PollOptionViewState.PollEnded(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = voteSummary?.total ?: 0,
votePercentage = voteSummary?.percentage ?: 0.0,
isWinner = winnerVoteCount != 0 && voteSummary?.total == winnerVoteCount
)
},
) )
} }
@ -114,14 +98,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended), votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended),
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseSummary),
val isMyVote = pollResponseSummary?.myVote == answer.id
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = isMyVote
)
},
) )
} }
@ -140,17 +117,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = totalVotesText, votesStatus = totalVotesText,
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseSummary),
val isMyVote = pollResponseSummary?.myVote == answer.id
val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "")
PollOptionViewState.PollVoted(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = voteSummary?.total ?: 0,
votePercentage = voteSummary?.percentage ?: 0.0,
isSelected = isMyVote
)
},
) )
} }
@ -168,12 +135,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question, question = question,
votesStatus = totalVotesText, votesStatus = totalVotesText,
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollOptionViewStateFactory.createPollReadyOptions(pollCreationInfo),
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
) )
} }
} }

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
import javax.inject.Inject
class PollOptionViewStateFactory @Inject constructor() {
fun createPollEndedOptions(pollCreationInfo: PollCreationInfo?, pollResponseData: PollResponseData?): List<PollOptionViewState.PollEnded> {
val winnerVoteCount = pollResponseData?.winnerVoteCount
return pollCreationInfo?.answers?.map { answer ->
val voteSummary = pollResponseData?.getVoteSummaryOfAnOption(answer.id ?: "")
PollOptionViewState.PollEnded(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
voteCount = voteSummary?.total ?: 0,
votePercentage = voteSummary?.percentage ?: 0.0,
isWinner = winnerVoteCount != 0 && voteSummary?.total == winnerVoteCount
)
} ?: emptyList()
}
fun createPollSendingOptions(pollCreationInfo: PollCreationInfo?): List<PollOptionViewState.PollSending> {
return pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollSending(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
)
} ?: emptyList()
}
fun createPollUndisclosedOptions(pollCreationInfo: PollCreationInfo?, pollResponseData: PollResponseData?): List<PollOptionViewState.PollUndisclosed> {
return pollCreationInfo?.answers?.map { answer ->
val isMyVote = pollResponseData?.myVote == answer.id
PollOptionViewState.PollUndisclosed(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
isSelected = isMyVote
)
} ?: emptyList()
}
fun createPollVotedOptions(pollCreationInfo: PollCreationInfo?, pollResponseData: PollResponseData?): List<PollOptionViewState.PollVoted> {
return pollCreationInfo?.answers?.map { answer ->
val isMyVote = pollResponseData?.myVote == answer.id
val voteSummary = pollResponseData?.getVoteSummaryOfAnOption(answer.id ?: "")
PollOptionViewState.PollVoted(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
voteCount = voteSummary?.total ?: 0,
votePercentage = voteSummary?.percentage ?: 0.0,
isSelected = isMyVote
)
} ?: emptyList()
}
fun createPollReadyOptions(pollCreationInfo: PollCreationInfo?): List<PollOptionViewState.PollReady> {
return pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
} ?: emptyList()
}
}

View file

@ -207,6 +207,7 @@ class RoomProfileFragment :
} }
override fun onDestroyView() { override fun onDestroyView() {
roomProfileController.callback = null
views.matrixProfileAppBarLayout.removeOnOffsetChangedListener(appBarStateChangeListener) views.matrixProfileAppBarLayout.removeOnOffsetChangedListener(appBarStateChangeListener)
views.matrixProfileRecyclerView.cleanup() views.matrixProfileRecyclerView.cleanup()
appBarStateChangeListener = null appBarStateChangeListener = null

View file

@ -23,20 +23,23 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase import im.vector.app.features.roomprofile.polls.list.domain.DisposePollHistoryUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase
import im.vector.app.features.roomprofile.polls.list.ui.PollSummaryMapper
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class RoomPollsViewModel @AssistedInject constructor( class RoomPollsViewModel @AssistedInject constructor(
@Assisted initialState: RoomPollsViewState, @Assisted initialState: RoomPollsViewState,
private val getPollsUseCase: GetPollsUseCase, private val getPollsUseCase: GetPollsUseCase,
private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase,
private val loadMorePollsUseCase: LoadMorePollsUseCase, private val loadMorePollsUseCase: LoadMorePollsUseCase,
private val syncPollsUseCase: SyncPollsUseCase, private val syncPollsUseCase: SyncPollsUseCase,
private val disposePollHistoryUseCase: DisposePollHistoryUseCase,
private val pollSummaryMapper: PollSummaryMapper,
) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) { ) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) {
@AssistedFactory @AssistedFactory
@ -48,26 +51,26 @@ class RoomPollsViewModel @AssistedInject constructor(
init { init {
val roomId = initialState.roomId val roomId = initialState.roomId
updateLoadedPollStatus(roomId)
syncPolls(roomId) syncPolls(roomId)
observePolls(roomId) observePolls(roomId)
} }
private fun updateLoadedPollStatus(roomId: String) { override fun onCleared() {
val loadedPollsStatus = getLoadedPollsStatusUseCase.execute(roomId) withState { disposePollHistoryUseCase.execute(it.roomId) }
setState { super.onCleared()
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays
)
}
} }
private fun syncPolls(roomId: String) { private fun syncPolls(roomId: String) {
viewModelScope.launch { viewModelScope.launch {
setState { copy(isSyncing = true) } setState { copy(isSyncing = true) }
val result = runCatching { val result = runCatching {
syncPollsUseCase.execute(roomId) val loadedPollsStatus = syncPollsUseCase.execute(roomId)
setState {
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbSyncedDays = loadedPollsStatus.daysSynced,
)
}
} }
if (result.isFailure) { if (result.isFailure) {
_viewEvents.post(RoomPollsViewEvent.LoadingError) _viewEvents.post(RoomPollsViewEvent.LoadingError)
@ -78,6 +81,7 @@ class RoomPollsViewModel @AssistedInject constructor(
private fun observePolls(roomId: String) { private fun observePolls(roomId: String) {
getPollsUseCase.execute(roomId) getPollsUseCase.execute(roomId)
.map { it.mapNotNull { event -> pollSummaryMapper.map(event) } }
.onEach { setState { copy(polls = it) } } .onEach { setState { copy(polls = it) } }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
@ -96,7 +100,7 @@ class RoomPollsViewModel @AssistedInject constructor(
setState { setState {
copy( copy(
canLoadMore = status.canLoadMore, canLoadMore = status.canLoadMore,
nbLoadedDays = status.nbLoadedDays, nbSyncedDays = status.daysSynced,
) )
} }
} }

View file

@ -25,7 +25,7 @@ data class RoomPollsViewState(
val polls: List<PollSummary> = emptyList(), val polls: List<PollSummary> = emptyList(),
val isLoadingMore: Boolean = false, val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = true, val canLoadMore: Boolean = true,
val nbLoadedDays: Int = 0, val nbSyncedDays: Int = 0,
val isSyncing: Boolean = false, val isSyncing: Boolean = false,
) : MavericksState { ) : MavericksState {

View file

@ -16,7 +16,6 @@
package im.vector.app.features.roomprofile.polls.list.data package im.vector.app.features.roomprofile.polls.list.data
data class LoadedPollsStatus( sealed class PollHistoryError : Exception() {
val canLoadMore: Boolean, object UnknownRoomError : PollHistoryError()
val nbLoadedDays: Int, }
)

View file

@ -16,159 +16,44 @@
package im.vector.app.features.roomprofile.polls.list.data package im.vector.app.features.roomprofile.polls.list.data
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import androidx.lifecycle.asFlow
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary import im.vector.app.core.di.ActiveSessionHolder
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import org.matrix.android.sdk.api.session.getRoom
import kotlinx.coroutines.flow.asSharedFlow import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import timber.log.Timber import org.matrix.android.sdk.api.session.room.poll.PollHistoryService
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
@Singleton class RoomPollDataSource @Inject constructor(
class RoomPollDataSource @Inject constructor() { private val activeSessionHolder: ActiveSessionHolder,
) {
private val pollsFlow = MutableSharedFlow<List<PollSummary>>(replay = 1) private fun getPollHistoryService(roomId: String): PollHistoryService {
private val polls = mutableListOf<PollSummary>() return activeSessionHolder
private var fakeLoadCounter = 0 .getSafeActiveSession()
?.getRoom(roomId)
// TODO ?.pollHistoryService()
// unmock using SDK service + add unit tests ?: throw PollHistoryError.UnknownRoomError
// after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer
fun getPolls(roomId: String): Flow<List<PollSummary>> {
Timber.d("roomId=$roomId")
return pollsFlow.asSharedFlow()
} }
fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { fun dispose(roomId: String) {
Timber.d("roomId=$roomId") getPollHistoryService(roomId).dispose()
return LoadedPollsStatus(
canLoadMore = canLoadMore(),
nbLoadedDays = fakeLoadCounter * 30,
)
} }
private fun canLoadMore(): Boolean { fun getPolls(roomId: String): Flow<List<TimelineEvent>> {
return fakeLoadCounter < 2 return getPollHistoryService(roomId).getPollEvents().asFlow()
}
suspend fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
return getPollHistoryService(roomId).getLoadedPollsStatus()
} }
suspend fun loadMorePolls(roomId: String): LoadedPollsStatus { suspend fun loadMorePolls(roomId: String): LoadedPollsStatus {
// TODO return getPollHistoryService(roomId).loadMore()
// unmock using SDK service + add unit tests
delay(3000)
fakeLoadCounter++
when (fakeLoadCounter) {
1 -> polls.addAll(getActivePollsPart1() + getEndedPollsPart1())
2 -> polls.addAll(getActivePollsPart2() + getEndedPollsPart2())
else -> Unit
}
pollsFlow.emit(polls)
return getLoadedPollsStatus(roomId)
}
private fun getActivePollsPart1(): List<PollSummary.ActivePoll> {
return listOf(
PollSummary.ActivePoll(
id = "id1",
// 2022/06/28 UTC+1
creationTimestamp = 1656367200000,
title = "Which charity would you like to support?"
),
PollSummary.ActivePoll(
id = "id2",
// 2022/06/26 UTC+1
creationTimestamp = 1656194400000,
title = "Which sport should the pupils do this year?"
),
)
}
private fun getActivePollsPart2(): List<PollSummary.ActivePoll> {
return listOf(
PollSummary.ActivePoll(
id = "id3",
// 2022/06/24 UTC+1
creationTimestamp = 1656021600000,
title = "What type of food should we have at the party?"
),
PollSummary.ActivePoll(
id = "id4",
// 2022/06/22 UTC+1
creationTimestamp = 1655848800000,
title = "What film should we show at the end of the year party?"
),
)
}
private fun getEndedPollsPart1(): List<PollSummary.EndedPoll> {
return listOf(
PollSummary.EndedPoll(
id = "id1-ended",
// 2022/06/28 UTC+1
creationTimestamp = 1656367200000,
title = "Which charity would you like to support?",
totalVotes = 22,
winnerOptions = listOf(
PollOptionViewState.PollEnded(
optionId = "id1",
optionAnswer = "Cancer research",
voteCount = 13,
votePercentage = 13 / 22.0,
isWinner = true,
)
),
),
)
}
private fun getEndedPollsPart2(): List<PollSummary.EndedPoll> {
return listOf(
PollSummary.EndedPoll(
id = "id2-ended",
// 2022/06/26 UTC+1
creationTimestamp = 1656194400000,
title = "Where should we do the offsite?",
totalVotes = 92,
winnerOptions = listOf(
PollOptionViewState.PollEnded(
optionId = "id1",
optionAnswer = "Hawaii",
voteCount = 43,
votePercentage = 43 / 92.0,
isWinner = true,
)
),
),
PollSummary.EndedPoll(
id = "id3-ended",
// 2022/06/24 UTC+1
creationTimestamp = 1656021600000,
title = "What type of food should we have at the party?",
totalVotes = 22,
winnerOptions = listOf(
PollOptionViewState.PollEnded(
optionId = "id1",
optionAnswer = "Brazilian",
voteCount = 13,
votePercentage = 13 / 22.0,
isWinner = true,
)
),
),
)
} }
suspend fun syncPolls(roomId: String) { suspend fun syncPolls(roomId: String) {
Timber.d("roomId=$roomId") getPollHistoryService(roomId).syncPolls()
// TODO
// unmock using SDK service + add unit tests
if (fakeLoadCounter == 0) {
// fake first load
loadMorePolls(roomId)
} else {
// fake sync
delay(3000)
}
} }
} }

View file

@ -16,20 +16,24 @@
package im.vector.app.features.roomprofile.polls.list.data package im.vector.app.features.roomprofile.polls.list.data
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject import javax.inject.Inject
class RoomPollRepository @Inject constructor( class RoomPollRepository @Inject constructor(
private val roomPollDataSource: RoomPollDataSource, private val roomPollDataSource: RoomPollDataSource,
) { ) {
// TODO after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer fun dispose(roomId: String) {
fun getPolls(roomId: String): Flow<List<PollSummary>> { roomPollDataSource.dispose(roomId)
}
fun getPolls(roomId: String): Flow<List<TimelineEvent>> {
return roomPollDataSource.getPolls(roomId) return roomPollDataSource.getPolls(roomId)
} }
fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus { suspend fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
return roomPollDataSource.getLoadedPollsStatus(roomId) return roomPollDataSource.getLoadedPollsStatus(roomId)
} }

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import javax.inject.Inject
class DisposePollHistoryUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
fun execute(roomId: String) {
roomPollRepository.dispose(roomId)
}
}

View file

@ -16,15 +16,15 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import javax.inject.Inject import javax.inject.Inject
class GetLoadedPollsStatusUseCase @Inject constructor( class GetLoadedPollsStatusUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository, private val roomPollRepository: RoomPollRepository,
) { ) {
fun execute(roomId: String): LoadedPollsStatus { suspend fun execute(roomId: String): LoadedPollsStatus {
return roomPollRepository.getLoadedPollsStatus(roomId) return roomPollRepository.getLoadedPollsStatus(roomId)
} }
} }

View file

@ -17,17 +17,17 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject import javax.inject.Inject
class GetPollsUseCase @Inject constructor( class GetPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository, private val roomPollRepository: RoomPollRepository,
) { ) {
fun execute(roomId: String): Flow<List<PollSummary>> { fun execute(roomId: String): Flow<List<TimelineEvent>> {
return roomPollRepository.getPolls(roomId) return roomPollRepository.getPolls(roomId)
.map { it.sortedByDescending { poll -> poll.creationTimestamp } } .map { it.sortedByDescending { event -> event.root.originServerTs } }
} }
} }

View file

@ -16,8 +16,8 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import javax.inject.Inject import javax.inject.Inject
class LoadMorePollsUseCase @Inject constructor( class LoadMorePollsUseCase @Inject constructor(

View file

@ -17,16 +17,26 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import javax.inject.Inject import javax.inject.Inject
/** /**
* Sync the polls of a given room from last manual loading (see LoadMorePollsUseCase) until now. * Sync the polls of a given room from last manual loading if any (see LoadMorePollsUseCase) until now.
* Resume or start loading more to have at least a complete load.
*/ */
class SyncPollsUseCase @Inject constructor( class SyncPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository, private val roomPollRepository: RoomPollRepository,
private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase,
private val loadMorePollsUseCase: LoadMorePollsUseCase,
) { ) {
suspend fun execute(roomId: String) { suspend fun execute(roomId: String): LoadedPollsStatus {
roomPollRepository.syncPolls(roomId) roomPollRepository.syncPolls(roomId)
val loadedStatus = getLoadedPollsStatusUseCase.execute(roomId)
return if (loadedStatus.hasCompletedASyncBackward) {
loadedStatus
} else {
loadMorePollsUseCase.execute(roomId)
}
} }
} }

View file

@ -0,0 +1,82 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.features.roomprofile.polls.list.ui
import im.vector.app.core.extensions.getVectorLastMessageContent
import im.vector.app.features.home.room.detail.timeline.factory.PollOptionViewStateFactory
import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import timber.log.Timber
import javax.inject.Inject
class PollSummaryMapper @Inject constructor(
private val pollResponseDataFactory: PollResponseDataFactory,
private val pollOptionViewStateFactory: PollOptionViewStateFactory,
) {
fun map(timelineEvent: TimelineEvent): PollSummary? {
val eventId = timelineEvent.root.eventId.orEmpty()
val result = runCatching {
val content = timelineEvent.getVectorLastMessageContent()
val pollResponseData = pollResponseDataFactory.create(timelineEvent)
val creationTimestamp = timelineEvent.root.originServerTs ?: 0
return if (eventId.isNotEmpty() && creationTimestamp > 0 && content is MessagePollContent) {
convertToPollSummary(
eventId = eventId,
creationTimestamp = creationTimestamp,
messagePollContent = content,
pollResponseData = pollResponseData
)
} else {
Timber.w("missing mandatory info about poll event with id=$eventId")
null
}
}
if (result.isFailure) {
Timber.w("failed to map event with id $eventId")
}
return result.getOrNull()
}
private fun convertToPollSummary(
eventId: String,
creationTimestamp: Long,
messagePollContent: MessagePollContent,
pollResponseData: PollResponseData?
): PollSummary {
val pollCreationInfo = messagePollContent.getBestPollCreationInfo()
val pollTitle = pollCreationInfo?.question?.getBestQuestion().orEmpty()
return if (pollResponseData?.isClosed == true) {
PollSummary.EndedPoll(
id = eventId,
creationTimestamp = creationTimestamp,
title = pollTitle,
totalVotes = pollResponseData.totalVotes,
winnerOptions = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseData)
)
} else {
PollSummary.ActivePoll(
id = eventId,
creationTimestamp = creationTimestamp,
title = pollTitle,
)
}
}
}

View file

@ -78,7 +78,7 @@ abstract class RoomPollsListFragment :
views.roomPollsList.configureWith(roomPollsController) views.roomPollsList.configureWith(roomPollsController)
views.roomPollsEmptyTitle.text = getEmptyListTitle( views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore, canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays, nbLoadedDays = viewState.nbSyncedDays,
) )
} }
@ -117,7 +117,7 @@ abstract class RoomPollsListFragment :
roomPollsController.setData(viewState) roomPollsController.setData(viewState)
views.roomPollsEmptyTitle.text = getEmptyListTitle( views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore, canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays, nbLoadedDays = viewState.nbSyncedDays,
) )
views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls() views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls()
views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore() views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore()

View file

@ -17,127 +17,71 @@
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData
import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryData
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.poll.PollViewState import im.vector.app.features.poll.PollViewState
import im.vector.app.test.fakes.FakeStringProvider import im.vector.app.test.fakes.FakeStringProvider
import im.vector.app.test.fixtures.PollFixture.A_MESSAGE_INFORMATION_DATA
import im.vector.app.test.fixtures.PollFixture.A_POLL_CONTENT
import im.vector.app.test.fixtures.PollFixture.A_POLL_OPTION_IDS
import im.vector.app.test.fixtures.PollFixture.A_POLL_RESPONSE_DATA
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
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.PollType import org.matrix.android.sdk.api.session.room.model.message.PollType
import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.send.SendState
private val A_MESSAGE_INFORMATION_DATA = MessageInformationData(
eventId = "eventId",
senderId = "senderId",
ageLocalTS = 0,
avatarUrl = "",
sendState = SendState.SENT,
messageLayout = TimelineMessageLayout.Default(showAvatar = true, showDisplayName = true, showTimestamp = true),
reactionsSummary = ReactionsSummaryData(),
sentByMe = true,
)
private val A_POLL_RESPONSE_DATA = PollResponseData(
myVote = null,
votes = emptyMap(),
)
private val A_POLL_OPTION_IDS = listOf("5ef5f7b0-c9a1-49cf-a0b3-374729a43e76", "ec1a4db0-46d8-4d7a-9bb6-d80724715938", "3677ca8e-061b-40ab-bffe-b22e4e88fcad")
private val A_POLL_CONTENT = MessagePollContent(
unstablePollCreationInfo = PollCreationInfo(
question = PollQuestion(
unstableQuestion = "What is your favourite coffee?"
),
kind = PollType.UNDISCLOSED_UNSTABLE,
maxSelections = 1,
answers = listOf(
PollAnswer(
id = A_POLL_OPTION_IDS[0],
unstableAnswer = "Double Espresso"
),
PollAnswer(
id = A_POLL_OPTION_IDS[1],
unstableAnswer = "Macchiato"
),
PollAnswer(
id = A_POLL_OPTION_IDS[2],
unstableAnswer = "Iced Coffee"
),
)
)
)
class PollItemViewStateFactoryTest { class PollItemViewStateFactoryTest {
private val fakeStringProvider = FakeStringProvider()
private val fakePollOptionViewStateFactory = mockk<PollOptionViewStateFactory>()
private val pollItemViewStateFactory = PollItemViewStateFactory(
stringProvider = fakeStringProvider.instance,
pollOptionViewStateFactory = fakePollOptionViewStateFactory,
)
@Test @Test
fun `given a sending poll state then poll is not votable and option states are PollSending`() { fun `given a sending poll state then poll is not votable and option states are PollSending`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val sendingPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(sendState = SendState.SENDING) val sendingPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(sendState = SendState.SENDING)
val optionViewStates = listOf(PollOptionViewState.PollSending(optionId = "", optionAnswer = ""))
every { fakePollOptionViewStateFactory.createPollSendingOptions(A_POLL_CONTENT.getBestPollCreationInfo()) } returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT, pollContent = A_POLL_CONTENT,
informationData = sendingPollInformationData, informationData = sendingPollInformationData,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = stringProvider.instance.getString(R.string.poll_no_votes_cast), votesStatus = fakeStringProvider.instance.getString(R.string.poll_no_votes_cast),
canVote = false, canVote = false,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> optionViewStates = optionViewStates,
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
) )
verify { fakePollOptionViewStateFactory.createPollSendingOptions(A_POLL_CONTENT.getBestPollCreationInfo()) }
} }
@Test @Test
fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() { fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true) val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary) val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
val optionViewStates = listOf(
val pollViewState = pollItemViewStateFactory.create( PollOptionViewState.PollEnded(
pollContent = A_POLL_CONTENT, optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false
informationData = closedPollInformationData, )
) )
every {
pollViewState shouldBeEqualTo PollViewState( fakePollOptionViewStateFactory.createPollEndedOptions(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", A_POLL_CONTENT.getBestPollCreationInfo(),
votesStatus = stringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_after_ended, 0, 0), closedPollInformationData.pollResponseAggregatedSummary,
canVote = false, )
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> } returns optionViewStates
PollOptionViewState.PollEnded(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = 0,
votePercentage = 0.0,
isWinner = false
)
},
)
}
@Test
fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() {
// Given
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true, hasEncryptedRelatedEvents = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
// When // When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
@ -146,42 +90,90 @@ class PollItemViewStateFactoryTest {
) )
// Then // Then
pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll) pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = fakeStringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_after_ended, 0, 0),
canVote = false,
optionViewStates = optionViewStates,
)
verify {
fakePollOptionViewStateFactory.createPollEndedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
closedPollInformationData.pollResponseAggregatedSummary,
)
}
}
@Test
fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() {
// Given
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true, hasEncryptedRelatedEvents = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
val optionViewStates = listOf(
PollOptionViewState.PollEnded(
optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false
)
)
every {
fakePollOptionViewStateFactory.createPollEndedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
closedPollInformationData.pollResponseAggregatedSummary,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
)
// Then
pollViewState.votesStatus shouldBeEqualTo fakeStringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll)
} }
@Test @Test
fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() { fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance) val optionViewStates = listOf(
PollOptionViewState.PollUndisclosed(
optionId = "",
optionAnswer = "",
isSelected = false,
)
)
every {
fakePollOptionViewStateFactory.createPollUndisclosedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT, pollContent = A_POLL_CONTENT,
informationData = A_MESSAGE_INFORMATION_DATA, informationData = A_MESSAGE_INFORMATION_DATA,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = stringProvider.instance.getString(R.string.poll_undisclosed_not_ended), votesStatus = fakeStringProvider.instance.getString(R.string.poll_undisclosed_not_ended),
canVote = true, canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> optionViewStates = optionViewStates,
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = false
)
},
) )
verify {
fakePollOptionViewStateFactory.createPollUndisclosedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary,
)
}
} }
@Test @Test
fun `given a sent poll when my vote exists then poll is still votable and options states are PollVoted`() { fun `given a sent poll when my vote exists then poll is still votable and options states are PollVoted`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val votedPollData = A_POLL_RESPONSE_DATA.copy( val votedPollData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1, totalVotes = 1, myVote = A_POLL_OPTION_IDS[0], votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0))
myVote = A_POLL_OPTION_IDS[0],
votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0))
) )
val disclosedPollContent = A_POLL_CONTENT.copy( val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy( unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
@ -189,33 +181,46 @@ class PollItemViewStateFactoryTest {
), ),
) )
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData) val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData)
val optionViewStates = listOf(
PollOptionViewState.PollVoted(
optionId = "",
optionAnswer = "",
voteCount = 0,
votePercentage = 0.0,
isSelected = false,
)
)
every {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent, pollContent = disclosedPollContent,
informationData = votedInformationData, informationData = votedInformationData,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = stringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, 1, 1), votesStatus = fakeStringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, 1, 1),
canVote = true, canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.mapIndexed { index, answer -> optionViewStates = optionViewStates,
PollOptionViewState.PollVoted(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
voteCount = if (index == 0) 1 else 0,
votePercentage = if (index == 0) 1.0 else 0.0,
isSelected = index == 0
)
},
) )
verify {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
)
}
} }
@Test @Test
fun `given a sent poll with decryption failure when my vote exists then a warning message is displayed`() { fun `given a sent poll with decryption failure when my vote exists then a warning message is displayed`() {
// Given // Given
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val votedPollData = A_POLL_RESPONSE_DATA.copy( val votedPollData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1, totalVotes = 1,
myVote = A_POLL_OPTION_IDS[0], myVote = A_POLL_OPTION_IDS[0],
@ -228,6 +233,21 @@ class PollItemViewStateFactoryTest {
), ),
) )
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData) val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData)
val optionViewStates = listOf(
PollOptionViewState.PollVoted(
optionId = "",
optionAnswer = "",
voteCount = 0,
votePercentage = 0.0,
isSelected = false,
)
)
every {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
)
} returns optionViewStates
// When // When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
@ -236,34 +256,46 @@ class PollItemViewStateFactoryTest {
) )
// Then // Then
pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll) pollViewState.votesStatus shouldBeEqualTo fakeStringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll)
} }
@Test @Test
fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() { fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() {
val stringProvider = FakeStringProvider() // Given
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val disclosedPollContent = A_POLL_CONTENT.copy( val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy( unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE kind = PollType.DISCLOSED_UNSTABLE
) )
) )
val optionViewStates = listOf(
PollOptionViewState.PollReady(
optionId = "",
optionAnswer = "",
)
)
every {
fakePollOptionViewStateFactory.createPollReadyOptions(
disclosedPollContent.getBestPollCreationInfo(),
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create( val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent, pollContent = disclosedPollContent,
informationData = A_MESSAGE_INFORMATION_DATA, informationData = A_MESSAGE_INFORMATION_DATA,
) )
// Then
pollViewState shouldBeEqualTo PollViewState( pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "", question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = stringProvider.instance.getString(R.string.poll_no_votes_cast), votesStatus = fakeStringProvider.instance.getString(R.string.poll_no_votes_cast),
canVote = true, canVote = true,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer -> optionViewStates = optionViewStates,
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
) )
verify {
fakePollOptionViewStateFactory.createPollReadyOptions(
disclosedPollContent.getBestPollCreationInfo(),
)
}
} }
} }

View file

@ -0,0 +1,157 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollVoteSummaryData
import im.vector.app.test.fixtures.PollFixture.A_POLL_CONTENT
import im.vector.app.test.fixtures.PollFixture.A_POLL_OPTION_IDS
import im.vector.app.test.fixtures.PollFixture.A_POLL_RESPONSE_DATA
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.PollType
internal class PollOptionViewStateFactoryTest {
private val pollOptionViewStateFactory = PollOptionViewStateFactory()
@Test
fun `given poll data when creating ended poll options then correct options are returned`() {
// Given
val winnerVotesCount = 0
val pollResponseData = A_POLL_RESPONSE_DATA.copy(
isClosed = true,
winnerVoteCount = winnerVotesCount,
)
val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()
val expectedOptions = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollEnded(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
voteCount = 0,
votePercentage = 0.0,
isWinner = false,
)
}
// When
val result = pollOptionViewStateFactory.createPollEndedOptions(
pollCreationInfo = pollCreationInfo,
pollResponseData = pollResponseData,
)
// Then
result shouldBeEqualTo expectedOptions
}
@Test
fun `given poll data when creating sending poll options then correct options are returned`() {
// Given
val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()
val expectedOptions = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollSending(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
)
}
// When
val result = pollOptionViewStateFactory.createPollSendingOptions(
pollCreationInfo = pollCreationInfo,
)
// Then
result shouldBeEqualTo expectedOptions
}
@Test
fun `given poll data when creating undisclosed poll options then correct options are returned`() {
// Given
val pollResponseData = A_POLL_RESPONSE_DATA
val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()
val expectedOptions = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollUndisclosed(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
isSelected = false,
)
}
// When
val result = pollOptionViewStateFactory.createPollUndisclosedOptions(
pollCreationInfo = pollCreationInfo,
pollResponseData = pollResponseData,
)
// Then
result shouldBeEqualTo expectedOptions
}
@Test
fun `given poll data when creating voted poll options then correct options are returned`() {
// Given
val pollResponseData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1,
myVote = A_POLL_OPTION_IDS[0],
votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0)),
)
val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE,
),
)
val pollCreationInfo = disclosedPollContent.getBestPollCreationInfo()
val expectedOptions = pollCreationInfo?.answers?.mapIndexed { index, answer ->
PollOptionViewState.PollVoted(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
voteCount = if (index == 0) 1 else 0,
votePercentage = if (index == 0) 1.0 else 0.0,
isSelected = index == 0,
)
}
// When
val result = pollOptionViewStateFactory.createPollVotedOptions(
pollCreationInfo = pollCreationInfo,
pollResponseData = pollResponseData,
)
// Then
result shouldBeEqualTo expectedOptions
}
@Test
fun `given poll data when creating ready poll options then correct options are returned`() {
// Given
val pollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()
val expectedOptions = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollReady(
optionId = answer.id.orEmpty(),
optionAnswer = answer.getBestAnswer().orEmpty(),
)
}
// When
val result = pollOptionViewStateFactory.createPollReadyOptions(
pollCreationInfo = pollCreationInfo,
)
// Then
result shouldBeEqualTo expectedOptions
}
}

View file

@ -17,23 +17,26 @@
package im.vector.app.features.roomprofile.polls package im.vector.app.features.roomprofile.polls
import com.airbnb.mvrx.test.MavericksTestRule import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus import im.vector.app.features.roomprofile.polls.list.domain.DisposePollHistoryUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import im.vector.app.features.roomprofile.polls.list.ui.PollSummaryMapper
import im.vector.app.test.test import im.vector.app.test.test
import im.vector.app.test.testDispatcher import im.vector.app.test.testDispatcher
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_ROOM_ID = "room-id" private const val A_ROOM_ID = "room-id"
@ -42,33 +45,37 @@ class RoomPollsViewModelTest {
@get:Rule @get:Rule
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher) val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
private val initialState = RoomPollsViewState(A_ROOM_ID)
private val fakeGetPollsUseCase = mockk<GetPollsUseCase>() private val fakeGetPollsUseCase = mockk<GetPollsUseCase>()
private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>() private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
private val fakeSyncPollsUseCase = mockk<SyncPollsUseCase>() private val fakeSyncPollsUseCase = mockk<SyncPollsUseCase>()
private val initialState = RoomPollsViewState(A_ROOM_ID) private val fakeDisposePollHistoryUseCase = mockk<DisposePollHistoryUseCase>()
private val fakePollSummaryMapper = mockk<PollSummaryMapper>()
private fun createViewModel(): RoomPollsViewModel { private fun createViewModel(): RoomPollsViewModel {
return RoomPollsViewModel( return RoomPollsViewModel(
initialState = initialState, initialState = initialState,
getPollsUseCase = fakeGetPollsUseCase, getPollsUseCase = fakeGetPollsUseCase,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase, loadMorePollsUseCase = fakeLoadMorePollsUseCase,
syncPollsUseCase = fakeSyncPollsUseCase, syncPollsUseCase = fakeSyncPollsUseCase,
disposePollHistoryUseCase = fakeDisposePollHistoryUseCase,
pollSummaryMapper = fakePollSummaryMapper,
) )
} }
@Test @Test
fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() { fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() {
// Given // Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess() val loadedPollsStatus = givenSyncPollsWithSuccess()
givenSyncPollsWithSuccess() val aPollEvent = givenAPollEvent()
val polls = listOf(givenAPollSummary()) val aPollSummary = givenAPollSummary()
val polls = listOf(aPollEvent)
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls) every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
every { fakePollSummaryMapper.map(aPollEvent) } returns aPollSummary
val expectedViewState = initialState.copy( val expectedViewState = initialState.copy(
polls = polls, polls = listOf(aPollSummary),
canLoadMore = loadedPollsStatus.canLoadMore, canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays, nbSyncedDays = loadedPollsStatus.daysSynced,
) )
// When // When
@ -81,6 +88,7 @@ class RoomPollsViewModelTest {
.finish() .finish()
verify { verify {
fakeGetPollsUseCase.execute(A_ROOM_ID) fakeGetPollsUseCase.execute(A_ROOM_ID)
fakePollSummaryMapper.map(aPollEvent)
} }
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) } coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
} }
@ -88,10 +96,8 @@ class RoomPollsViewModelTest {
@Test @Test
fun `given viewModel and error during sync process when created then error is raised in view event`() { fun `given viewModel and error during sync process when created then error is raised in view event`() {
// Given // Given
givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithError(Exception()) givenSyncPollsWithError(Exception())
val polls = listOf(givenAPollSummary()) every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow()
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
@ -104,19 +110,32 @@ class RoomPollsViewModelTest {
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) } coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
} }
@Test
fun `given viewModel when calling onCleared then poll history is disposed`() {
// Given
givenSyncPollsWithSuccess()
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow()
justRun { fakeDisposePollHistoryUseCase.execute(A_ROOM_ID) }
val viewModel = createViewModel()
// When
viewModel.onCleared()
// Then
verify { fakeDisposePollHistoryUseCase.execute(A_ROOM_ID) }
}
@Test @Test
fun `given viewModel when handle load more action then viewState is updated`() { fun `given viewModel when handle load more action then viewState is updated`() {
// Given // Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess() val loadedPollsStatus = givenSyncPollsWithSuccess()
givenSyncPollsWithSuccess() every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow()
val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
val newLoadedPollsStatus = givenLoadMoreWithSuccess() val newLoadedPollsStatus = givenLoadMoreWithSuccess()
val viewModel = createViewModel() val viewModel = createViewModel()
val stateAfterInit = initialState.copy( val stateAfterInit = initialState.copy(
polls = polls, polls = emptyList(),
canLoadMore = loadedPollsStatus.canLoadMore, canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays, nbSyncedDays = loadedPollsStatus.daysSynced,
) )
// When // When
@ -128,7 +147,7 @@ class RoomPollsViewModelTest {
.assertStatesChanges( .assertStatesChanges(
stateAfterInit, stateAfterInit,
{ copy(isLoadingMore = true) }, { copy(isLoadingMore = true) },
{ copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbLoadedDays = newLoadedPollsStatus.nbLoadedDays) }, { copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbSyncedDays = newLoadedPollsStatus.daysSynced) },
{ copy(isLoadingMore = false) }, { copy(isLoadingMore = false) },
) )
.finish() .finish()
@ -139,8 +158,14 @@ class RoomPollsViewModelTest {
return mockk() return mockk()
} }
private fun givenSyncPollsWithSuccess() { private fun givenAPollEvent(): TimelineEvent {
coJustRun { fakeSyncPollsUseCase.execute(A_ROOM_ID) } return mockk()
}
private fun givenSyncPollsWithSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus()
coEvery { fakeSyncPollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
} }
private fun givenSyncPollsWithError(error: Exception) { private fun givenSyncPollsWithError(error: Exception) {
@ -148,20 +173,15 @@ class RoomPollsViewModelTest {
} }
private fun givenLoadMoreWithSuccess(): LoadedPollsStatus { private fun givenLoadMoreWithSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus(canLoadMore = false, nbLoadedDays = 20) val loadedPollsStatus = givenALoadedPollsStatus(canLoadMore = false, nbSyncedDays = 20)
coEvery { fakeLoadMorePollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus coEvery { fakeLoadMorePollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus return loadedPollsStatus
} }
private fun givenGetLoadedPollsStatusSuccess(): LoadedPollsStatus { private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbSyncedDays: Int = 10) =
val loadedPollsStatus = givenALoadedPollsStatus()
every { fakeGetLoadedPollsStatusUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
}
private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbLoadedDays: Int = 10) =
LoadedPollsStatus( LoadedPollsStatus(
canLoadMore = canLoadMore, canLoadMore = canLoadMore,
nbLoadedDays = nbLoadedDays, daysSynced = nbSyncedDays,
hasCompletedASyncBackward = false,
) )
} }

View file

@ -0,0 +1,130 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.features.roomprofile.polls.list.data
import im.vector.app.test.fakes.FakeActiveSessionHolder
import im.vector.app.test.fakes.FakeFlowLiveDataConversions
import im.vector.app.test.fakes.FakePollHistoryService
import im.vector.app.test.fakes.givenAsFlow
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_ROOM_ID = "room-id"
internal class RoomPollDataSourceTest {
private val fakeActiveSessionHolder = FakeActiveSessionHolder()
private val roomPollDataSource = RoomPollDataSource(
activeSessionHolder = fakeActiveSessionHolder.instance,
)
@Test
fun `given poll history service when dispose then correct method of service is called`() {
// Given
val fakePollHistoryService = givenPollHistoryService()
fakePollHistoryService.givenDispose()
// When
roomPollDataSource.dispose(A_ROOM_ID)
// Then
fakePollHistoryService.verifyDispose()
}
@Test
fun `given poll history service when get polls then correct method of service is called and correct result is returned`() = runTest {
// Given
val fakeFlowLiveDataConversions = FakeFlowLiveDataConversions()
fakeFlowLiveDataConversions.setup()
val fakePollHistoryService = givenPollHistoryService()
val pollEvents = listOf<TimelineEvent>()
fakePollHistoryService
.givenGetPollsReturns(pollEvents)
.givenAsFlow()
// When
val result = roomPollDataSource.getPolls(A_ROOM_ID).firstOrNull()
// Then
result shouldBeEqualTo pollEvents
fakePollHistoryService.verifyGetPolls()
unmockkAll()
}
@Test
fun `given poll history service when get loaded polls then correct method of service is called and correct result is returned`() = runTest {
// Given
val fakePollHistoryService = givenPollHistoryService()
val aLoadedPollsStatus = givenALoadedPollsStatus()
fakePollHistoryService.givenGetLoadedPollsStatusReturns(aLoadedPollsStatus)
// When
val result = roomPollDataSource.getLoadedPollsStatus(A_ROOM_ID)
// Then
result shouldBeEqualTo aLoadedPollsStatus
fakePollHistoryService.verifyGetLoadedPollsStatus()
}
@Test
fun `given poll history service when load more then correct method of service is called and correct result is returned`() = runTest {
// Given
val fakePollHistoryService = givenPollHistoryService()
val aLoadedPollsStatus = givenALoadedPollsStatus()
fakePollHistoryService.givenLoadMoreReturns(aLoadedPollsStatus)
// When
val result = roomPollDataSource.loadMorePolls(A_ROOM_ID)
// Then
result shouldBeEqualTo aLoadedPollsStatus
fakePollHistoryService.verifyLoadMore()
}
@Test
fun `given poll history service when sync polls then correct method of service is called`() = runTest {
// Given
val fakePollHistoryService = givenPollHistoryService()
fakePollHistoryService.givenSyncPollsSuccess()
// When
roomPollDataSource.syncPolls(A_ROOM_ID)
// Then
fakePollHistoryService.verifySyncPolls()
}
private fun givenPollHistoryService(): FakePollHistoryService {
return fakeActiveSessionHolder
.fakeSession
.fakeRoomService
.getRoom(A_ROOM_ID)
.pollHistoryService()
}
private fun givenALoadedPollsStatus() = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
}

View file

@ -16,10 +16,11 @@
package im.vector.app.features.roomprofile.polls.list.data package im.vector.app.features.roomprofile.polls.list.data
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary import io.mockk.coEvery
import io.mockk.coJustRun import io.mockk.coJustRun
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
@ -27,6 +28,8 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
private const val A_ROOM_ID = "room-id" private const val A_ROOM_ID = "room-id"
@ -38,10 +41,22 @@ class RoomPollRepositoryTest {
roomPollDataSource = fakeRoomPollDataSource, roomPollDataSource = fakeRoomPollDataSource,
) )
@Test
fun `given data source when dispose then correct method of data source is called`() {
// Given
justRun { fakeRoomPollDataSource.dispose(A_ROOM_ID) }
// When
roomPollRepository.dispose(A_ROOM_ID)
// Then
verify { fakeRoomPollDataSource.dispose(A_ROOM_ID) }
}
@Test @Test
fun `given data source when getting polls then correct method of data source is called`() = runTest { fun `given data source when getting polls then correct method of data source is called`() = runTest {
// Given // Given
val expectedPolls = listOf<PollSummary>() val expectedPolls = listOf<TimelineEvent>()
every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls) every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls)
// When // When
@ -53,20 +68,21 @@ class RoomPollRepositoryTest {
} }
@Test @Test
fun `given data source when getting loaded polls status then correct method of data source is called`() { fun `given data source when getting loaded polls status then correct method of data source is called`() = runTest {
// Given // Given
val expectedStatus = LoadedPollsStatus( val expectedStatus = LoadedPollsStatus(
canLoadMore = true, canLoadMore = true,
nbLoadedDays = 10, daysSynced = 10,
hasCompletedASyncBackward = false,
) )
every { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } returns expectedStatus coEvery { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } returns expectedStatus
// When // When
val result = roomPollRepository.getLoadedPollsStatus(A_ROOM_ID) val result = roomPollRepository.getLoadedPollsStatus(A_ROOM_ID)
// Then // Then
result shouldBeEqualTo expectedStatus result shouldBeEqualTo expectedStatus
verify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) } coVerify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) }
} }
@Test @Test

View file

@ -0,0 +1,45 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.coVerify
import io.mockk.justRun
import io.mockk.mockk
import org.junit.Test
internal class DisposePollHistoryUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val disposePollHistoryUseCase = DisposePollHistoryUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
fun `given repo when execute then correct method of repo is called`() {
// Given
val aRoomId = "roomId"
justRun { fakeRoomPollRepository.dispose(aRoomId) }
// When
disposePollHistoryUseCase.execute(aRoomId)
// Then
coVerify { fakeRoomPollRepository.dispose(aRoomId) }
}
}

View file

@ -16,13 +16,14 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.every import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class GetLoadedPollsStatusUseCaseTest { class GetLoadedPollsStatusUseCaseTest {
@ -33,20 +34,21 @@ class GetLoadedPollsStatusUseCaseTest {
) )
@Test @Test
fun `given repo when execute then correct method of repo is called`() { fun `given repo when execute then correct method of repo is called`() = runTest {
// Given // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val expectedStatus = LoadedPollsStatus( val expectedStatus = LoadedPollsStatus(
canLoadMore = true, canLoadMore = true,
nbLoadedDays = 10, daysSynced = 10,
hasCompletedASyncBackward = true,
) )
every { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus coEvery { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus
// When // When
val status = getLoadedPollsStatusUseCase.execute(aRoomId) val status = getLoadedPollsStatusUseCase.execute(aRoomId)
// Then // Then
status shouldBeEqualTo expectedStatus status shouldBeEqualTo expectedStatus
verify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } coVerify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) }
} }
} }

View file

@ -17,8 +17,6 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import im.vector.app.test.fixtures.RoomPollFixture
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@ -27,6 +25,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
class GetPollsUseCaseTest { class GetPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>() private val fakeRoomPollRepository = mockk<RoomPollRepository>()
@ -39,16 +38,16 @@ class GetPollsUseCaseTest {
fun `given repo when execute then correct method of repo is called and polls are sorted most recent first`() = runTest { fun `given repo when execute then correct method of repo is called and polls are sorted most recent first`() = runTest {
// Given // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val poll1 = RoomPollFixture.anActivePollSummary(timestamp = 1) val poll1 = givenTimelineEvent(timestamp = 1)
val poll2 = RoomPollFixture.anActivePollSummary(timestamp = 2) val poll2 = givenTimelineEvent(timestamp = 2)
val poll3 = RoomPollFixture.anActivePollSummary(timestamp = 3) val poll3 = givenTimelineEvent(timestamp = 3)
val polls = listOf<PollSummary>( val polls = listOf(
poll1, poll1,
poll2, poll2,
poll3, poll3,
) )
every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls) every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls)
val expectedPolls = listOf<PollSummary>( val expectedPolls = listOf(
poll3, poll3,
poll2, poll2,
poll1, poll1,
@ -60,4 +59,10 @@ class GetPollsUseCaseTest {
result shouldBeEqualTo expectedPolls result shouldBeEqualTo expectedPolls
verify { fakeRoomPollRepository.getPolls(aRoomId) } verify { fakeRoomPollRepository.getPolls(aRoomId) }
} }
private fun givenTimelineEvent(timestamp: Long): TimelineEvent {
return mockk<TimelineEvent>().also {
every { it.root.originServerTs } returns timestamp
}
}
} }

View file

@ -17,11 +17,13 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.coJustRun import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class LoadMorePollsUseCaseTest { class LoadMorePollsUseCaseTest {
@ -35,12 +37,18 @@ class LoadMorePollsUseCaseTest {
fun `given repo when execute then correct method of repo is called`() = runTest { fun `given repo when execute then correct method of repo is called`() = runTest {
// Given // Given
val aRoomId = "roomId" val aRoomId = "roomId"
coJustRun { fakeRoomPollRepository.loadMorePolls(aRoomId) } val loadedPollsStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
coEvery { fakeRoomPollRepository.loadMorePolls(aRoomId) } returns loadedPollsStatus
// When // When
loadMorePollsUseCase.execute(aRoomId) val result = loadMorePollsUseCase.execute(aRoomId)
// Then // Then
result shouldBeEqualTo loadedPollsStatus
coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) } coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) }
} }
} }

View file

@ -17,30 +17,81 @@
package im.vector.app.features.roomprofile.polls.list.domain package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.coEvery
import io.mockk.coJustRun import io.mockk.coJustRun
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk import io.mockk.mockk
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class SyncPollsUseCaseTest { class SyncPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>() private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
private val syncPollsUseCase = SyncPollsUseCase( private val syncPollsUseCase = SyncPollsUseCase(
roomPollRepository = fakeRoomPollRepository, roomPollRepository = fakeRoomPollRepository,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase,
) )
@Test @Test
fun `given repo when execute then correct method of repo is called`() = runTest { fun `given it has completed a sync backward when execute then only sync process is called`() = runTest {
// Given // Given
val aRoomId = "roomId" val aRoomId = "roomId"
val aLoadedStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) } coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) }
coEvery { fakeGetLoadedPollsStatusUseCase.execute(aRoomId) } returns aLoadedStatus
// When // When
syncPollsUseCase.execute(aRoomId) val result = syncPollsUseCase.execute(aRoomId)
// Then // Then
coVerify { fakeRoomPollRepository.syncPolls(aRoomId) } result shouldBeEqualTo aLoadedStatus
coVerifyOrder {
fakeRoomPollRepository.syncPolls(aRoomId)
fakeGetLoadedPollsStatusUseCase.execute(aRoomId)
}
coVerify(inverse = true) {
fakeLoadMorePollsUseCase.execute(any())
}
}
@Test
fun `given it has not completed a sync backward when execute then sync process and load more is called`() = runTest {
// Given
val aRoomId = "roomId"
val aLoadedStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = false,
)
val anUpdatedLoadedStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) }
coEvery { fakeGetLoadedPollsStatusUseCase.execute(aRoomId) } returns aLoadedStatus
coEvery { fakeLoadMorePollsUseCase.execute(aRoomId) } returns anUpdatedLoadedStatus
// When
val result = syncPollsUseCase.execute(aRoomId)
// Then
result shouldBeEqualTo anUpdatedLoadedStatus
coVerifyOrder {
fakeRoomPollRepository.syncPolls(aRoomId)
fakeGetLoadedPollsStatusUseCase.execute(aRoomId)
fakeLoadMorePollsUseCase.execute(aRoomId)
}
} }
} }

View file

@ -0,0 +1,201 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.features.roomprofile.polls.list.ui
import im.vector.app.core.extensions.getVectorLastMessageContent
import im.vector.app.features.home.room.detail.timeline.factory.PollOptionViewStateFactory
import im.vector.app.features.home.room.detail.timeline.helper.PollResponseDataFactory
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import org.amshove.kluent.shouldBe
import org.amshove.kluent.shouldBeEqualTo
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
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.timeline.TimelineEvent
private const val AN_EVENT_ID = "event-id"
private const val AN_EVENT_TIMESTAMP = 123L
private const val A_POLL_TITLE = "poll-title"
internal class PollSummaryMapperTest {
private val fakePollResponseDataFactory = mockk<PollResponseDataFactory>()
private val fakePollOptionViewStateFactory = mockk<PollOptionViewStateFactory>()
private val pollSummaryMapper = PollSummaryMapper(
pollResponseDataFactory = fakePollResponseDataFactory,
pollOptionViewStateFactory = fakePollOptionViewStateFactory,
)
@Before
fun setup() {
mockkStatic("im.vector.app.core.extensions.TimelineEventKt")
}
@After
fun tearDown() {
unmockkAll()
}
@Test
fun `given a not ended poll event when mapping to model then result is active poll`() {
// Given
val pollStartedEvent = givenAPollTimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = AN_EVENT_TIMESTAMP,
pollTitle = A_POLL_TITLE,
isClosed = false,
)
val expectedResult = PollSummary.ActivePoll(
id = AN_EVENT_ID,
creationTimestamp = AN_EVENT_TIMESTAMP,
title = A_POLL_TITLE,
)
// When
val result = pollSummaryMapper.map(pollStartedEvent)
// Then
result shouldBeEqualTo expectedResult
}
@Test
fun `given an ended poll event when mapping to model then result is ended poll`() {
// Given
val totalVotes = 10
val winnerOptions = listOf<PollOptionViewState.PollEnded>()
val endedPollEvent = givenAPollTimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = AN_EVENT_TIMESTAMP,
pollTitle = A_POLL_TITLE,
isClosed = true,
totalVotes = totalVotes,
winnerOptions = winnerOptions,
)
val expectedResult = PollSummary.EndedPoll(
id = AN_EVENT_ID,
creationTimestamp = AN_EVENT_TIMESTAMP,
title = A_POLL_TITLE,
totalVotes = totalVotes,
winnerOptions = winnerOptions,
)
// When
val result = pollSummaryMapper.map(endedPollEvent)
// Then
result shouldBeEqualTo expectedResult
}
@Test
fun `given missing data in event when mapping to model then result is null`() {
// Given
val noIdPollEvent = givenAPollTimelineEvent(
eventId = "",
creationTimestamp = AN_EVENT_TIMESTAMP,
pollTitle = A_POLL_TITLE,
isClosed = false,
)
val noTimestampPollEvent = givenAPollTimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = 0,
pollTitle = A_POLL_TITLE,
isClosed = false,
)
val notAPollEvent = givenATimelineEvent(
eventId = AN_EVENT_ID,
creationTimestamp = 0,
content = mockk<MessageTextContent>()
)
// When
val result1 = pollSummaryMapper.map(noIdPollEvent)
val result2 = pollSummaryMapper.map(noTimestampPollEvent)
val result3 = pollSummaryMapper.map(notAPollEvent)
// Then
result1 shouldBe null
result2 shouldBe null
result3 shouldBe null
}
private fun givenATimelineEvent(
eventId: String,
creationTimestamp: Long,
content: MessageContent,
): TimelineEvent {
val timelineEvent = mockk<TimelineEvent>()
every { timelineEvent.root.eventId } returns eventId
every { timelineEvent.root.originServerTs } returns creationTimestamp
every { timelineEvent.getVectorLastMessageContent() } returns content
return timelineEvent
}
private fun givenAPollTimelineEvent(
eventId: String,
creationTimestamp: Long,
pollTitle: String,
isClosed: Boolean,
totalVotes: Int = 0,
winnerOptions: List<PollOptionViewState.PollEnded> = emptyList(),
): TimelineEvent {
val pollCreationInfo = givenPollCreationInfo(pollTitle)
val messageContent = givenAMessagePollContent(pollCreationInfo)
val timelineEvent = givenATimelineEvent(eventId, creationTimestamp, messageContent)
val pollResponseData = givenAPollResponseData(isClosed, totalVotes)
every { fakePollResponseDataFactory.create(timelineEvent) } returns pollResponseData
every {
fakePollOptionViewStateFactory.createPollEndedOptions(
pollCreationInfo,
pollResponseData
)
} returns winnerOptions
return timelineEvent
}
private fun givenAMessagePollContent(pollCreationInfo: PollCreationInfo): MessagePollContent {
return MessagePollContent(
unstablePollCreationInfo = pollCreationInfo,
)
}
private fun givenPollCreationInfo(pollTitle: String): PollCreationInfo {
return PollCreationInfo(
question = PollQuestion(unstableQuestion = pollTitle),
)
}
private fun givenAPollResponseData(isClosed: Boolean, totalVotes: Int): PollResponseData {
return PollResponseData(
myVote = "",
votes = emptyMap(),
isClosed = isClosed,
totalVotes = totalVotes,
)
}
}

View file

@ -0,0 +1,75 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.test.fakes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
import io.mockk.verify
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import org.matrix.android.sdk.api.session.room.poll.PollHistoryService
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
class FakePollHistoryService : PollHistoryService by mockk() {
fun givenDispose() {
justRun { dispose() }
}
fun verifyDispose() {
verify { dispose() }
}
fun givenGetPollsReturns(events: List<TimelineEvent>): LiveData<List<TimelineEvent>> {
return MutableLiveData(events).also {
every { getPollEvents() } returns it
}
}
fun verifyGetPolls() {
verify { getPollEvents() }
}
fun givenGetLoadedPollsStatusReturns(status: LoadedPollsStatus) {
coEvery { getLoadedPollsStatus() } returns status
}
fun verifyGetLoadedPollsStatus() {
coVerify { getLoadedPollsStatus() }
}
fun givenLoadMoreReturns(status: LoadedPollsStatus) {
coEvery { loadMore() } returns status
}
fun verifyLoadMore() {
coVerify { loadMore() }
}
fun givenSyncPollsSuccess() {
coJustRun { syncPolls() }
}
fun verifySyncPolls() {
coVerify { syncPolls() }
}
}

View file

@ -25,6 +25,7 @@ class FakeRoom(
private val fakeTimelineService: FakeTimelineService = FakeTimelineService(), private val fakeTimelineService: FakeTimelineService = FakeTimelineService(),
private val fakeRelationService: FakeRelationService = FakeRelationService(), private val fakeRelationService: FakeRelationService = FakeRelationService(),
private val fakeStateService: FakeStateService = FakeStateService(), private val fakeStateService: FakeStateService = FakeStateService(),
private val fakePollHistoryService: FakePollHistoryService = FakePollHistoryService(),
) : Room by mockk() { ) : Room by mockk() {
override fun locationSharingService() = fakeLocationSharingService override fun locationSharingService() = fakeLocationSharingService
@ -36,4 +37,6 @@ class FakeRoom(
override fun relationService() = fakeRelationService override fun relationService() = fakeRelationService
override fun stateService() = fakeStateService override fun stateService() = fakeStateService
override fun pollHistoryService() = fakePollHistoryService
} }

View file

@ -0,0 +1,67 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.test.fixtures
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.item.PollResponseData
import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryData
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
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.PollType
import org.matrix.android.sdk.api.session.room.send.SendState
object PollFixture {
val A_MESSAGE_INFORMATION_DATA = MessageInformationData(
eventId = "eventId",
senderId = "senderId",
ageLocalTS = 0,
avatarUrl = "",
sendState = SendState.SENT,
messageLayout = TimelineMessageLayout.Default(showAvatar = true, showDisplayName = true, showTimestamp = true),
reactionsSummary = ReactionsSummaryData(),
sentByMe = true,
)
val A_POLL_RESPONSE_DATA = PollResponseData(
myVote = null,
votes = emptyMap(),
)
val A_POLL_OPTION_IDS = listOf("5ef5f7b0-c9a1-49cf-a0b3-374729a43e76", "ec1a4db0-46d8-4d7a-9bb6-d80724715938", "3677ca8e-061b-40ab-bffe-b22e4e88fcad")
val A_POLL_CONTENT = MessagePollContent(
unstablePollCreationInfo = PollCreationInfo(
question = PollQuestion(
unstableQuestion = "What is your favourite coffee?"
), kind = PollType.UNDISCLOSED_UNSTABLE, maxSelections = 1, answers = listOf(
PollAnswer(
id = A_POLL_OPTION_IDS[0], unstableAnswer = "Double Espresso"
),
PollAnswer(
id = A_POLL_OPTION_IDS[1], unstableAnswer = "Macchiato"
),
PollAnswer(
id = A_POLL_OPTION_IDS[2], unstableAnswer = "Iced Coffee"
),
)
)
)
}

View file

@ -1,47 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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 im.vector.app.test.fixtures
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
object RoomPollFixture {
fun anActivePollSummary(
id: String = "",
timestamp: Long,
title: String = "",
) = PollSummary.ActivePoll(
id = id,
creationTimestamp = timestamp,
title = title,
)
fun anEndedPollSummary(
id: String = "",
timestamp: Long,
title: String = "",
totalVotes: Int,
winnerOptions: List<PollOptionViewState.PollEnded>
) = PollSummary.EndedPoll(
id = id,
creationTimestamp = timestamp,
title = title,
totalVotes = totalVotes,
winnerOptions = winnerOptions,
)
}