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.relation.RelationService
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.reporting.ReportingService
import org.matrix.android.sdk.api.session.room.send.DraftService
@ -181,4 +182,9 @@ interface Room {
* Get the LocationSharingService associated to this Room.
*/
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.MigrateSessionTo048
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.database.MatrixRealmMigration
import javax.inject.Inject
@ -74,7 +75,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
private val normalizer: Normalizer
) : MatrixRealmMigration(
dbName = "Session",
schemaVersion = 49L,
schemaVersion = 50L,
) {
/**
* Forces all RealmSessionStoreMigration instances to be equal.
@ -133,5 +134,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 47) MigrateSessionTo047(realm).perform()
if (oldVersion < 48) MigrateSessionTo048(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(),
// list of related event ids which are encrypted due to decryption failure
var encryptedRelatedEventIds: RealmList<String> = RealmList(),
) : RealmObject() {
companion object
}
) : RealmObject()

View File

@ -73,6 +73,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit
UserPresenceEntity::class,
ThreadSummaryEntity::class,
ThreadListPageEntity::class,
PollHistoryStatusEntity::class,
]
)
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.relation.RelationService
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.reporting.ReportingService
import org.matrix.android.sdk.api.session.room.send.DraftService
@ -72,6 +73,7 @@ internal class DefaultRoom(
private val roomVersionService: RoomVersionService,
private val viaParameterFinder: ViaParameterFinder,
private val locationSharingService: LocationSharingService,
private val pollHistoryService: PollHistoryService,
override val coroutineDispatchers: MatrixCoroutineDispatchers
) : Room {
@ -116,4 +118,5 @@ internal class DefaultRoom(
override fun roomAccountDataService() = roomAccountDataService
override fun roomVersionService() = roomVersionService
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.membership.DefaultMembershipService
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.relation.DefaultRelationService
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 viaParameterFinder: ViaParameterFinder,
private val locationSharingServiceFactory: DefaultLocationSharingService.Factory,
private val pollHistoryServiceFactory: DefaultPollHistoryService.Factory,
private val coroutineDispatchers: MatrixCoroutineDispatchers
) : RoomFactory {
override fun create(roomId: String): Room {
val timelineService = timelineServiceFactory.create(roomId)
return DefaultRoom(
roomId = roomId,
roomSummaryDataSource = roomSummaryDataSource,
roomCryptoService = roomCryptoServiceFactory.create(roomId),
timelineService = timelineServiceFactory.create(roomId),
timelineService = timelineService,
threadsService = threadsServiceFactory.create(roomId),
threadsLocalService = threadsLocalServiceFactory.create(roomId),
sendService = sendServiceFactory.create(roomId),
@ -99,6 +102,7 @@ internal class DefaultRoomFactory @Inject constructor(
roomVersionService = roomVersionServiceFactory.create(roomId),
viaParameterFinder = viaParameterFinder,
locationSharingService = locationSharingServiceFactory.create(roomId),
pollHistoryService = pollHistoryServiceFactory.create(roomId, timelineService),
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.GetRoomDirectoryVisibilityTask
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.DefaultCheckIfExistingActiveLiveTask
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.PeekRoomTask
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.DefaultSetReadMarkersTask
import org.matrix.android.sdk.internal.session.room.read.MarkAllRoomsReadTask
@ -359,4 +367,16 @@ internal abstract class RoomModule {
@Binds
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
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.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.executeRequest
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.task.Task
import org.matrix.android.sdk.internal.util.awaitTransaction
import org.matrix.android.sdk.internal.util.time.Clock
import javax.inject.Inject
@VisibleForTesting
@ -54,10 +43,9 @@ internal interface FetchPollResponseEventsTask : Task<FetchPollResponseEventsTas
internal class DefaultFetchPollResponseEventsTask @Inject constructor(
private val roomAPI: RoomAPI,
private val globalErrorReceiver: GlobalErrorReceiver,
@SessionDatabase private val monarchy: Monarchy,
private val clock: Clock,
private val eventDecryptor: EventDecryptor,
) : FetchPollResponseEventsTask {
private val filterAndStoreEventsTask: FilterAndStoreEventsTask,
) : FetchPollResponseEventsTask {
override suspend fun execute(params: FetchPollResponseEventsTask.Params): Result<Unit> = runCatching {
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? {
val response = getRelatedEvents(params, from)
val filteredEvents = response.chunks
.map { decryptEventIfNeeded(it) }
.filter { it.isPollResponse() }
addMissingEventsInDB(params.roomId, filteredEvents)
val filterTaskParams = FilterAndStoreEventsTask.Params(
roomId = params.roomId,
events = response.chunks,
filterPredicate = { it.isPollResponse() }
)
filterAndStoreEventsTask.execute(filterTaskParams)
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
import io.mockk.coJustRun
import io.mockk.coVerify
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
@ -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.RelationType
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.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.event.FilterAndStoreEventsTask
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.FakeMonarchy
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)
internal class DefaultFetchPollResponseEventsTaskTest {
private val fakeRoomAPI = FakeRoomApi()
private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver()
private val fakeMonarchy = FakeMonarchy()
private val fakeClock = FakeClock()
private val fakeEventDecryptor = FakeEventDecryptor()
private val filterAndStoreEventsTask = mockk<FilterAndStoreEventsTask>()
private val defaultFetchPollResponseEventsTask = DefaultFetchPollResponseEventsTask(
roomAPI = fakeRoomAPI.instance,
globalErrorReceiver = fakeGlobalErrorReceiver,
monarchy = fakeMonarchy.instance,
clock = fakeClock,
eventDecryptor = fakeEventDecryptor.instance,
filterAndStoreEventsTask = filterAndStoreEventsTask,
)
@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")
}
@ -74,7 +62,7 @@ internal class DefaultFetchPollResponseEventsTaskTest {
}
@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
val aRoomId = "roomId"
val aPollEventId = "eventId"
@ -94,13 +82,7 @@ internal class DefaultFetchPollResponseEventsTaskTest {
fakeRoomAPI.givenGetRelationsReturns(from = null, relationsResponse = firstResponse)
val secondResponse = givenARelationsResponse(events = secondEvents, nextBatch = null)
fakeRoomAPI.givenGetRelationsReturns(from = aNextBatchToken, relationsResponse = secondResponse)
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
coJustRun { filterAndStoreEventsTask.execute(any()) }
// When
defaultFetchPollResponseEventsTask.execute(params)
@ -111,21 +93,22 @@ internal class DefaultFetchPollResponseEventsTaskTest {
eventId = params.startPollEventId,
relationType = RelationType.REFERENCE,
from = null,
limit = FETCH_RELATED_EVENTS_LIMIT
limit = FETCH_RELATED_EVENTS_LIMIT,
)
fakeRoomAPI.verifyGetRelations(
roomId = params.roomId,
eventId = params.startPollEventId,
relationType = RelationType.REFERENCE,
from = aNextBatchToken,
limit = FETCH_RELATED_EVENTS_LIMIT
limit = FETCH_RELATED_EVENTS_LIMIT,
)
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)
coVerify {
filterAndStoreEventsTask.execute(match {
it.roomId == aRoomId && it.events == firstEvents
})
filterAndStoreEventsTask.execute(match {
it.roomId == aRoomId && it.events == secondEvents
})
}
}
@ -153,11 +136,4 @@ internal class DefaultFetchPollResponseEventsTaskTest {
every { event.isEncrypted() } returns isEncrypted
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.core.resources.StringProvider
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.poll.PollViewState
import org.matrix.android.sdk.api.extensions.orFalse
@ -29,6 +28,7 @@ import javax.inject.Inject
class PollItemViewStateFactory @Inject constructor(
private val stringProvider: StringProvider,
private val pollOptionViewStateFactory: PollOptionViewStateFactory,
) {
fun create(
@ -40,7 +40,6 @@ class PollItemViewStateFactory @Inject constructor(
val question = pollCreationInfo?.question?.getBestQuestion().orEmpty()
val pollResponseSummary = informationData.pollResponseAggregatedSummary
val winnerVoteCount = pollResponseSummary?.winnerVoteCount
val totalVotes = pollResponseSummary?.totalVotes ?: 0
return when {
@ -48,7 +47,7 @@ class PollItemViewStateFactory @Inject constructor(
createSendingPollViewState(question, pollCreationInfo)
}
informationData.pollResponseAggregatedSummary?.isClosed.orFalse() -> {
createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes, winnerVoteCount)
createEndedPollViewState(question, pollCreationInfo, pollResponseSummary, totalVotes)
}
pollContent.getBestPollCreationInfo()?.isUndisclosed().orFalse() -> {
createUndisclosedPollViewState(question, pollCreationInfo, pollResponseSummary)
@ -67,12 +66,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question,
votesStatus = stringProvider.getString(R.string.poll_no_votes_cast),
canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
optionViewStates = pollOptionViewStateFactory.createPollSendingOptions(pollCreationInfo),
)
}
@ -81,7 +75,6 @@ class PollItemViewStateFactory @Inject constructor(
pollCreationInfo: PollCreationInfo?,
pollResponseSummary: PollResponseData?,
totalVotes: Int,
winnerVoteCount: Int?,
): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
@ -92,16 +85,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question,
votesStatus = totalVotesText,
canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
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
)
},
optionViewStates = pollOptionViewStateFactory.createPollEndedOptions(pollCreationInfo, pollResponseSummary),
)
}
@ -114,14 +98,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question,
votesStatus = stringProvider.getString(R.string.poll_undisclosed_not_ended),
canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
val isMyVote = pollResponseSummary?.myVote == answer.id
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = isMyVote
)
},
optionViewStates = pollOptionViewStateFactory.createPollUndisclosedOptions(pollCreationInfo, pollResponseSummary),
)
}
@ -140,17 +117,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question,
votesStatus = totalVotesText,
canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
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
)
},
optionViewStates = pollOptionViewStateFactory.createPollVotedOptions(pollCreationInfo, pollResponseSummary),
)
}
@ -168,12 +135,7 @@ class PollItemViewStateFactory @Inject constructor(
question = question,
votesStatus = totalVotesText,
canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer ->
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
optionViewStates = pollOptionViewStateFactory.createPollReadyOptions(pollCreationInfo),
)
}
}

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() {
roomProfileController.callback = null
views.matrixProfileAppBarLayout.removeOnOffsetChangedListener(appBarStateChangeListener)
views.matrixProfileRecyclerView.cleanup()
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.hiltMavericksViewModelFactory
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.LoadMorePollsUseCase
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.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class RoomPollsViewModel @AssistedInject constructor(
@Assisted initialState: RoomPollsViewState,
private val getPollsUseCase: GetPollsUseCase,
private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase,
private val loadMorePollsUseCase: LoadMorePollsUseCase,
private val syncPollsUseCase: SyncPollsUseCase,
private val disposePollHistoryUseCase: DisposePollHistoryUseCase,
private val pollSummaryMapper: PollSummaryMapper,
) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) {
@AssistedFactory
@ -48,26 +51,26 @@ class RoomPollsViewModel @AssistedInject constructor(
init {
val roomId = initialState.roomId
updateLoadedPollStatus(roomId)
syncPolls(roomId)
observePolls(roomId)
}
private fun updateLoadedPollStatus(roomId: String) {
val loadedPollsStatus = getLoadedPollsStatusUseCase.execute(roomId)
setState {
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays
)
}
override fun onCleared() {
withState { disposePollHistoryUseCase.execute(it.roomId) }
super.onCleared()
}
private fun syncPolls(roomId: String) {
viewModelScope.launch {
setState { copy(isSyncing = true) }
val result = runCatching {
syncPollsUseCase.execute(roomId)
val loadedPollsStatus = syncPollsUseCase.execute(roomId)
setState {
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbSyncedDays = loadedPollsStatus.daysSynced,
)
}
}
if (result.isFailure) {
_viewEvents.post(RoomPollsViewEvent.LoadingError)
@ -78,6 +81,7 @@ class RoomPollsViewModel @AssistedInject constructor(
private fun observePolls(roomId: String) {
getPollsUseCase.execute(roomId)
.map { it.mapNotNull { event -> pollSummaryMapper.map(event) } }
.onEach { setState { copy(polls = it) } }
.launchIn(viewModelScope)
}
@ -96,7 +100,7 @@ class RoomPollsViewModel @AssistedInject constructor(
setState {
copy(
canLoadMore = status.canLoadMore,
nbLoadedDays = status.nbLoadedDays,
nbSyncedDays = status.daysSynced,
)
}
}

View File

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

View File

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

View File

@ -16,159 +16,44 @@
package im.vector.app.features.roomprofile.polls.list.data
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.delay
import androidx.lifecycle.asFlow
import im.vector.app.core.di.ActiveSessionHolder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import timber.log.Timber
import org.matrix.android.sdk.api.session.getRoom
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 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 val polls = mutableListOf<PollSummary>()
private var fakeLoadCounter = 0
// TODO
// unmock using SDK service + add unit tests
// 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()
private fun getPollHistoryService(roomId: String): PollHistoryService {
return activeSessionHolder
.getSafeActiveSession()
?.getRoom(roomId)
?.pollHistoryService()
?: throw PollHistoryError.UnknownRoomError
}
fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
Timber.d("roomId=$roomId")
return LoadedPollsStatus(
canLoadMore = canLoadMore(),
nbLoadedDays = fakeLoadCounter * 30,
)
fun dispose(roomId: String) {
getPollHistoryService(roomId).dispose()
}
private fun canLoadMore(): Boolean {
return fakeLoadCounter < 2
fun getPolls(roomId: String): Flow<List<TimelineEvent>> {
return getPollHistoryService(roomId).getPollEvents().asFlow()
}
suspend fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
return getPollHistoryService(roomId).getLoadedPollsStatus()
}
suspend fun loadMorePolls(roomId: String): LoadedPollsStatus {
// TODO
// 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,
)
),
),
)
return getPollHistoryService(roomId).loadMore()
}
suspend fun syncPolls(roomId: String) {
Timber.d("roomId=$roomId")
// TODO
// unmock using SDK service + add unit tests
if (fakeLoadCounter == 0) {
// fake first load
loadMorePolls(roomId)
} else {
// fake sync
delay(3000)
}
getPollHistoryService(roomId).syncPolls()
}
}

View File

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

View File

@ -17,17 +17,17 @@
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.ui.PollSummary
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
class GetPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
fun execute(roomId: String): Flow<List<PollSummary>> {
fun execute(roomId: String): Flow<List<TimelineEvent>> {
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
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
import javax.inject.Inject
class LoadMorePollsUseCase @Inject constructor(

View File

@ -17,16 +17,26 @@
package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
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(
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)
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.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays,
nbLoadedDays = viewState.nbSyncedDays,
)
}
@ -117,7 +117,7 @@ abstract class RoomPollsListFragment :
roomPollsController.setData(viewState)
views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays,
nbLoadedDays = viewState.nbSyncedDays,
)
views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls()
views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore()

View File

@ -17,127 +17,71 @@
package im.vector.app.features.home.room.detail.timeline.factory
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.PollResponseData
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.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.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.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 {
private val fakeStringProvider = FakeStringProvider()
private val fakePollOptionViewStateFactory = mockk<PollOptionViewStateFactory>()
private val pollItemViewStateFactory = PollItemViewStateFactory(
stringProvider = fakeStringProvider.instance,
pollOptionViewStateFactory = fakePollOptionViewStateFactory,
)
@Test
fun `given a sending poll state then poll is not votable and option states are PollSending`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
// Given
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(
pollContent = A_POLL_CONTENT,
informationData = sendingPollInformationData,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
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,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
PollOptionViewState.PollSending(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
optionViewStates = optionViewStates,
)
verify { fakePollOptionViewStateFactory.createPollSendingOptions(A_POLL_CONTENT.getBestPollCreationInfo()) }
}
@Test
fun `given a sent poll state when poll is closed then poll is not votable and option states are Ended`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
// Given
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
val optionViewStates = listOf(
PollOptionViewState.PollEnded(
optionId = "", optionAnswer = "", voteCount = 0, votePercentage = 0.0, isWinner = false
)
)
pollViewState shouldBeEqualTo PollViewState(
question = A_POLL_CONTENT.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "",
votesStatus = stringProvider.instance.getQuantityString(R.plurals.poll_total_vote_count_after_ended, 0, 0),
canVote = false,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
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)
every {
fakePollOptionViewStateFactory.createPollEndedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
closedPollInformationData.pollResponseAggregatedSummary,
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
@ -146,42 +90,90 @@ class PollItemViewStateFactoryTest {
)
// 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
fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
// Given
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(
pollContent = A_POLL_CONTENT,
informationData = A_MESSAGE_INFORMATION_DATA,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
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,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
PollOptionViewState.PollUndisclosed(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: "",
isSelected = false
)
},
optionViewStates = optionViewStates,
)
verify {
fakePollOptionViewStateFactory.createPollUndisclosedOptions(
A_POLL_CONTENT.getBestPollCreationInfo(),
A_MESSAGE_INFORMATION_DATA.pollResponseAggregatedSummary,
)
}
}
@Test
fun `given a sent poll when my vote exists then poll is still votable and options states are PollVoted`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
// Given
val votedPollData = 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))
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(
@ -189,33 +181,46 @@ class PollItemViewStateFactoryTest {
),
)
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(
pollContent = disclosedPollContent,
informationData = votedInformationData,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
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,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.mapIndexed { index, answer ->
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
)
},
optionViewStates = optionViewStates,
)
verify {
fakePollOptionViewStateFactory.createPollVotedOptions(
disclosedPollContent.getBestPollCreationInfo(),
votedInformationData.pollResponseAggregatedSummary,
)
}
}
@Test
fun `given a sent poll with decryption failure when my vote exists then a warning message is displayed`() {
// Given
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val votedPollData = A_POLL_RESPONSE_DATA.copy(
totalVotes = 1,
myVote = A_POLL_OPTION_IDS[0],
@ -228,6 +233,21 @@ class PollItemViewStateFactoryTest {
),
)
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(
@ -236,34 +256,46 @@ class PollItemViewStateFactoryTest {
)
// 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
fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() {
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
// Given
val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE
)
)
val optionViewStates = listOf(
PollOptionViewState.PollReady(
optionId = "",
optionAnswer = "",
)
)
every {
fakePollOptionViewStateFactory.createPollReadyOptions(
disclosedPollContent.getBestPollCreationInfo(),
)
} returns optionViewStates
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = A_MESSAGE_INFORMATION_DATA,
)
// Then
pollViewState shouldBeEqualTo PollViewState(
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,
optionViewStates = A_POLL_CONTENT.getBestPollCreationInfo()?.answers?.map { answer ->
PollOptionViewState.PollReady(
optionId = answer.id ?: "",
optionAnswer = answer.getBestAnswer() ?: ""
)
},
optionViewStates = optionViewStates,
)
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
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.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.LoadMorePollsUseCase
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.PollSummaryMapper
import im.vector.app.test.test
import im.vector.app.test.testDispatcher
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 kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import org.junit.Rule
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"
@ -42,33 +45,37 @@ class RoomPollsViewModelTest {
@get:Rule
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
private val initialState = RoomPollsViewState(A_ROOM_ID)
private val fakeGetPollsUseCase = mockk<GetPollsUseCase>()
private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
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 {
return RoomPollsViewModel(
initialState = initialState,
getPollsUseCase = fakeGetPollsUseCase,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase,
syncPollsUseCase = fakeSyncPollsUseCase,
disposePollHistoryUseCase = fakeDisposePollHistoryUseCase,
pollSummaryMapper = fakePollSummaryMapper,
)
}
@Test
fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() {
// Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithSuccess()
val polls = listOf(givenAPollSummary())
val loadedPollsStatus = givenSyncPollsWithSuccess()
val aPollEvent = givenAPollEvent()
val aPollSummary = givenAPollSummary()
val polls = listOf(aPollEvent)
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
every { fakePollSummaryMapper.map(aPollEvent) } returns aPollSummary
val expectedViewState = initialState.copy(
polls = polls,
polls = listOf(aPollSummary),
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays,
nbSyncedDays = loadedPollsStatus.daysSynced,
)
// When
@ -81,6 +88,7 @@ class RoomPollsViewModelTest {
.finish()
verify {
fakeGetPollsUseCase.execute(A_ROOM_ID)
fakePollSummaryMapper.map(aPollEvent)
}
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
}
@ -88,10 +96,8 @@ class RoomPollsViewModelTest {
@Test
fun `given viewModel and error during sync process when created then error is raised in view event`() {
// Given
givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithError(Exception())
val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow()
// When
val viewModel = createViewModel()
@ -104,19 +110,32 @@ class RoomPollsViewModelTest {
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
fun `given viewModel when handle load more action then viewState is updated`() {
// Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithSuccess()
val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
val loadedPollsStatus = givenSyncPollsWithSuccess()
every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns emptyFlow()
val newLoadedPollsStatus = givenLoadMoreWithSuccess()
val viewModel = createViewModel()
val stateAfterInit = initialState.copy(
polls = polls,
polls = emptyList(),
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays,
nbSyncedDays = loadedPollsStatus.daysSynced,
)
// When
@ -128,7 +147,7 @@ class RoomPollsViewModelTest {
.assertStatesChanges(
stateAfterInit,
{ copy(isLoadingMore = true) },
{ copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbLoadedDays = newLoadedPollsStatus.nbLoadedDays) },
{ copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbSyncedDays = newLoadedPollsStatus.daysSynced) },
{ copy(isLoadingMore = false) },
)
.finish()
@ -139,8 +158,14 @@ class RoomPollsViewModelTest {
return mockk()
}
private fun givenSyncPollsWithSuccess() {
coJustRun { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
private fun givenAPollEvent(): TimelineEvent {
return mockk()
}
private fun givenSyncPollsWithSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus()
coEvery { fakeSyncPollsUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
}
private fun givenSyncPollsWithError(error: Exception) {
@ -148,20 +173,15 @@ class RoomPollsViewModelTest {
}
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
return loadedPollsStatus
}
private fun givenGetLoadedPollsStatusSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus()
every { fakeGetLoadedPollsStatusUseCase.execute(A_ROOM_ID) } returns loadedPollsStatus
return loadedPollsStatus
}
private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbLoadedDays: Int = 10) =
private fun givenALoadedPollsStatus(canLoadMore: Boolean = true, nbSyncedDays: Int = 10) =
LoadedPollsStatus(
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
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
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 kotlinx.coroutines.flow.firstOrNull
@ -27,6 +28,8 @@ import kotlinx.coroutines.flow.flowOf
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"
@ -38,10 +41,22 @@ class RoomPollRepositoryTest {
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
fun `given data source when getting polls then correct method of data source is called`() = runTest {
// Given
val expectedPolls = listOf<PollSummary>()
val expectedPolls = listOf<TimelineEvent>()
every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls)
// When
@ -53,20 +68,21 @@ class RoomPollRepositoryTest {
}
@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
val expectedStatus = LoadedPollsStatus(
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
val result = roomPollRepository.getLoadedPollsStatus(A_ROOM_ID)
// Then
result shouldBeEqualTo expectedStatus
verify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) }
coVerify { fakeRoomPollDataSource.getLoadedPollsStatus(A_ROOM_ID) }
}
@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
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
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.verify
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class GetLoadedPollsStatusUseCaseTest {
@ -33,20 +34,21 @@ class GetLoadedPollsStatusUseCaseTest {
)
@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
val aRoomId = "roomId"
val expectedStatus = LoadedPollsStatus(
canLoadMore = true,
nbLoadedDays = 10,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
every { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus
coEvery { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus
// When
val status = getLoadedPollsStatusUseCase.execute(aRoomId)
// Then
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
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.mockk
import io.mockk.verify
@ -27,6 +25,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
class GetPollsUseCaseTest {
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 {
// Given
val aRoomId = "roomId"
val poll1 = RoomPollFixture.anActivePollSummary(timestamp = 1)
val poll2 = RoomPollFixture.anActivePollSummary(timestamp = 2)
val poll3 = RoomPollFixture.anActivePollSummary(timestamp = 3)
val polls = listOf<PollSummary>(
val poll1 = givenTimelineEvent(timestamp = 1)
val poll2 = givenTimelineEvent(timestamp = 2)
val poll3 = givenTimelineEvent(timestamp = 3)
val polls = listOf(
poll1,
poll2,
poll3,
)
every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls)
val expectedPolls = listOf<PollSummary>(
val expectedPolls = listOf(
poll3,
poll2,
poll1,
@ -60,4 +59,10 @@ class GetPollsUseCaseTest {
result shouldBeEqualTo expectedPolls
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
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.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class LoadMorePollsUseCaseTest {
@ -35,12 +37,18 @@ class LoadMorePollsUseCaseTest {
fun `given repo when execute then correct method of repo is called`() = runTest {
// Given
val aRoomId = "roomId"
coJustRun { fakeRoomPollRepository.loadMorePolls(aRoomId) }
val loadedPollsStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
coEvery { fakeRoomPollRepository.loadMorePolls(aRoomId) } returns loadedPollsStatus
// When
loadMorePollsUseCase.execute(aRoomId)
val result = loadMorePollsUseCase.execute(aRoomId)
// Then
result shouldBeEqualTo loadedPollsStatus
coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) }
}
}

View File

@ -17,30 +17,81 @@
package im.vector.app.features.roomprofile.polls.list.domain
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import org.matrix.android.sdk.api.session.room.poll.LoadedPollsStatus
class SyncPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
private val syncPollsUseCase = SyncPollsUseCase(
roomPollRepository = fakeRoomPollRepository,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase,
)
@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
val aRoomId = "roomId"
val aLoadedStatus = LoadedPollsStatus(
canLoadMore = true,
daysSynced = 10,
hasCompletedASyncBackward = true,
)
coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) }
coEvery { fakeGetLoadedPollsStatusUseCase.execute(aRoomId) } returns aLoadedStatus
// When
syncPollsUseCase.execute(aRoomId)
val result = syncPollsUseCase.execute(aRoomId)
// 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 fakeRelationService: FakeRelationService = FakeRelationService(),
private val fakeStateService: FakeStateService = FakeStateService(),
private val fakePollHistoryService: FakePollHistoryService = FakePollHistoryService(),
) : Room by mockk() {
override fun locationSharingService() = fakeLocationSharingService
@ -36,4 +37,6 @@ class FakeRoom(
override fun relationService() = fakeRelationService
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,
)
}