Merge branch 'develop' into feature/bca/rust_flavor

This commit is contained in:
valere 2023-01-18 23:38:10 +01:00
commit 1ab4a2fd8a
63 changed files with 1887 additions and 211 deletions

1
changelog.d/7824.feature Normal file
View File

@ -0,0 +1 @@
[Poll] Warning message on decryption failure of some events

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

@ -0,0 +1 @@
[Poll] History list: Load more UI mechanism

View File

@ -18,7 +18,7 @@ def markwon = "4.6.2"
def moshi = "1.14.0" def moshi = "1.14.0"
def lifecycle = "2.5.1" def lifecycle = "2.5.1"
def flowBinding = "1.2.0" def flowBinding = "1.2.0"
def flipper = "0.176.1" def flipper = "0.177.0"
def epoxy = "5.0.0" def epoxy = "5.0.0"
def mavericks = "3.0.1" def mavericks = "3.0.1"
def glide = "4.14.2" def glide = "4.14.2"
@ -103,7 +103,7 @@ ext.libs = [
], ],
element : [ element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0", 'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.17.0" 'wysiwyg' : "io.element.android:wysiwyg:0.18.0"
], ],
squareup : [ squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshi' : "com.squareup.moshi:moshi:$moshi",

View File

@ -795,7 +795,7 @@
<string name="thread_list_modal_my_threads_subtitle">Shows all threads youve participated in</string> <string name="thread_list_modal_my_threads_subtitle">Shows all threads youve participated in</string>
<string name="thread_list_empty_title">Keep discussions organized with threads</string> <string name="thread_list_empty_title">Keep discussions organized with threads</string>
<string name="thread_list_empty_subtitle">Threads help keep your conversations on-topic and easy to track.</string> <string name="thread_list_empty_subtitle">Threads help keep your conversations on-topic and easy to track.</string>
<string name="thread_list_not_available">You\'re homeserver does not support listing threads yet.</string> <string name="thread_list_not_available">Your homeserver does not support listing threads yet.</string>
<!-- Parameter %s will be replaced by the value of string reply_in_thread --> <!-- Parameter %s will be replaced by the value of string reply_in_thread -->
<string name="thread_list_empty_notice">Tip: Long tap a message and use “%s”.</string> <string name="thread_list_empty_notice">Tip: Long tap a message and use “%s”.</string>
<string name="search_thread_from_a_thread">From a Thread</string> <string name="search_thread_from_a_thread">From a Thread</string>
@ -3207,10 +3207,22 @@
<string name="closed_poll_option_title">Closed poll</string> <string name="closed_poll_option_title">Closed poll</string>
<string name="closed_poll_option_description">Results are only revealed when you end the poll</string> <string name="closed_poll_option_description">Results are only revealed when you end the poll</string>
<string name="ended_poll_indicator">Ended the poll.</string> <string name="ended_poll_indicator">Ended the poll.</string>
<string name="unable_to_decrypt_some_events_in_poll">Due to decryption errors, some votes may not be counted</string>
<string name="room_polls_active">Active polls</string> <string name="room_polls_active">Active polls</string>
<string name="room_polls_active_no_item">There are no active polls in this room</string> <string name="room_polls_active_no_item">There are no active polls in this room</string>
<plurals name="room_polls_active_no_item_for_loaded_period">
<item quantity="one">"There are no active polls for the past day.\nLoad more polls to view polls for previous days."</item>
<item quantity="other">"There are no active polls for the past %1$d days.\nLoad more polls to view polls for previous days."</item>
</plurals>
<string name="room_polls_ended">Past polls</string> <string name="room_polls_ended">Past polls</string>
<string name="room_polls_ended_no_item">There are no past polls in this room</string> <string name="room_polls_ended_no_item">There are no past polls in this room</string>
<plurals name="room_polls_ended_no_item_for_loaded_period">
<item quantity="one">"There are no past polls for the past day.\nLoad more polls to view polls for previous days."</item>
<item quantity="other">"There are no past polls for the past %1$d days.\nLoad more polls to view polls for previous days."</item>
</plurals>
<string name="room_polls_wait_for_display">Displaying polls</string>
<string name="room_polls_load_more">Load more polls</string>
<string name="room_polls_loading_error">Error fetching polls.</string>
<!-- Location --> <!-- Location -->
<string name="location_activity_title_static_sharing">Share location</string> <string name="location_activity_title_static_sharing">Share location</string>

View File

@ -59,7 +59,7 @@ internal class EventDecryptor @Inject constructor(
private val sendToDeviceTask: SendToDeviceTask, private val sendToDeviceTask: SendToDeviceTask,
private val deviceListManager: DeviceListManager, private val deviceListManager: DeviceListManager,
private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction,
private val cryptoStore: IMXCryptoStore private val cryptoStore: IMXCryptoStore,
) { ) {
/** /**

View File

@ -23,5 +23,7 @@ data class PollResponseAggregatedSummary(
val nbOptions: Int = 0, val nbOptions: Int = 0,
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
val sourceEvents: List<String>, val sourceEvents: List<String>,
val localEchos: List<String> val localEchos: List<String>,
// list of related event ids which are encrypted due to decryption failure
val encryptedRelatedEventIds: List<String>,
) )

View File

@ -17,11 +17,16 @@
package org.matrix.android.sdk.internal.database package org.matrix.android.sdk.internal.database
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import io.realm.Realm
import io.realm.RealmConfiguration import io.realm.RealmConfiguration
import io.realm.RealmResults import io.realm.RealmResults
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.asDomain
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertEntity import org.matrix.android.sdk.internal.database.model.EventInsertEntity
@ -34,7 +39,7 @@ import javax.inject.Inject
internal class EventInsertLiveObserver @Inject constructor( internal class EventInsertLiveObserver @Inject constructor(
@SessionDatabase realmConfiguration: RealmConfiguration, @SessionDatabase realmConfiguration: RealmConfiguration,
private val processors: Set<@JvmSuppressWildcards EventInsertLiveProcessor> private val processors: Set<@JvmSuppressWildcards EventInsertLiveProcessor>,
) : ) :
RealmLiveEntityObserver<EventInsertEntity>(realmConfiguration) { RealmLiveEntityObserver<EventInsertEntity>(realmConfiguration) {
@ -50,48 +55,90 @@ internal class EventInsertLiveObserver @Inject constructor(
if (!results.isLoaded || results.isEmpty()) { if (!results.isLoaded || results.isEmpty()) {
return@withLock return@withLock
} }
val idsToDeleteAfterProcess = ArrayList<String>() val eventsToProcess = ArrayList<EventInsertEntity>(results.size)
val filteredEvents = ArrayList<EventInsertEntity>(results.size) val eventsToIgnore = ArrayList<EventInsertEntity>(results.size)
Timber.v("EventInsertEntity updated with ${results.size} results in db") Timber.v("EventInsertEntity updated with ${results.size} results in db")
results.forEach { results.forEach {
if (shouldProcess(it)) { // don't use copy from realm over there
// don't use copy from realm over there val copiedEvent = EventInsertEntity(
val copiedEvent = EventInsertEntity( eventId = it.eventId,
eventId = it.eventId, eventType = it.eventType
eventType = it.eventType ).apply {
).apply { insertType = it.insertType
insertType = it.insertType }
}
filteredEvents.add(copiedEvent) if (shouldProcess(it)) {
eventsToProcess.add(copiedEvent)
} else {
eventsToIgnore.add(copiedEvent)
} }
idsToDeleteAfterProcess.add(it.eventId)
} }
awaitTransaction(realmConfiguration) { realm -> awaitTransaction(realmConfiguration) { realm ->
Timber.v("##Transaction: There are ${filteredEvents.size} events to process ") Timber.v("##Transaction: There are ${eventsToProcess.size} events to process")
filteredEvents.forEach { eventInsert ->
val idsToDeleteAfterProcess = ArrayList<String>()
val idsOfEncryptedEvents = ArrayList<String>()
val getAndTriageEvent: (EventInsertEntity) -> Event? = { eventInsert ->
val eventId = eventInsert.eventId val eventId = eventInsert.eventId
val event = EventEntity.where(realm, eventId).findFirst() val event = getEvent(realm, eventId)
if (event == null) { if (event?.getClearType() == EventType.ENCRYPTED) {
Timber.v("Event $eventId not found") idsOfEncryptedEvents.add(eventId)
} else {
idsToDeleteAfterProcess.add(eventId)
}
event
}
eventsToProcess.forEach { eventInsert ->
val eventId = eventInsert.eventId
val event = getAndTriageEvent(eventInsert)
if (event != null && canProcessEvent(event)) {
processors.filter {
it.shouldProcess(eventId, event.getClearType(), eventInsert.insertType)
}.forEach {
it.process(realm, event)
}
} else {
Timber.v("Cannot process event with id $eventId")
return@forEach return@forEach
} }
val domainEvent = event.asDomain()
processors.filter {
it.shouldProcess(eventId, domainEvent.getClearType(), eventInsert.insertType)
}.forEach {
it.process(realm, domainEvent)
}
} }
eventsToIgnore.forEach { getAndTriageEvent(it) }
realm.where(EventInsertEntity::class.java) realm.where(EventInsertEntity::class.java)
.`in`(EventInsertEntityFields.EVENT_ID, idsToDeleteAfterProcess.toTypedArray()) .`in`(EventInsertEntityFields.EVENT_ID, idsToDeleteAfterProcess.toTypedArray())
.findAll() .findAll()
.deleteAllFromRealm() .deleteAllFromRealm()
// make the encrypted events not processable: they will be processed again after decryption
realm.where(EventInsertEntity::class.java)
.`in`(EventInsertEntityFields.EVENT_ID, idsOfEncryptedEvents.toTypedArray())
.findAll()
.forEach { it.canBeProcessed = false }
} }
processors.forEach { it.onPostProcess() } processors.forEach { it.onPostProcess() }
} }
} }
} }
private fun getEvent(realm: Realm, eventId: String): Event? {
val event = EventEntity.where(realm, eventId).findFirst()
if (event == null) {
Timber.v("Event $eventId not found")
}
return event?.asDomain()
}
private fun canProcessEvent(event: Event): Boolean {
// event should be either not encrypted or if encrypted it should contain relatesTo content
return event.getClearType() != EventType.ENCRYPTED ||
event.content.toModel<EncryptedEventContent>()?.relatesTo != null
}
private fun shouldProcess(eventInsertEntity: EventInsertEntity): Boolean { private fun shouldProcess(eventInsertEntity: EventInsertEntity): Boolean {
return processors.any { return processors.any {
it.shouldProcess(eventInsertEntity.eventId, eventInsertEntity.eventType, eventInsertEntity.insertType) it.shouldProcess(eventInsertEntity.eventId, eventInsertEntity.eventType, eventInsertEntity.insertType)

View File

@ -64,6 +64,7 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo044
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo045 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo045
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo046 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo046
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo048
import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.Normalizer
import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
import javax.inject.Inject import javax.inject.Inject
@ -72,7 +73,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
private val normalizer: Normalizer private val normalizer: Normalizer
) : MatrixRealmMigration( ) : MatrixRealmMigration(
dbName = "Session", dbName = "Session",
schemaVersion = 47L, schemaVersion = 48L,
) { ) {
/** /**
* Forces all RealmSessionStoreMigration instances to be equal. * Forces all RealmSessionStoreMigration instances to be equal.
@ -129,5 +130,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
if (oldVersion < 45) MigrateSessionTo045(realm).perform() if (oldVersion < 45) MigrateSessionTo045(realm).perform()
if (oldVersion < 46) MigrateSessionTo046(realm).perform() if (oldVersion < 46) MigrateSessionTo046(realm).perform()
if (oldVersion < 47) MigrateSessionTo047(realm).perform() if (oldVersion < 47) MigrateSessionTo047(realm).perform()
if (oldVersion < 48) MigrateSessionTo048(realm).perform()
} }
} }

View File

@ -30,7 +30,8 @@ internal object PollResponseAggregatedSummaryEntityMapper {
closedTime = entity.closedTime, closedTime = entity.closedTime,
localEchos = entity.sourceLocalEchoEvents.toList(), localEchos = entity.sourceLocalEchoEvents.toList(),
sourceEvents = entity.sourceEvents.toList(), sourceEvents = entity.sourceEvents.toList(),
nbOptions = entity.nbOptions nbOptions = entity.nbOptions,
encryptedRelatedEventIds = entity.encryptedRelatedEventIds.toList(),
) )
} }
@ -40,7 +41,8 @@ internal object PollResponseAggregatedSummaryEntityMapper {
nbOptions = model.nbOptions, nbOptions = model.nbOptions,
closedTime = model.closedTime, closedTime = model.closedTime,
sourceEvents = RealmList<String>().apply { addAll(model.sourceEvents) }, sourceEvents = RealmList<String>().apply { addAll(model.sourceEvents) },
sourceLocalEchoEvents = RealmList<String>().apply { addAll(model.localEchos) } sourceLocalEchoEvents = RealmList<String>().apply { addAll(model.localEchos) },
encryptedRelatedEventIds = RealmList<String>().apply { addAll(model.encryptedRelatedEventIds) },
) )
} }
} }

View File

@ -0,0 +1,32 @@
/*
* 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.PollResponseAggregatedSummaryEntityFields
import org.matrix.android.sdk.internal.util.database.RealmMigrator
/**
* Adding a new field in poll summary to keep track of non decrypted related events.
*/
internal class MigrateSessionTo048(realm: DynamicRealm) : RealmMigrator(realm, 48) {
override fun doMigrate(realm: DynamicRealm) {
realm.schema.get("PollResponseAggregatedSummaryEntity")
?.addRealmListField(PollResponseAggregatedSummaryEntityFields.ENCRYPTED_RELATED_EVENT_IDS.`$`, String::class.java)
}
}

View File

@ -27,7 +27,7 @@ internal open class EventInsertEntity(
var eventType: String = "", var eventType: String = "",
/** /**
* This flag will be used to filter EventInsertEntity in EventInsertLiveObserver. * This flag will be used to filter EventInsertEntity in EventInsertLiveObserver.
* Currently it's set to false when the event content is encrypted. * Currently it's set to false after an event with encrypted content has been processed.
*/ */
var canBeProcessed: Boolean = true var canBeProcessed: Boolean = true
) : RealmObject() { ) : RealmObject() {

View File

@ -33,7 +33,9 @@ internal open class PollResponseAggregatedSummaryEntity(
// The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk) // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
var sourceEvents: RealmList<String> = RealmList(), var sourceEvents: RealmList<String> = RealmList(),
var sourceLocalEchoEvents: RealmList<String> = RealmList() var sourceLocalEchoEvents: RealmList<String> = RealmList(),
// list of related event ids which are encrypted due to decryption failure
var encryptedRelatedEventIds: RealmList<String> = RealmList(),
) : RealmObject() { ) : RealmObject() {
companion object companion object

View File

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

View File

@ -20,7 +20,6 @@ import io.realm.Realm
import io.realm.RealmList import io.realm.RealmList
import io.realm.RealmQuery import io.realm.RealmQuery
import io.realm.kotlin.where import io.realm.kotlin.where
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.internal.database.model.EventEntity 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.EventEntityFields
import org.matrix.android.sdk.internal.database.model.EventInsertEntity import org.matrix.android.sdk.internal.database.model.EventInsertEntity
@ -32,10 +31,9 @@ internal fun EventEntity.copyToRealmOrIgnore(realm: Realm, insertType: EventInse
.equalTo(EventEntityFields.ROOM_ID, roomId) .equalTo(EventEntityFields.ROOM_ID, roomId)
.findFirst() .findFirst()
return if (eventEntity == null) { return if (eventEntity == null) {
val canBeProcessed = type != EventType.ENCRYPTED || decryptionResultJson != null val insertEntity = EventInsertEntity(eventId = eventId, eventType = type, canBeProcessed = true)
val insertEntity = EventInsertEntity(eventId = eventId, eventType = type, canBeProcessed = canBeProcessed).apply { insertEntity.insertType = insertType
this.insertType = insertType
}
realm.insert(insertEntity) realm.insert(insertEntity)
// copy this event entity and return it // copy this event entity and return it
realm.copyToRealm(this) realm.copyToRealm(this)

View File

@ -61,6 +61,7 @@ import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor import org.matrix.android.sdk.internal.session.EventInsertLiveProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor
import org.matrix.android.sdk.internal.session.room.aggregation.utd.EncryptedReferenceAggregationProcessor
import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource import org.matrix.android.sdk.internal.session.room.state.StateEventDataSource
import org.matrix.android.sdk.internal.util.time.Clock import org.matrix.android.sdk.internal.util.time.Clock
import timber.log.Timber import timber.log.Timber
@ -73,6 +74,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor, private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor,
private val pollAggregationProcessor: PollAggregationProcessor, private val pollAggregationProcessor: PollAggregationProcessor,
private val encryptedReferenceAggregationProcessor: EncryptedReferenceAggregationProcessor,
private val editValidator: EventEditValidator, private val editValidator: EventEditValidator,
private val clock: Clock, private val clock: Clock,
) : EventInsertLiveProcessor { ) : EventInsertLiveProcessor {
@ -140,6 +142,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}") Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}")
handleReaction(realm, event, roomId, isLocalEcho) handleReaction(realm, event, roomId, isLocalEcho)
} }
EventType.ENCRYPTED -> {
val encryptedEventContent = event.content.toModel<EncryptedEventContent>()
processEncryptedContent(
encryptedEventContent = encryptedEventContent,
realm = realm,
event = event,
roomId = roomId,
isLocalEcho = isLocalEcho,
)
}
EventType.MESSAGE -> { EventType.MESSAGE -> {
if (event.unsignedData?.relations?.annotations != null) { if (event.unsignedData?.relations?.annotations != null) {
Timber.v("###REACTION Aggregation in room $roomId for event ${event.eventId}") Timber.v("###REACTION Aggregation in room $roomId for event ${event.eventId}")
@ -170,32 +182,6 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
} }
} }
} }
// As for now Live event processors are not receiving UTD events.
// They will get an update if the event is decrypted later
EventType.ENCRYPTED -> {
// Relation type is in clear, it might be possible to do some things?
// Notice that if the event is decrypted later, process be called again
val encryptedEventContent = event.content.toModel<EncryptedEventContent>()
when (encryptedEventContent?.relatesTo?.type) {
RelationType.REPLACE -> {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
// A replace!
handleReplace(realm, event, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
}
RelationType.RESPONSE -> {
// can we / should we do we something for UTD response??
Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
RelationType.REFERENCE -> {
// can we / should we do we something for UTD reference??
Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
RelationType.ANNOTATION -> {
// can we / should we do we something for UTD annotation??
Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
}
}
EventType.REDACTION -> { EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
?: return ?: return
@ -250,6 +236,36 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
} }
} }
private fun processEncryptedContent(
encryptedEventContent: EncryptedEventContent?,
realm: Realm,
event: Event,
roomId: String,
isLocalEcho: Boolean,
) {
when (encryptedEventContent?.relatesTo?.type) {
RelationType.REPLACE -> {
Timber.w("## UTD replace in room $roomId for event ${event.eventId}")
}
RelationType.RESPONSE -> {
Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
RelationType.REFERENCE -> {
Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
encryptedReferenceAggregationProcessor.handle(
realm = realm,
event = event,
isLocalEcho = isLocalEcho,
relatedEventId = encryptedEventContent.relatesTo.eventId,
)
}
RelationType.ANNOTATION -> {
Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
}
else -> Unit
}
}
// OPT OUT serer aggregation until API mature enough // OPT OUT serer aggregation until API mature enough
private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e

View File

@ -155,6 +155,8 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
) )
aggregatedPollSummaryEntity.aggregatedContent = ContentMapper.map(newSumModel.toContent()) aggregatedPollSummaryEntity.aggregatedContent = ContentMapper.map(newSumModel.toContent())
event.eventId?.let { removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity, it) }
return true return true
} }
@ -180,6 +182,8 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
aggregatedPollSummaryEntity.sourceEvents.add(event.eventId) aggregatedPollSummaryEntity.sourceEvents.add(event.eventId)
} }
event.eventId?.let { removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity, it) }
if (!isLocalEcho) { if (!isLocalEcho) {
ensurePollIsFullyAggregated(roomId, pollEventId) ensurePollIsFullyAggregated(roomId, pollEventId)
} }
@ -226,4 +230,10 @@ internal class DefaultPollAggregationProcessor @Inject constructor(
fetchPollResponseEventsTask.execute(params) fetchPollResponseEventsTask.execute(params)
} }
} }
private fun removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity: PollResponseAggregatedSummaryEntity, eventId: String) {
if (aggregatedPollSummaryEntity.encryptedRelatedEventIds.contains(eventId)) {
aggregatedPollSummaryEntity.encryptedRelatedEventIds.remove(eventId)
}
}
} }

View File

@ -21,7 +21,7 @@ import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
interface PollAggregationProcessor { internal interface PollAggregationProcessor {
/** /**
* Poll start events don't need to be processed by the aggregator. * Poll start events don't need to be processed by the aggregator.
* This function will only handle if the poll is edited and will update the poll summary entity. * This function will only handle if the poll is edited and will update the poll summary entity.

View File

@ -0,0 +1,59 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.aggregation.utd
import io.realm.Realm
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntityFields
import javax.inject.Inject
internal class EncryptedReferenceAggregationProcessor @Inject constructor() {
fun handle(
realm: Realm,
event: Event,
isLocalEcho: Boolean,
relatedEventId: String?
): Boolean {
return if (isLocalEcho || relatedEventId.isNullOrEmpty()) {
false
} else {
handlePollReference(realm = realm, event = event, relatedEventId = relatedEventId)
true
}
}
private fun handlePollReference(
realm: Realm,
event: Event,
relatedEventId: String
) {
event.eventId?.let { eventId ->
val existingRelatedPoll = getPollSummaryWithEventId(realm, relatedEventId)
if (eventId !in existingRelatedPoll?.encryptedRelatedEventIds.orEmpty()) {
existingRelatedPoll?.encryptedRelatedEventIds?.add(eventId)
}
}
}
private fun getPollSummaryWithEventId(realm: Realm, eventId: String): PollResponseAggregatedSummaryEntity? {
return realm.where(PollResponseAggregatedSummaryEntity::class.java)
.containsValue(PollResponseAggregatedSummaryEntityFields.SOURCE_EVENTS.`$`, eventId)
.findFirst()
}
}

View File

@ -0,0 +1,132 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room
import io.mockk.every
import io.mockk.mockk
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.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntityFields
import org.matrix.android.sdk.test.fakes.FakeClock
import org.matrix.android.sdk.test.fakes.FakeRealm
import org.matrix.android.sdk.test.fakes.FakeStateEventDataSource
import org.matrix.android.sdk.test.fakes.givenEqualTo
import org.matrix.android.sdk.test.fakes.givenFindFirst
import org.matrix.android.sdk.test.fakes.internal.FakeEventEditValidator
import org.matrix.android.sdk.test.fakes.internal.FakeLiveLocationAggregationProcessor
import org.matrix.android.sdk.test.fakes.internal.FakePollAggregationProcessor
import org.matrix.android.sdk.test.fakes.internal.FakeSessionManager
import org.matrix.android.sdk.test.fakes.internal.session.room.aggregation.utd.FakeEncryptedReferenceAggregationProcessor
private const val A_ROOM_ID = "room-id"
private const val AN_EVENT_ID = "event-id"
internal class EventRelationsAggregationProcessorTest {
private val fakeStateEventDataSource = FakeStateEventDataSource()
private val fakeSessionManager = FakeSessionManager()
private val fakeLiveLocationAggregationProcessor = FakeLiveLocationAggregationProcessor()
private val fakePollAggregationProcessor = FakePollAggregationProcessor()
private val fakeEncryptedReferenceAggregationProcessor = FakeEncryptedReferenceAggregationProcessor()
private val fakeEventEditValidator = FakeEventEditValidator()
private val fakeClock = FakeClock()
private val fakeRealm = FakeRealm()
private val encryptedEventRelationsAggregationProcessor = EventRelationsAggregationProcessor(
userId = "userId",
stateEventDataSource = fakeStateEventDataSource.instance,
sessionId = "sessionId",
sessionManager = fakeSessionManager.instance,
liveLocationAggregationProcessor = fakeLiveLocationAggregationProcessor.instance,
pollAggregationProcessor = fakePollAggregationProcessor.instance,
encryptedReferenceAggregationProcessor = fakeEncryptedReferenceAggregationProcessor.instance,
editValidator = fakeEventEditValidator.instance,
clock = fakeClock,
)
@Test
fun `given an encrypted reference event when process then reference is processed`() {
// Given
val anEvent = givenAnEvent(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
eventType = EventType.ENCRYPTED,
)
val relatedEventId = "related-event-id"
val encryptedEventContent = givenEncryptedEventContent(
relationType = RelationType.REFERENCE,
relatedEventId = relatedEventId,
)
every { anEvent.content } returns encryptedEventContent.toContent()
val resultOfReferenceProcess = false
fakeEncryptedReferenceAggregationProcessor.givenHandleReturns(resultOfReferenceProcess)
givenEventAnnotationsSummary(roomId = A_ROOM_ID, eventId = AN_EVENT_ID, annotationsSummary = null)
// When
encryptedEventRelationsAggregationProcessor.process(
realm = fakeRealm.instance,
event = anEvent,
)
// Then
fakeEncryptedReferenceAggregationProcessor.verifyHandle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = false,
relatedEventId = relatedEventId,
)
}
private fun givenAnEvent(
eventId: String,
roomId: String?,
eventType: String,
): Event {
return mockk<Event>().also {
every { it.eventId } returns eventId
every { it.roomId } returns roomId
every { it.getClearType() } returns eventType
}
}
private fun givenEncryptedEventContent(relationType: String, relatedEventId: String): EncryptedEventContent {
val relationContent = RelationDefaultContent(
eventId = relatedEventId,
type = relationType,
)
return EncryptedEventContent(
relatesTo = relationContent,
)
}
private fun givenEventAnnotationsSummary(
roomId: String,
eventId: String,
annotationsSummary: EventAnnotationsSummaryEntity?
) {
fakeRealm.givenWhere<EventAnnotationsSummaryEntity>()
.givenEqualTo(EventAnnotationsSummaryEntityFields.ROOM_ID, roomId)
.givenEqualTo(EventAnnotationsSummaryEntityFields.EVENT_ID, eventId)
.givenFindFirst(annotationsSummary)
}
}

View File

@ -25,6 +25,8 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue import org.amshove.kluent.shouldBeTrue
import org.amshove.kluent.shouldContain
import org.amshove.kluent.shouldNotContain
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
@ -105,6 +107,24 @@ class DefaultPollAggregationProcessorTest {
pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT).shouldBeTrue() pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT).shouldBeTrue()
} }
@Test
fun `given a poll response event with a reference, when processing, then event id is removed from encrypted events list`() {
// Given
val anotherEventId = "other-event-id"
val pollResponseAggregatedSummaryEntity = PollResponseAggregatedSummaryEntity(
encryptedRelatedEventIds = RealmList(AN_EVENT_ID, anotherEventId)
)
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns pollResponseAggregatedSummaryEntity
// When
val result = pollAggregationProcessor.handlePollResponseEvent(session, realm.instance, A_POLL_RESPONSE_EVENT)
// Then
result.shouldBeTrue()
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldNotContain(AN_EVENT_ID)
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldContain(anotherEventId)
}
@Test @Test
fun `given a poll response event after poll is closed, when processing, then is ignored and returns false`() { fun `given a poll response event after poll is closed, when processing, then is ignored and returns false`() {
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity().apply { every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity().apply {
@ -132,12 +152,33 @@ class DefaultPollAggregationProcessorTest {
// Given // Given
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity()
every { fakeTaskExecutor.instance.executorScope } returns this every { fakeTaskExecutor.instance.executorScope } returns this
// When
val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true) val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true)
// When
val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT)
// Then // Then
pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() result.shouldBeTrue()
}
@Test
fun `given a poll end event, when processing, then event id is removed from encrypted events list`() = runTest {
// Given
val anotherEventId = "other-event-id"
val pollResponseAggregatedSummaryEntity = PollResponseAggregatedSummaryEntity(
encryptedRelatedEventIds = RealmList(AN_EVENT_ID, anotherEventId)
)
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns pollResponseAggregatedSummaryEntity
every { fakeTaskExecutor.instance.executorScope } returns this
val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true)
// When
val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT)
// Then
result.shouldBeTrue()
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldNotContain(AN_EVENT_ID)
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldContain(anotherEventId)
} }
@Test @Test
@ -145,12 +186,13 @@ class DefaultPollAggregationProcessorTest {
// Given // Given
every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity()
every { fakeTaskExecutor.instance.executorScope } returns this every { fakeTaskExecutor.instance.executorScope } returns this
// When
val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, false) val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, false)
// When
val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT)
// Then // Then
pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() result.shouldBeTrue()
} }
@Test @Test

View File

@ -0,0 +1,138 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.internal.session.room.aggregation.utd
import io.mockk.every
import io.mockk.mockk
import io.realm.RealmList
import org.amshove.kluent.shouldBeFalse
import org.amshove.kluent.shouldBeTrue
import org.amshove.kluent.shouldContain
import org.junit.Test
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntity
import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSummaryEntityFields
import org.matrix.android.sdk.test.fakes.FakeRealm
import org.matrix.android.sdk.test.fakes.givenContainsValue
import org.matrix.android.sdk.test.fakes.givenFindFirst
internal class EncryptedReferenceAggregationProcessorTest {
private val fakeRealm = FakeRealm()
private val encryptedReferenceAggregationProcessor = EncryptedReferenceAggregationProcessor()
@Test
fun `given local echo when process then result is false`() {
// Given
val anEvent = mockk<Event>()
val isLocalEcho = true
val relatedEventId = "event-id"
// When
val result = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = relatedEventId,
)
// Then
result.shouldBeFalse()
}
@Test
fun `given invalid event id when process then result is false`() {
// Given
val anEvent = mockk<Event>()
val isLocalEcho = false
// When
val result1 = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = null,
)
val result2 = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = "",
)
// Then
result1.shouldBeFalse()
result2.shouldBeFalse()
}
@Test
fun `given related event id of an existing poll when process then result is true and event id is stored in poll summary`() {
// Given
val anEventId = "event-id"
val anEvent = givenAnEvent(anEventId)
val isLocalEcho = false
val relatedEventId = "related-event-id"
val pollResponseAggregatedSummaryEntity = PollResponseAggregatedSummaryEntity(
encryptedRelatedEventIds = RealmList(),
)
fakeRealm.givenWhere<PollResponseAggregatedSummaryEntity>()
.givenContainsValue(PollResponseAggregatedSummaryEntityFields.SOURCE_EVENTS.`$`, relatedEventId)
.givenFindFirst(pollResponseAggregatedSummaryEntity)
// When
val result = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = relatedEventId,
)
// Then
result.shouldBeTrue()
pollResponseAggregatedSummaryEntity.encryptedRelatedEventIds.shouldContain(anEventId)
}
@Test
fun `given related event id but no existing related poll when process then result is true and event id is not stored`() {
// Given
val anEventId = "event-id"
val anEvent = givenAnEvent(anEventId)
val isLocalEcho = false
val relatedEventId = "related-event-id"
fakeRealm.givenWhere<PollResponseAggregatedSummaryEntity>()
.givenContainsValue(PollResponseAggregatedSummaryEntityFields.SOURCE_EVENTS.`$`, relatedEventId)
.givenFindFirst(null)
// When
val result = encryptedReferenceAggregationProcessor.handle(
realm = fakeRealm.instance,
event = anEvent,
isLocalEcho = isLocalEcho,
relatedEventId = relatedEventId,
)
// Then
result.shouldBeTrue()
}
private fun givenAnEvent(eventId: String): Event {
return mockk<Event>().also {
every { it.eventId } returns eventId
}
}
}

View File

@ -117,6 +117,14 @@ inline fun <reified T : RealmModel> RealmQuery<T>.givenIn(
return this return this
} }
inline fun <reified T : RealmModel> RealmQuery<T>.givenContainsValue(
fieldName: String,
value: String,
): RealmQuery<T> {
every { containsValue(fieldName, value) } returns this
return this
}
/** /**
* Should be called on a mocked RealmObject and not on a real RealmObject so that the underlying final method is mocked. * Should be called on a mocked RealmObject and not on a real RealmObject so that the underlying final method is mocked.
*/ */

View File

@ -0,0 +1,25 @@
/*
* 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.internal
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.room.EventEditValidator
internal class FakeEventEditValidator {
val instance: EventEditValidator = mockk()
}

View File

@ -0,0 +1,25 @@
/*
* 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.internal
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.room.aggregation.livelocation.LiveLocationAggregationProcessor
internal class FakeLiveLocationAggregationProcessor {
val instance: LiveLocationAggregationProcessor = mockk()
}

View File

@ -0,0 +1,25 @@
/*
* 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.internal
import io.mockk.mockk
import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollAggregationProcessor
internal class FakePollAggregationProcessor {
val instance: PollAggregationProcessor = mockk()
}

View File

@ -0,0 +1,42 @@
/*
* Copyright (c) 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.test.fakes.internal.session.room.aggregation.utd
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.realm.Realm
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.internal.session.room.aggregation.utd.EncryptedReferenceAggregationProcessor
internal class FakeEncryptedReferenceAggregationProcessor {
val instance: EncryptedReferenceAggregationProcessor = mockk()
fun givenHandleReturns(result: Boolean) {
every { instance.handle(any(), any(), any(), any()) } returns result
}
fun verifyHandle(
realm: Realm,
event: Event,
isLocalEcho: Boolean,
relatedEventId: String?,
) {
verify { instance.handle(realm, event, isLocalEcho, relatedEventId) }
}
}

View File

@ -20,6 +20,7 @@ import android.content.ActivityNotFoundException
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.call.dialpad.DialPadLookup
import im.vector.app.features.roomprofile.polls.RoomPollsLoadingError
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voice.VoiceFailure
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError
@ -138,6 +139,7 @@ class DefaultErrorFormatter @Inject constructor(
stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id) stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id)
is VoiceFailure -> voiceMessageError(throwable) is VoiceFailure -> voiceMessageError(throwable)
is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable) is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable)
is RoomPollsLoadingError -> stringProvider.getString(R.string.room_polls_loading_error)
is ActivityNotFoundException -> is ActivityNotFoundException ->
stringProvider.getString(R.string.error_no_external_application_found) stringProvider.getString(R.string.error_no_external_application_found)
else -> throwable.localizedMessage else -> throwable.localizedMessage

View File

@ -138,7 +138,7 @@ class MessageComposerViewModel @AssistedInject constructor(
} }
private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) { private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) {
val needsSendButtonVisibilityUpdate = currentComposerText.isEmpty() != action.text.isEmpty() val needsSendButtonVisibilityUpdate = currentComposerText.isBlank() != action.text.isBlank()
currentComposerText = SpannableString(action.text) currentComposerText = SpannableString(action.text)
if (needsSendButtonVisibilityUpdate) { if (needsSendButtonVisibilityUpdate) {
updateIsSendButtonVisibility(true) updateIsSendButtonVisibility(true)

View File

@ -83,9 +83,14 @@ class PollItemViewStateFactory @Inject constructor(
totalVotes: Int, totalVotes: Int,
winnerVoteCount: Int?, winnerVoteCount: Int?,
): PollViewState { ): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes)
}
return PollViewState( return PollViewState(
question = question, question = question,
votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes), votesStatus = totalVotesText,
canVote = false, canVote = false,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollCreationInfo?.answers?.map { answer ->
val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "") val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "")
@ -126,9 +131,14 @@ class PollItemViewStateFactory @Inject constructor(
pollResponseSummary: PollResponseData?, pollResponseSummary: PollResponseData?,
totalVotes: Int totalVotes: Int
): PollViewState { ): PollViewState {
val totalVotesText = if (pollResponseSummary?.hasEncryptedRelatedEvents.orFalse()) {
stringProvider.getString(R.string.unable_to_decrypt_some_events_in_poll)
} else {
stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes)
}
return PollViewState( return PollViewState(
question = question, question = question,
votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes), votesStatus = totalVotesText,
canVote = true, canVote = true,
optionViewStates = pollCreationInfo?.answers?.map { answer -> optionViewStates = pollCreationInfo?.answers?.map { answer ->
val isMyVote = pollResponseSummary?.myVote == answer.id val isMyVote = pollResponseSummary?.myVote == answer.id
@ -144,7 +154,11 @@ class PollItemViewStateFactory @Inject constructor(
) )
} }
private fun createReadyPollViewState(question: String, pollCreationInfo: PollCreationInfo?, totalVotes: Int): PollViewState { private fun createReadyPollViewState(
question: String,
pollCreationInfo: PollCreationInfo?,
totalVotes: Int
): PollViewState {
val totalVotesText = if (totalVotes == 0) { val totalVotesText = if (totalVotes == 0) {
stringProvider.getString(R.string.poll_no_votes_cast) stringProvider.getString(R.string.poll_no_votes_cast)
} else { } else {

View File

@ -44,7 +44,8 @@ class PollResponseDataFactory @Inject constructor(
) )
}, },
winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0, winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0,
totalVotes = it.aggregatedContent?.totalVotes ?: 0 totalVotes = it.aggregatedContent?.totalVotes ?: 0,
hasEncryptedRelatedEvents = it.encryptedRelatedEventIds.isNotEmpty(),
) )
} }
} }

View File

@ -90,7 +90,8 @@ data class PollResponseData(
val votes: Map<String, PollVoteSummaryData>?, val votes: Map<String, PollVoteSummaryData>?,
val totalVotes: Int = 0, val totalVotes: Int = 0,
val winnerVoteCount: Int = 0, val winnerVoteCount: Int = 0,
val isClosed: Boolean = false val isClosed: Boolean = false,
val hasEncryptedRelatedEvents: Boolean = false,
) : Parcelable { ) : Parcelable {
fun getVoteSummaryOfAnOption(optionId: String) = votes?.get(optionId) fun getVoteSummaryOfAnOption(optionId: String) = votes?.get(optionId)

View File

@ -76,6 +76,8 @@ class RoomProfileActivity :
return ActivitySimpleBinding.inflate(layoutInflater) return ActivitySimpleBinding.inflate(layoutInflater)
} }
override fun getCoordinatorLayout() = views.coordinatorLayout
override fun initUiAndData() { override fun initUiAndData() {
sharedActionViewModel = viewModelProvider.get(RoomProfileSharedActionViewModel::class.java) sharedActionViewModel = viewModelProvider.get(RoomProfileSharedActionViewModel::class.java)
roomProfileArgs = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) ?: return roomProfileArgs = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) ?: return

View File

@ -18,4 +18,6 @@ package im.vector.app.features.roomprofile.polls
import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.core.platform.VectorViewModelAction
sealed interface RoomPollsAction : VectorViewModelAction sealed interface RoomPollsAction : VectorViewModelAction {
object LoadMorePolls : RoomPollsAction
}

View File

@ -0,0 +1,19 @@
/*
* 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
class RoomPollsLoadingError : Throwable()

View File

@ -18,4 +18,6 @@ package im.vector.app.features.roomprofile.polls
import im.vector.app.core.platform.VectorViewEvents import im.vector.app.core.platform.VectorViewEvents
sealed class RoomPollsViewEvent : VectorViewEvents sealed class RoomPollsViewEvent : VectorViewEvents {
object LoadingError : RoomPollsViewEvent()
}

View File

@ -23,12 +23,20 @@ import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase
import im.vector.app.features.roomprofile.polls.list.domain.GetPollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.LoadMorePollsUseCase
import im.vector.app.features.roomprofile.polls.list.domain.SyncPollsUseCase
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class RoomPollsViewModel @AssistedInject constructor( class RoomPollsViewModel @AssistedInject constructor(
@Assisted initialState: RoomPollsViewState, @Assisted initialState: RoomPollsViewState,
private val getPollsUseCase: GetPollsUseCase, private val getPollsUseCase: GetPollsUseCase,
private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase,
private val loadMorePollsUseCase: LoadMorePollsUseCase,
private val syncPollsUseCase: SyncPollsUseCase,
) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) { ) : VectorViewModel<RoomPollsViewState, RoomPollsAction, RoomPollsViewEvent>(initialState) {
@AssistedFactory @AssistedFactory
@ -39,16 +47,63 @@ class RoomPollsViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<RoomPollsViewModel, RoomPollsViewState> by hiltMavericksViewModelFactory() companion object : MavericksViewModelFactory<RoomPollsViewModel, RoomPollsViewState> by hiltMavericksViewModelFactory()
init { init {
observePolls() val roomId = initialState.roomId
updateLoadedPollStatus(roomId)
syncPolls(roomId)
observePolls(roomId)
} }
private fun observePolls() { private fun updateLoadedPollStatus(roomId: String) {
getPollsUseCase.execute() val loadedPollsStatus = getLoadedPollsStatusUseCase.execute(roomId)
setState {
copy(
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays
)
}
}
private fun syncPolls(roomId: String) {
viewModelScope.launch {
setState { copy(isSyncing = true) }
val result = runCatching {
syncPollsUseCase.execute(roomId)
}
if (result.isFailure) {
_viewEvents.post(RoomPollsViewEvent.LoadingError)
}
setState { copy(isSyncing = false) }
}
}
private fun observePolls(roomId: String) {
getPollsUseCase.execute(roomId)
.onEach { setState { copy(polls = it) } } .onEach { setState { copy(polls = it) } }
.launchIn(viewModelScope) .launchIn(viewModelScope)
} }
override fun handle(action: RoomPollsAction) { override fun handle(action: RoomPollsAction) {
// do nothing for now when (action) {
RoomPollsAction.LoadMorePolls -> handleLoadMore()
}
}
private fun handleLoadMore() = withState { viewState ->
viewModelScope.launch {
setState { copy(isLoadingMore = true) }
val result = runCatching {
val status = loadMorePollsUseCase.execute(viewState.roomId)
setState {
copy(
canLoadMore = status.canLoadMore,
nbLoadedDays = status.nbLoadedDays,
)
}
}
if (result.isFailure) {
_viewEvents.post(RoomPollsViewEvent.LoadingError)
}
setState { copy(isLoadingMore = false) }
}
} }
} }

View File

@ -18,11 +18,19 @@ package im.vector.app.features.roomprofile.polls
import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksState
import im.vector.app.features.roomprofile.RoomProfileArgs import im.vector.app.features.roomprofile.RoomProfileArgs
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
data class RoomPollsViewState( data class RoomPollsViewState(
val roomId: String, val roomId: String,
val polls: List<PollSummary> = emptyList(), val polls: List<PollSummary> = emptyList(),
val isLoadingMore: Boolean = false,
val canLoadMore: Boolean = true,
val nbLoadedDays: Int = 0,
val isSyncing: Boolean = false,
) : MavericksState { ) : MavericksState {
constructor(roomProfileArgs: RoomProfileArgs) : this(roomId = roomProfileArgs.roomId) constructor(roomProfileArgs: RoomProfileArgs) : this(roomId = roomProfileArgs.roomId)
fun hasNoPolls() = polls.isEmpty()
fun hasNoPollsAndCanLoadMore() = !isSyncing && hasNoPolls() && canLoadMore
} }

View File

@ -19,13 +19,17 @@ package im.vector.app.features.roomprofile.polls.active
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.roomprofile.polls.RoomPollsType import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.list.RoomPollsListFragment import im.vector.app.features.roomprofile.polls.list.ui.RoomPollsListFragment
@AndroidEntryPoint @AndroidEntryPoint
class RoomActivePollsFragment : RoomPollsListFragment() { class RoomActivePollsFragment : RoomPollsListFragment() {
override fun getEmptyListTitle(): String { override fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String {
return getString(R.string.room_polls_active_no_item) return if (canLoadMore) {
stringProvider.getQuantityString(R.plurals.room_polls_active_no_item_for_loaded_period, nbLoadedDays, nbLoadedDays)
} else {
getString(R.string.room_polls_active_no_item)
}
} }
override fun getRoomPollsType(): RoomPollsType { override fun getRoomPollsType(): RoomPollsType {

View File

@ -19,13 +19,17 @@ package im.vector.app.features.roomprofile.polls.ended
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.roomprofile.polls.RoomPollsType import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.list.RoomPollsListFragment import im.vector.app.features.roomprofile.polls.list.ui.RoomPollsListFragment
@AndroidEntryPoint @AndroidEntryPoint
class RoomEndedPollsFragment : RoomPollsListFragment() { class RoomEndedPollsFragment : RoomPollsListFragment() {
override fun getEmptyListTitle(): String { override fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String {
return getString(R.string.room_polls_ended_no_item) return if (canLoadMore) {
stringProvider.getQuantityString(R.plurals.room_polls_ended_no_item_for_loaded_period, nbLoadedDays, nbLoadedDays)
} else {
getString(R.string.room_polls_ended_no_item)
}
} }
override fun getRoomPollsType(): RoomPollsType { override fun getRoomPollsType(): RoomPollsType {

View File

@ -1,90 +0,0 @@
/*
* Copyright (c) 2022 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
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentRoomPollsListBinding
import im.vector.app.features.roomprofile.polls.PollSummary
import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.RoomPollsViewModel
import timber.log.Timber
import javax.inject.Inject
abstract class RoomPollsListFragment :
VectorBaseFragment<FragmentRoomPollsListBinding>(),
RoomPollsController.Listener {
@Inject
lateinit var roomPollsController: RoomPollsController
private val viewModel: RoomPollsViewModel by parentFragmentViewModel(RoomPollsViewModel::class)
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomPollsListBinding {
return FragmentRoomPollsListBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupList()
}
abstract fun getEmptyListTitle(): String
abstract fun getRoomPollsType(): RoomPollsType
private fun setupList() {
roomPollsController.listener = this
views.roomPollsList.configureWith(roomPollsController)
views.roomPollsEmptyTitle.text = getEmptyListTitle()
}
override fun onDestroyView() {
cleanUpList()
super.onDestroyView()
}
private fun cleanUpList() {
views.roomPollsList.cleanup()
roomPollsController.listener = null
}
override fun invalidate() = withState(viewModel) { viewState ->
when (getRoomPollsType()) {
RoomPollsType.ACTIVE -> renderList(viewState.polls.filterIsInstance(PollSummary.ActivePoll::class.java))
RoomPollsType.ENDED -> renderList(viewState.polls.filterIsInstance(PollSummary.EndedPoll::class.java))
}
}
private fun renderList(polls: List<PollSummary>) {
roomPollsController.setData(polls)
views.roomPollsEmptyTitle.isVisible = polls.isEmpty()
}
override fun onPollClicked(pollId: String) {
// TODO navigate to details
Timber.d("poll with id $pollId clicked")
}
}

View File

@ -0,0 +1,22 @@
/*
* 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
data class LoadedPollsStatus(
val canLoadMore: Boolean,
val nbLoadedDays: Int,
)

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,23 +14,60 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls 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.home.room.detail.timeline.item.PollOptionViewState
import im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.asSharedFlow
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton
class GetPollsUseCase @Inject constructor() { @Singleton
class RoomPollDataSource @Inject constructor() {
fun execute(): Flow<List<PollSummary>> { private val pollsFlow = MutableSharedFlow<List<PollSummary>>(replay = 1)
// TODO unmock and add unit tests private val polls = mutableListOf<PollSummary>()
return flowOf(getActivePolls() + getEndedPolls()) private var fakeLoadCounter = 0
.map { it.sortedByDescending { poll -> poll.creationTimestamp } }
// 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 getActivePolls(): List<PollSummary.ActivePoll> { fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
Timber.d("roomId=$roomId")
return LoadedPollsStatus(
canLoadMore = canLoadMore(),
nbLoadedDays = fakeLoadCounter * 30,
)
}
private fun canLoadMore(): Boolean {
return fakeLoadCounter < 2
}
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( return listOf(
PollSummary.ActivePoll( PollSummary.ActivePoll(
id = "id1", id = "id1",
@ -44,6 +81,11 @@ class GetPollsUseCase @Inject constructor() {
creationTimestamp = 1656194400000, creationTimestamp = 1656194400000,
title = "Which sport should the pupils do this year?" title = "Which sport should the pupils do this year?"
), ),
)
}
private fun getActivePollsPart2(): List<PollSummary.ActivePoll> {
return listOf(
PollSummary.ActivePoll( PollSummary.ActivePoll(
id = "id3", id = "id3",
// 2022/06/24 UTC+1 // 2022/06/24 UTC+1
@ -59,7 +101,7 @@ class GetPollsUseCase @Inject constructor() {
) )
} }
private fun getEndedPolls(): List<PollSummary.EndedPoll> { private fun getEndedPollsPart1(): List<PollSummary.EndedPoll> {
return listOf( return listOf(
PollSummary.EndedPoll( PollSummary.EndedPoll(
id = "id1-ended", id = "id1-ended",
@ -77,6 +119,11 @@ class GetPollsUseCase @Inject constructor() {
) )
), ),
), ),
)
}
private fun getEndedPollsPart2(): List<PollSummary.EndedPoll> {
return listOf(
PollSummary.EndedPoll( PollSummary.EndedPoll(
id = "id2-ended", id = "id2-ended",
// 2022/06/26 UTC+1 // 2022/06/26 UTC+1
@ -111,4 +158,17 @@ class GetPollsUseCase @Inject constructor() {
), ),
) )
} }
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)
}
}
} }

View File

@ -0,0 +1,43 @@
/*
* 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.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.flow.Flow
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>> {
return roomPollDataSource.getPolls(roomId)
}
fun getLoadedPollsStatus(roomId: String): LoadedPollsStatus {
return roomPollDataSource.getLoadedPollsStatus(roomId)
}
suspend fun loadMorePolls(roomId: String): LoadedPollsStatus {
return roomPollDataSource.loadMorePolls(roomId)
}
suspend fun syncPolls(roomId: String) {
return roomPollDataSource.syncPolls(roomId)
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import javax.inject.Inject
class GetLoadedPollsStatusUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
fun execute(roomId: String): LoadedPollsStatus {
return roomPollRepository.getLoadedPollsStatus(roomId)
}
}

View File

@ -0,0 +1,33 @@
/*
* 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 im.vector.app.features.roomprofile.polls.list.ui.PollSummary
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
class GetPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
fun execute(roomId: String): Flow<List<PollSummary>> {
return roomPollRepository.getPolls(roomId)
.map { it.sortedByDescending { poll -> poll.creationTimestamp } }
}
}

View File

@ -0,0 +1,30 @@
/*
* 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.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import javax.inject.Inject
class LoadMorePollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
suspend fun execute(roomId: String): LoadedPollsStatus {
return roomPollRepository.loadMorePolls(roomId)
}
}

View File

@ -0,0 +1,32 @@
/*
* 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
/**
* Sync the polls of a given room from last manual loading (see LoadMorePollsUseCase) until now.
*/
class SyncPollsUseCase @Inject constructor(
private val roomPollRepository: RoomPollRepository,
) {
suspend fun execute(roomId: String) {
roomPollRepository.syncPolls(roomId)
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls package im.vector.app.features.roomprofile.polls.list.ui
import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState import im.vector.app.features.home.room.detail.timeline.item.PollOptionViewState

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls.list package im.vector.app.features.roomprofile.polls.list.ui
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView

View File

@ -0,0 +1,50 @@
/*
* 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 android.widget.Button
import android.widget.ProgressBar
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.onClick
@EpoxyModelClass
abstract class RoomPollLoadMoreItem : VectorEpoxyModel<RoomPollLoadMoreItem.Holder>(R.layout.item_poll_load_more) {
@EpoxyAttribute
var loadingMore: Boolean = false
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var clickListener: ClickListener? = null
override fun bind(holder: Holder) {
super.bind(holder)
holder.loadMoreButton.isEnabled = loadingMore.not()
holder.loadMoreButton.onClick(clickListener)
holder.loadMoreProgressBar.isVisible = loadingMore
}
class Holder : VectorEpoxyHolder() {
val loadMoreButton by bind<Button>(R.id.roomPollsLoadMore)
val loadMoreProgressBar by bind<ProgressBar>(R.id.roomPollsLoadMoreProgress)
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright (c) 2022 New Vector Ltd * Copyright (c) 2023 New Vector Ltd
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,38 +14,45 @@
* limitations under the License. * limitations under the License.
*/ */
package im.vector.app.features.roomprofile.polls.list package im.vector.app.features.roomprofile.polls.list.ui
import com.airbnb.epoxy.TypedEpoxyController import com.airbnb.epoxy.TypedEpoxyController
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.DateFormatKind
import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.date.VectorDateFormatter
import im.vector.app.core.resources.StringProvider import im.vector.app.core.resources.StringProvider
import im.vector.app.features.roomprofile.polls.PollSummary import im.vector.app.features.roomprofile.polls.RoomPollsViewState
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
class RoomPollsController @Inject constructor( class RoomPollsController @Inject constructor(
val dateFormatter: VectorDateFormatter, val dateFormatter: VectorDateFormatter,
val stringProvider: StringProvider, val stringProvider: StringProvider,
) : TypedEpoxyController<List<PollSummary>>() { ) : TypedEpoxyController<RoomPollsViewState>() {
interface Listener { interface Listener {
fun onPollClicked(pollId: String) fun onPollClicked(pollId: String)
fun onLoadMoreClicked()
} }
var listener: Listener? = null var listener: Listener? = null
override fun buildModels(data: List<PollSummary>?) { override fun buildModels(viewState: RoomPollsViewState?) {
if (data.isNullOrEmpty()) { val polls = viewState?.polls
if (polls.isNullOrEmpty() || viewState.isSyncing) {
return return
} }
for (poll in data) { for (poll in polls) {
when (poll) { when (poll) {
is PollSummary.ActivePoll -> buildActivePollItem(poll) is PollSummary.ActivePoll -> buildActivePollItem(poll)
is PollSummary.EndedPoll -> buildEndedPollItem(poll) is PollSummary.EndedPoll -> buildEndedPollItem(poll)
} }
} }
if (viewState.canLoadMore) {
buildLoadMoreItem(viewState.isLoadingMore)
}
} }
private fun buildActivePollItem(poll: PollSummary.ActivePoll) { private fun buildActivePollItem(poll: PollSummary.ActivePoll) {
@ -73,4 +80,15 @@ class RoomPollsController @Inject constructor(
} }
} }
} }
private fun buildLoadMoreItem(isLoadingMore: Boolean) {
val host = this
roomPollLoadMoreItem {
id(UUID.randomUUID().toString())
loadingMore(isLoadingMore)
clickListener {
host.listener?.onLoadMoreClicked()
}
}
}
} }

View File

@ -0,0 +1,136 @@
/*
* 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 android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.parentFragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.cleanup
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.resources.StringProvider
import im.vector.app.databinding.FragmentRoomPollsListBinding
import im.vector.app.features.roomprofile.polls.RoomPollsAction
import im.vector.app.features.roomprofile.polls.RoomPollsLoadingError
import im.vector.app.features.roomprofile.polls.RoomPollsType
import im.vector.app.features.roomprofile.polls.RoomPollsViewEvent
import im.vector.app.features.roomprofile.polls.RoomPollsViewModel
import im.vector.app.features.roomprofile.polls.RoomPollsViewState
import timber.log.Timber
import javax.inject.Inject
abstract class RoomPollsListFragment :
VectorBaseFragment<FragmentRoomPollsListBinding>(),
RoomPollsController.Listener {
@Inject
lateinit var roomPollsController: RoomPollsController
@Inject
lateinit var stringProvider: StringProvider
private val viewModel: RoomPollsViewModel by parentFragmentViewModel(RoomPollsViewModel::class)
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRoomPollsListBinding {
return FragmentRoomPollsListBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
observeViewEvents()
setupList()
setupLoadMoreButton()
}
private fun observeViewEvents() {
viewModel.observeViewEvents { viewEvent ->
when (viewEvent) {
RoomPollsViewEvent.LoadingError -> showErrorInSnackbar(RoomPollsLoadingError())
}
}
}
abstract fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String
abstract fun getRoomPollsType(): RoomPollsType
private fun setupList() = withState(viewModel) { viewState ->
roomPollsController.listener = this
views.roomPollsList.configureWith(roomPollsController)
views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays,
)
}
private fun setupLoadMoreButton() {
views.roomPollsLoadMoreWhenEmpty.onClick {
onLoadMoreClicked()
}
}
override fun onDestroyView() {
cleanUpList()
super.onDestroyView()
}
private fun cleanUpList() {
views.roomPollsList.cleanup()
roomPollsController.listener = null
}
override fun invalidate() = withState(viewModel) { viewState ->
val filteredPolls = when (getRoomPollsType()) {
RoomPollsType.ACTIVE -> viewState.polls.filterIsInstance(PollSummary.ActivePoll::class.java)
RoomPollsType.ENDED -> viewState.polls.filterIsInstance(PollSummary.EndedPoll::class.java)
}
val updatedViewState = viewState.copy(polls = filteredPolls)
renderList(updatedViewState)
renderSyncingView(updatedViewState)
}
private fun renderSyncingView(viewState: RoomPollsViewState) {
views.roomPollsSyncingTitle.isVisible = viewState.isSyncing
views.roomPollsSyncingProgress.isVisible = viewState.isSyncing
}
private fun renderList(viewState: RoomPollsViewState) {
roomPollsController.setData(viewState)
views.roomPollsEmptyTitle.text = getEmptyListTitle(
canLoadMore = viewState.canLoadMore,
nbLoadedDays = viewState.nbLoadedDays,
)
views.roomPollsEmptyTitle.isVisible = !viewState.isSyncing && viewState.hasNoPolls()
views.roomPollsLoadMoreWhenEmpty.isVisible = viewState.hasNoPollsAndCanLoadMore()
views.roomPollsLoadMoreWhenEmpty.isEnabled = !viewState.isLoadingMore
views.roomPollsLoadMoreWhenEmptyProgress.isVisible = viewState.hasNoPollsAndCanLoadMore() && viewState.isLoadingMore
}
override fun onPollClicked(pollId: String) {
// TODO navigate to details
Timber.d("poll with id $pollId clicked")
}
override fun onLoadMoreClicked() {
viewModel.handle(RoomPollsAction.LoadMorePolls)
}
}

View File

@ -17,6 +17,34 @@
tools:itemCount="5" tools:itemCount="5"
tools:listitem="@layout/item_poll" /> tools:listitem="@layout/item_poll" />
<ProgressBar
android:id="@+id/roomPollsSyncingProgress"
style="?android:attr/progressBarStyle"
android:layout_width="16dp"
android:layout_height="16dp"
android:indeterminateTint="?vctr_content_secondary"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/roomPollsSyncingTitle"
app:layout_constraintEnd_toStartOf="@id/roomPollsSyncingTitle"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/roomPollsSyncingTitle" />
<TextView
android:id="@+id/roomPollsSyncingTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="9dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:gravity="center"
android:text="@string/room_polls_wait_for_display"
android:textAppearance="@style/TextAppearance.Vector.Body"
android:textColor="?vctr_content_secondary"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/roomPollsSyncingProgress"
app:layout_constraintTop_toTopOf="@id/roomPollsTitleGuideline" />
<TextView <TextView
android:id="@+id/roomPollsEmptyTitle" android:id="@+id/roomPollsEmptyTitle"
android:layout_width="0dp" android:layout_width="0dp"
@ -26,14 +54,39 @@
android:gravity="center" android:gravity="center"
android:textAppearance="@style/TextAppearance.Vector.Body" android:textAppearance="@style/TextAppearance.Vector.Body"
android:textColor="?vctr_content_secondary" android:textColor="?vctr_content_secondary"
android:textSize="17sp"
android:visibility="gone" android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/roomPollsEmptyGuideline" app:layout_constraintTop_toTopOf="@id/roomPollsTitleGuideline"
tools:text="@string/room_polls_active_no_item" /> tools:text="@string/room_polls_active_no_item" />
<Button
android:id="@+id/roomPollsLoadMoreWhenEmpty"
style="@style/Widget.Vector.Button.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="@dimen/layout_horizontal_margin"
android:layout_marginTop="8dp"
android:text="@string/room_polls_load_more"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomPollsEmptyTitle" />
<ProgressBar
android:id="@+id/roomPollsLoadMoreWhenEmptyProgress"
style="?android:attr/progressBarStyle"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="9dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/roomPollsLoadMoreWhenEmpty"
app:layout_constraintStart_toEndOf="@id/roomPollsLoadMoreWhenEmpty"
app:layout_constraintTop_toTopOf="@id/roomPollsLoadMoreWhenEmpty" />
<androidx.constraintlayout.widget.Guideline <androidx.constraintlayout.widget.Guideline
android:id="@+id/roomPollsEmptyGuideline" android:id="@+id/roomPollsTitleGuideline"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"

View File

@ -85,6 +85,7 @@
app:layout_constraintCircle="@id/breadcrumbsImageView" app:layout_constraintCircle="@id/breadcrumbsImageView"
app:layout_constraintCircleAngle="225" app:layout_constraintCircleAngle="225"
app:layout_constraintCircleRadius="28dp" app:layout_constraintCircleRadius="28dp"
app:tint="?vctr_content_primary"
tools:ignore="MissingConstraints" tools:ignore="MissingConstraints"
tools:visibility="visible" /> tools:visibility="visible" />

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<Button
android:id="@+id/roomPollsLoadMore"
style="@style/Widget.Vector.Button.Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="38dp"
android:layout_marginBottom="46dp"
android:padding="0dp"
android:text="@string/room_polls_load_more"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/roomPollsLoadMoreProgress"
style="?android:attr/progressBarStyle"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="9dp"
app:layout_constraintBottom_toBottomOf="@id/roomPollsLoadMore"
app:layout_constraintStart_toEndOf="@id/roomPollsLoadMore"
app:layout_constraintTop_toTopOf="@id/roomPollsLoadMore" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -131,6 +131,24 @@ class PollItemViewStateFactoryTest {
) )
} }
@Test
fun `given a sent poll state with some decryption error when poll is closed then warning message is displayed`() {
// Given
val stringProvider = FakeStringProvider()
val pollItemViewStateFactory = PollItemViewStateFactory(stringProvider.instance)
val closedPollSummary = A_POLL_RESPONSE_DATA.copy(isClosed = true, hasEncryptedRelatedEvents = true)
val closedPollInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = closedPollSummary)
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = A_POLL_CONTENT,
informationData = closedPollInformationData,
)
// Then
pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll)
}
@Test @Test
fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() { fun `given a sent poll when undisclosed poll type is selected then poll is votable and option states are PollUndisclosed`() {
val stringProvider = FakeStringProvider() val stringProvider = FakeStringProvider()
@ -193,6 +211,34 @@ class PollItemViewStateFactoryTest {
) )
} }
@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],
votes = mapOf(A_POLL_OPTION_IDS[0] to PollVoteSummaryData(total = 1, percentage = 1.0)),
hasEncryptedRelatedEvents = true,
)
val disclosedPollContent = A_POLL_CONTENT.copy(
unstablePollCreationInfo = A_POLL_CONTENT.getBestPollCreationInfo()?.copy(
kind = PollType.DISCLOSED_UNSTABLE
),
)
val votedInformationData = A_MESSAGE_INFORMATION_DATA.copy(pollResponseAggregatedSummary = votedPollData)
// When
val pollViewState = pollItemViewStateFactory.create(
pollContent = disclosedPollContent,
informationData = votedInformationData,
)
// Then
pollViewState.votesStatus shouldBeEqualTo stringProvider.instance.getString(R.string.unable_to_decrypt_some_events_in_poll)
}
@Test @Test
fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() { fun `given a sent poll when poll type is disclosed then poll is votable and option view states are PollReady`() {
val stringProvider = FakeStringProvider() val stringProvider = FakeStringProvider()

View File

@ -17,8 +17,17 @@
package im.vector.app.features.roomprofile.polls package im.vector.app.features.roomprofile.polls
import com.airbnb.mvrx.test.MavericksTestRule import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.features.roomprofile.polls.list.data.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase
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.test.test import im.vector.app.test.test
import im.vector.app.test.testDispatcher import im.vector.app.test.testDispatcher
import io.mockk.coEvery
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.verify import io.mockk.verify
@ -26,7 +35,7 @@ import kotlinx.coroutines.flow.flowOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
private const val ROOM_ID = "room-id" private const val A_ROOM_ID = "room-id"
class RoomPollsViewModelTest { class RoomPollsViewModelTest {
@ -34,21 +43,33 @@ class RoomPollsViewModelTest {
val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher) val mavericksTestRule = MavericksTestRule(testDispatcher = testDispatcher)
private val fakeGetPollsUseCase = mockk<GetPollsUseCase>() private val fakeGetPollsUseCase = mockk<GetPollsUseCase>()
private val initialState = RoomPollsViewState(ROOM_ID) private val fakeGetLoadedPollsStatusUseCase = mockk<GetLoadedPollsStatusUseCase>()
private val fakeLoadMorePollsUseCase = mockk<LoadMorePollsUseCase>()
private val fakeSyncPollsUseCase = mockk<SyncPollsUseCase>()
private val initialState = RoomPollsViewState(A_ROOM_ID)
private fun createViewModel(): RoomPollsViewModel { private fun createViewModel(): RoomPollsViewModel {
return RoomPollsViewModel( return RoomPollsViewModel(
initialState = initialState, initialState = initialState,
getPollsUseCase = fakeGetPollsUseCase, getPollsUseCase = fakeGetPollsUseCase,
getLoadedPollsStatusUseCase = fakeGetLoadedPollsStatusUseCase,
loadMorePollsUseCase = fakeLoadMorePollsUseCase,
syncPollsUseCase = fakeSyncPollsUseCase,
) )
} }
@Test @Test
fun `given viewModel when created then polls list is observed and viewState is updated`() { fun `given viewModel when created then polls list is observed, sync is launched and viewState is updated`() {
// Given // Given
val loadedPollsStatus = givenGetLoadedPollsStatusSuccess()
givenSyncPollsWithSuccess()
val polls = listOf(givenAPollSummary()) val polls = listOf(givenAPollSummary())
every { fakeGetPollsUseCase.execute() } returns flowOf(polls) every { fakeGetPollsUseCase.execute(A_ROOM_ID) } returns flowOf(polls)
val expectedViewState = initialState.copy(polls = polls) val expectedViewState = initialState.copy(
polls = polls,
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays,
)
// When // When
val viewModel = createViewModel() val viewModel = createViewModel()
@ -59,11 +80,88 @@ class RoomPollsViewModelTest {
.assertLatestState(expectedViewState) .assertLatestState(expectedViewState)
.finish() .finish()
verify { verify {
fakeGetPollsUseCase.execute() fakeGetPollsUseCase.execute(A_ROOM_ID)
} }
coVerify { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
}
@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)
// When
val viewModel = createViewModel()
val viewModelTest = viewModel.test()
// Then
viewModelTest
.assertEvents(RoomPollsViewEvent.LoadingError)
.finish()
coVerify { fakeSyncPollsUseCase.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 newLoadedPollsStatus = givenLoadMoreWithSuccess()
val viewModel = createViewModel()
val stateAfterInit = initialState.copy(
polls = polls,
canLoadMore = loadedPollsStatus.canLoadMore,
nbLoadedDays = loadedPollsStatus.nbLoadedDays,
)
// When
val viewModelTest = viewModel.test()
viewModel.handle(RoomPollsAction.LoadMorePolls)
// Then
viewModelTest
.assertStatesChanges(
stateAfterInit,
{ copy(isLoadingMore = true) },
{ copy(canLoadMore = newLoadedPollsStatus.canLoadMore, nbLoadedDays = newLoadedPollsStatus.nbLoadedDays) },
{ copy(isLoadingMore = false) },
)
.finish()
coVerify { fakeLoadMorePollsUseCase.execute(A_ROOM_ID) }
} }
private fun givenAPollSummary(): PollSummary { private fun givenAPollSummary(): PollSummary {
return mockk() return mockk()
} }
private fun givenSyncPollsWithSuccess() {
coJustRun { fakeSyncPollsUseCase.execute(A_ROOM_ID) }
}
private fun givenSyncPollsWithError(error: Exception) {
coEvery { fakeSyncPollsUseCase.execute(A_ROOM_ID) } throws error
}
private fun givenLoadMoreWithSuccess(): LoadedPollsStatus {
val loadedPollsStatus = givenALoadedPollsStatus(canLoadMore = false, nbLoadedDays = 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) =
LoadedPollsStatus(
canLoadMore = canLoadMore,
nbLoadedDays = nbLoadedDays,
)
} }

View File

@ -0,0 +1,95 @@
/*
* 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.features.roomprofile.polls.list.ui.PollSummary
import io.mockk.coJustRun
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
private const val A_ROOM_ID = "room-id"
class RoomPollRepositoryTest {
private val fakeRoomPollDataSource = mockk<RoomPollDataSource>()
private val roomPollRepository = RoomPollRepository(
roomPollDataSource = fakeRoomPollDataSource,
)
@Test
fun `given data source when getting polls then correct method of data source is called`() = runTest {
// Given
val expectedPolls = listOf<PollSummary>()
every { fakeRoomPollDataSource.getPolls(A_ROOM_ID) } returns flowOf(expectedPolls)
// When
val result = roomPollRepository.getPolls(A_ROOM_ID).firstOrNull()
// Then
result shouldBeEqualTo expectedPolls
verify { fakeRoomPollDataSource.getPolls(A_ROOM_ID) }
}
@Test
fun `given data source when getting loaded polls status then correct method of data source is called`() {
// Given
val expectedStatus = LoadedPollsStatus(
canLoadMore = true,
nbLoadedDays = 10,
)
every { 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) }
}
@Test
fun `given data source when loading more polls then correct method of data source is called`() = runTest {
// Given
coJustRun { fakeRoomPollDataSource.loadMorePolls(A_ROOM_ID) }
// When
roomPollRepository.loadMorePolls(A_ROOM_ID)
// Then
coVerify { fakeRoomPollDataSource.loadMorePolls(A_ROOM_ID) }
}
@Test
fun `given data source when syncing polls then correct method of data source is called`() = runTest {
// Given
coJustRun { fakeRoomPollDataSource.syncPolls(A_ROOM_ID) }
// When
roomPollRepository.syncPolls(A_ROOM_ID)
// Then
coVerify { fakeRoomPollDataSource.syncPolls(A_ROOM_ID) }
}
}

View File

@ -0,0 +1,52 @@
/*
* 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.LoadedPollsStatus
import im.vector.app.features.roomprofile.polls.list.data.RoomPollRepository
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
class GetLoadedPollsStatusUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val getLoadedPollsStatusUseCase = GetLoadedPollsStatusUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
fun `given repo when execute then correct method of repo is called`() {
// Given
val aRoomId = "roomId"
val expectedStatus = LoadedPollsStatus(
canLoadMore = true,
nbLoadedDays = 10,
)
every { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) } returns expectedStatus
// When
val status = getLoadedPollsStatusUseCase.execute(aRoomId)
// Then
status shouldBeEqualTo expectedStatus
verify { fakeRoomPollRepository.getLoadedPollsStatus(aRoomId) }
}
}

View File

@ -0,0 +1,63 @@
/*
* 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 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
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
class GetPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val getPollsUseCase = GetPollsUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
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>(
poll1,
poll2,
poll3,
)
every { fakeRoomPollRepository.getPolls(aRoomId) } returns flowOf(polls)
val expectedPolls = listOf<PollSummary>(
poll3,
poll2,
poll1,
)
// When
val result = getPollsUseCase.execute(aRoomId).first()
// Then
result shouldBeEqualTo expectedPolls
verify { fakeRoomPollRepository.getPolls(aRoomId) }
}
}

View File

@ -0,0 +1,46 @@
/*
* 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.coJustRun
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class LoadMorePollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val loadMorePollsUseCase = LoadMorePollsUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
fun `given repo when execute then correct method of repo is called`() = runTest {
// Given
val aRoomId = "roomId"
coJustRun { fakeRoomPollRepository.loadMorePolls(aRoomId) }
// When
loadMorePollsUseCase.execute(aRoomId)
// Then
coVerify { fakeRoomPollRepository.loadMorePolls(aRoomId) }
}
}

View File

@ -0,0 +1,46 @@
/*
* 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.coJustRun
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SyncPollsUseCaseTest {
private val fakeRoomPollRepository = mockk<RoomPollRepository>()
private val syncPollsUseCase = SyncPollsUseCase(
roomPollRepository = fakeRoomPollRepository,
)
@Test
fun `given repo when execute then correct method of repo is called`() = runTest {
// Given
val aRoomId = "roomId"
coJustRun { fakeRoomPollRepository.syncPolls(aRoomId) }
// When
syncPollsUseCase.execute(aRoomId)
// Then
coVerify { fakeRoomPollRepository.syncPolls(aRoomId) }
}
}

View File

@ -0,0 +1,47 @@
/*
* 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,
)
}