diff --git a/changelog.d/7824.feature b/changelog.d/7824.feature new file mode 100644 index 0000000000..3c8b416571 --- /dev/null +++ b/changelog.d/7824.feature @@ -0,0 +1 @@ +[Poll] Warning message on decryption failure of some events diff --git a/changelog.d/7864.wip b/changelog.d/7864.wip new file mode 100644 index 0000000000..9d719d92ff --- /dev/null +++ b/changelog.d/7864.wip @@ -0,0 +1 @@ +[Poll] History list: Load more UI mechanism diff --git a/dependencies.gradle b/dependencies.gradle index f6be35edc0..4977543822 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -18,7 +18,7 @@ def markwon = "4.6.2" def moshi = "1.14.0" def lifecycle = "2.5.1" def flowBinding = "1.2.0" -def flipper = "0.176.1" +def flipper = "0.177.0" def epoxy = "5.0.0" def mavericks = "3.0.1" def glide = "4.14.2" @@ -103,7 +103,7 @@ ext.libs = [ ], element : [ '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 : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index e4935aa491..e6556520cf 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -795,7 +795,7 @@ Shows all threads you’ve participated in Keep discussions organized with threads Threads help keep your conversations on-topic and easy to track. - You\'re homeserver does not support listing threads yet. + Your homeserver does not support listing threads yet. Tip: Long tap a message and use “%s”. From a Thread @@ -3207,10 +3207,22 @@ Closed poll Results are only revealed when you end the poll Ended the poll. + Due to decryption errors, some votes may not be counted Active polls There are no active polls in this room + + "There are no active polls for the past day.\nLoad more polls to view polls for previous days." + "There are no active polls for the past %1$d days.\nLoad more polls to view polls for previous days." + Past polls There are no past polls in this room + + "There are no past polls for the past day.\nLoad more polls to view polls for previous days." + "There are no past polls for the past %1$d days.\nLoad more polls to view polls for previous days." + + Displaying polls + Load more polls + Error fetching polls. Share location diff --git a/matrix-sdk-android/src/kotlinCrypto/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt b/matrix-sdk-android/src/kotlinCrypto/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt index c9eabeab48..03672ae81c 100644 --- a/matrix-sdk-android/src/kotlinCrypto/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt +++ b/matrix-sdk-android/src/kotlinCrypto/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt @@ -59,7 +59,7 @@ internal class EventDecryptor @Inject constructor( private val sendToDeviceTask: SendToDeviceTask, private val deviceListManager: DeviceListManager, private val ensureOlmSessionsForDevicesAction: EnsureOlmSessionsForDevicesAction, - private val cryptoStore: IMXCryptoStore + private val cryptoStore: IMXCryptoStore, ) { /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollResponseAggregatedSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollResponseAggregatedSummary.kt index b16852e47d..e8b4ef6ed6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollResponseAggregatedSummary.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/PollResponseAggregatedSummary.kt @@ -23,5 +23,7 @@ data class PollResponseAggregatedSummary( 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) val sourceEvents: List, - val localEchos: List + val localEchos: List, + // list of related event ids which are encrypted due to decryption failure + val encryptedRelatedEventIds: List, ) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt index d1ca4f48a6..a3f38cf2c6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/EventInsertLiveObserver.kt @@ -17,11 +17,16 @@ package org.matrix.android.sdk.internal.database import com.zhuinden.monarchy.Monarchy +import io.realm.Realm import io.realm.RealmConfiguration import io.realm.RealmResults import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex 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.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertEntity @@ -34,7 +39,7 @@ import javax.inject.Inject internal class EventInsertLiveObserver @Inject constructor( @SessionDatabase realmConfiguration: RealmConfiguration, - private val processors: Set<@JvmSuppressWildcards EventInsertLiveProcessor> + private val processors: Set<@JvmSuppressWildcards EventInsertLiveProcessor>, ) : RealmLiveEntityObserver(realmConfiguration) { @@ -50,48 +55,90 @@ internal class EventInsertLiveObserver @Inject constructor( if (!results.isLoaded || results.isEmpty()) { return@withLock } - val idsToDeleteAfterProcess = ArrayList() - val filteredEvents = ArrayList(results.size) + val eventsToProcess = ArrayList(results.size) + val eventsToIgnore = ArrayList(results.size) + Timber.v("EventInsertEntity updated with ${results.size} results in db") results.forEach { - if (shouldProcess(it)) { - // don't use copy from realm over there - val copiedEvent = EventInsertEntity( - eventId = it.eventId, - eventType = it.eventType - ).apply { - insertType = it.insertType - } - filteredEvents.add(copiedEvent) + // don't use copy from realm over there + val copiedEvent = EventInsertEntity( + eventId = it.eventId, + eventType = it.eventType + ).apply { + insertType = it.insertType + } + + if (shouldProcess(it)) { + eventsToProcess.add(copiedEvent) + } else { + eventsToIgnore.add(copiedEvent) } - idsToDeleteAfterProcess.add(it.eventId) } + awaitTransaction(realmConfiguration) { realm -> - Timber.v("##Transaction: There are ${filteredEvents.size} events to process ") - filteredEvents.forEach { eventInsert -> + Timber.v("##Transaction: There are ${eventsToProcess.size} events to process") + + val idsToDeleteAfterProcess = ArrayList() + val idsOfEncryptedEvents = ArrayList() + val getAndTriageEvent: (EventInsertEntity) -> Event? = { eventInsert -> val eventId = eventInsert.eventId - val event = EventEntity.where(realm, eventId).findFirst() - if (event == null) { - Timber.v("Event $eventId not found") + val event = getEvent(realm, eventId) + if (event?.getClearType() == EventType.ENCRYPTED) { + 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 } - 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) .`in`(EventInsertEntityFields.EVENT_ID, idsToDeleteAfterProcess.toTypedArray()) .findAll() .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() } } } } + 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()?.relatesTo != null + } + private fun shouldProcess(eventInsertEntity: EventInsertEntity): Boolean { return processors.any { it.shouldProcess(eventInsertEntity.eventId, eventInsertEntity.eventType, eventInsertEntity.insertType) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt index ba102a7a48..2b7e9a04a1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt @@ -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.MigrateSessionTo046 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047 +import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo048 import org.matrix.android.sdk.internal.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import javax.inject.Inject @@ -72,7 +73,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val normalizer: Normalizer ) : MatrixRealmMigration( dbName = "Session", - schemaVersion = 47L, + schemaVersion = 48L, ) { /** * Forces all RealmSessionStoreMigration instances to be equal. @@ -129,5 +130,6 @@ internal class RealmSessionStoreMigration @Inject constructor( if (oldVersion < 45) MigrateSessionTo045(realm).perform() if (oldVersion < 46) MigrateSessionTo046(realm).perform() if (oldVersion < 47) MigrateSessionTo047(realm).perform() + if (oldVersion < 48) MigrateSessionTo048(realm).perform() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PollResponseAggregatedSummaryEntityMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PollResponseAggregatedSummaryEntityMapper.kt index 00998af9bb..808a49b958 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PollResponseAggregatedSummaryEntityMapper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/PollResponseAggregatedSummaryEntityMapper.kt @@ -30,7 +30,8 @@ internal object PollResponseAggregatedSummaryEntityMapper { closedTime = entity.closedTime, localEchos = entity.sourceLocalEchoEvents.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, closedTime = model.closedTime, sourceEvents = RealmList().apply { addAll(model.sourceEvents) }, - sourceLocalEchoEvents = RealmList().apply { addAll(model.localEchos) } + sourceLocalEchoEvents = RealmList().apply { addAll(model.localEchos) }, + encryptedRelatedEventIds = RealmList().apply { addAll(model.encryptedRelatedEventIds) }, ) } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo048.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo048.kt new file mode 100644 index 0000000000..4299054c56 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo048.kt @@ -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) + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventInsertEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventInsertEntity.kt index eff332dc3a..054094c398 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventInsertEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventInsertEntity.kt @@ -27,7 +27,7 @@ internal open class EventInsertEntity( var eventType: String = "", /** * 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 ) : RealmObject() { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt index d759bd3cd9..906e329f6f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/PollResponseAggregatedSummaryEntity.kt @@ -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) var sourceEvents: RealmList = RealmList(), - var sourceLocalEchoEvents: RealmList = RealmList() + var sourceLocalEchoEvents: RealmList = RealmList(), + // list of related event ids which are encrypted due to decryption failure + var encryptedRelatedEventIds: RealmList = RealmList(), ) : RealmObject() { companion object diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt index 0d998e8fe1..93fe1bd1d2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt @@ -72,7 +72,7 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit SpaceParentSummaryEntity::class, UserPresenceEntity::class, ThreadSummaryEntity::class, - ThreadListPageEntity::class + ThreadListPageEntity::class, ] ) internal class SessionRealmModule diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt index 0f1c226044..4805c36f8c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/EventEntityQueries.kt @@ -20,7 +20,6 @@ import io.realm.Realm import io.realm.RealmList import io.realm.RealmQuery 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.EventEntityFields 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) .findFirst() return if (eventEntity == null) { - val canBeProcessed = type != EventType.ENCRYPTED || decryptionResultJson != null - val insertEntity = EventInsertEntity(eventId = eventId, eventType = type, canBeProcessed = canBeProcessed).apply { - this.insertType = insertType - } + val insertEntity = EventInsertEntity(eventId = eventId, eventType = type, canBeProcessed = true) + insertEntity.insertType = insertType + realm.insert(insertEntity) // copy this event entity and return it realm.copyToRealm(this) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt index be73309837..edc10bd187 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt @@ -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.room.aggregation.livelocation.LiveLocationAggregationProcessor 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.util.time.Clock import timber.log.Timber @@ -73,6 +74,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor( private val sessionManager: SessionManager, private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor, private val pollAggregationProcessor: PollAggregationProcessor, + private val encryptedReferenceAggregationProcessor: EncryptedReferenceAggregationProcessor, private val editValidator: EventEditValidator, private val clock: Clock, ) : EventInsertLiveProcessor { @@ -140,6 +142,16 @@ internal class EventRelationsAggregationProcessor @Inject constructor( Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}") handleReaction(realm, event, roomId, isLocalEcho) } + EventType.ENCRYPTED -> { + val encryptedEventContent = event.content.toModel() + processEncryptedContent( + encryptedEventContent = encryptedEventContent, + realm = realm, + event = event, + roomId = roomId, + isLocalEcho = isLocalEcho, + ) + } EventType.MESSAGE -> { if (event.unsignedData?.relations?.annotations != null) { 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() - 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 -> { val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() } ?: 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 private val SHOULD_HANDLE_SERVER_AGREGGATION = false // should be true to work with e2e diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt index a424becbd6..2ff43d6812 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt @@ -155,6 +155,8 @@ internal class DefaultPollAggregationProcessor @Inject constructor( ) aggregatedPollSummaryEntity.aggregatedContent = ContentMapper.map(newSumModel.toContent()) + event.eventId?.let { removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity, it) } + return true } @@ -180,6 +182,8 @@ internal class DefaultPollAggregationProcessor @Inject constructor( aggregatedPollSummaryEntity.sourceEvents.add(event.eventId) } + event.eventId?.let { removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity, it) } + if (!isLocalEcho) { ensurePollIsFullyAggregated(roomId, pollEventId) } @@ -226,4 +230,10 @@ internal class DefaultPollAggregationProcessor @Inject constructor( fetchPollResponseEventsTask.execute(params) } } + + private fun removeEncryptedRelatedEventIdIfNeeded(aggregatedPollSummaryEntity: PollResponseAggregatedSummaryEntity, eventId: String) { + if (aggregatedPollSummaryEntity.encryptedRelatedEventIds.contains(eventId)) { + aggregatedPollSummaryEntity.encryptedRelatedEventIds.remove(eventId) + } + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessor.kt index 848643b435..33a69b720a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessor.kt @@ -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.room.powerlevels.PowerLevelsHelper -interface PollAggregationProcessor { +internal interface PollAggregationProcessor { /** * Poll start events don't need to be processed by the aggregator. * This function will only handle if the poll is edited and will update the poll summary entity. diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/utd/EncryptedReferenceAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/utd/EncryptedReferenceAggregationProcessor.kt new file mode 100644 index 0000000000..43631fcc3e --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/utd/EncryptedReferenceAggregationProcessor.kt @@ -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() + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessorTest.kt new file mode 100644 index 0000000000..ff803c4f1a --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessorTest.kt @@ -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().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() + .givenEqualTo(EventAnnotationsSummaryEntityFields.ROOM_ID, roomId) + .givenEqualTo(EventAnnotationsSummaryEntityFields.EVENT_ID, eventId) + .givenFindFirst(annotationsSummary) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt index 0888d82907..766e51a8e5 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue +import org.amshove.kluent.shouldContain +import org.amshove.kluent.shouldNotContain import org.junit.Before import org.junit.Test import org.matrix.android.sdk.api.session.Session @@ -105,6 +107,24 @@ class DefaultPollAggregationProcessorTest { 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 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 { @@ -132,12 +152,33 @@ class DefaultPollAggregationProcessorTest { // Given every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() every { fakeTaskExecutor.instance.executorScope } returns this - - // When val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true) + // When + val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT) + // 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 @@ -145,12 +186,13 @@ class DefaultPollAggregationProcessorTest { // Given every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() every { fakeTaskExecutor.instance.executorScope } returns this - - // When val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, false) + // When + val result = pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT) + // Then - pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() + result.shouldBeTrue() } @Test diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/utd/EncryptedReferenceAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/utd/EncryptedReferenceAggregationProcessorTest.kt new file mode 100644 index 0000000000..2998b9bff0 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/utd/EncryptedReferenceAggregationProcessorTest.kt @@ -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() + 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() + 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() + .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() + .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().also { + every { it.eventId } returns eventId + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt index ba124a86aa..49d64c1835 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt @@ -117,6 +117,14 @@ inline fun RealmQuery.givenIn( return this } +inline fun RealmQuery.givenContainsValue( + fieldName: String, + value: String, +): RealmQuery { + 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. */ diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeEventEditValidator.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeEventEditValidator.kt new file mode 100644 index 0000000000..2fa36cf60d --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeEventEditValidator.kt @@ -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() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeLiveLocationAggregationProcessor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeLiveLocationAggregationProcessor.kt new file mode 100644 index 0000000000..6385110963 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakeLiveLocationAggregationProcessor.kt @@ -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() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePollAggregationProcessor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePollAggregationProcessor.kt new file mode 100644 index 0000000000..5187c785ca --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/FakePollAggregationProcessor.kt @@ -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() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/aggregation/utd/FakeEncryptedReferenceAggregationProcessor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/aggregation/utd/FakeEncryptedReferenceAggregationProcessor.kt new file mode 100644 index 0000000000..7661095fe3 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/aggregation/utd/FakeEncryptedReferenceAggregationProcessor.kt @@ -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) } + } +} diff --git a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt index 78aaa058e9..c1e201cfc4 100644 --- a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt @@ -20,6 +20,7 @@ import android.content.ActivityNotFoundException import im.vector.app.R import im.vector.app.core.resources.StringProvider 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.voicebroadcast.VoiceBroadcastFailure 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) is VoiceFailure -> voiceMessageError(throwable) is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable) + is RoomPollsLoadingError -> stringProvider.getString(R.string.room_polls_loading_error) is ActivityNotFoundException -> stringProvider.getString(R.string.error_no_external_application_found) else -> throwable.localizedMessage diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index c02eb1fa8a..56ee9ffb5a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -138,7 +138,7 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) { - val needsSendButtonVisibilityUpdate = currentComposerText.isEmpty() != action.text.isEmpty() + val needsSendButtonVisibilityUpdate = currentComposerText.isBlank() != action.text.isBlank() currentComposerText = SpannableString(action.text) if (needsSendButtonVisibilityUpdate) { updateIsSendButtonVisibility(true) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt index 13f63e86c4..7abc51fa51 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/PollItemViewStateFactory.kt @@ -83,9 +83,14 @@ class PollItemViewStateFactory @Inject constructor( totalVotes: Int, winnerVoteCount: Int?, ): 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( question = question, - votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_after_ended, totalVotes, totalVotes), + votesStatus = totalVotesText, canVote = false, optionViewStates = pollCreationInfo?.answers?.map { answer -> val voteSummary = pollResponseSummary?.getVoteSummaryOfAnOption(answer.id ?: "") @@ -126,9 +131,14 @@ class PollItemViewStateFactory @Inject constructor( pollResponseSummary: PollResponseData?, totalVotes: Int ): 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( question = question, - votesStatus = stringProvider.getQuantityString(R.plurals.poll_total_vote_count_before_ended_and_voted, totalVotes, totalVotes), + votesStatus = totalVotesText, canVote = true, optionViewStates = pollCreationInfo?.answers?.map { answer -> 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) { stringProvider.getString(R.string.poll_no_votes_cast) } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/PollResponseDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/PollResponseDataFactory.kt index 533397b4d8..8f81adcd32 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/PollResponseDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/PollResponseDataFactory.kt @@ -44,7 +44,8 @@ class PollResponseDataFactory @Inject constructor( ) }, winnerVoteCount = it.aggregatedContent?.winnerVoteCount ?: 0, - totalVotes = it.aggregatedContent?.totalVotes ?: 0 + totalVotes = it.aggregatedContent?.totalVotes ?: 0, + hasEncryptedRelatedEvents = it.encryptedRelatedEventIds.isNotEmpty(), ) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt index 757246d4e4..a1a214785e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageInformationData.kt @@ -90,7 +90,8 @@ data class PollResponseData( val votes: Map?, val totalVotes: Int = 0, val winnerVoteCount: Int = 0, - val isClosed: Boolean = false + val isClosed: Boolean = false, + val hasEncryptedRelatedEvents: Boolean = false, ) : Parcelable { fun getVoteSummaryOfAnOption(optionId: String) = votes?.get(optionId) diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileActivity.kt b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileActivity.kt index 3c37c92650..3ee1ed867c 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileActivity.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/RoomProfileActivity.kt @@ -76,6 +76,8 @@ class RoomProfileActivity : return ActivitySimpleBinding.inflate(layoutInflater) } + override fun getCoordinatorLayout() = views.coordinatorLayout + override fun initUiAndData() { sharedActionViewModel = viewModelProvider.get(RoomProfileSharedActionViewModel::class.java) roomProfileArgs = intent?.extras?.getParcelableCompat(Mavericks.KEY_ARG) ?: return diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsAction.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsAction.kt index c18142a306..3fedbfc4a8 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsAction.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsAction.kt @@ -18,4 +18,6 @@ package im.vector.app.features.roomprofile.polls import im.vector.app.core.platform.VectorViewModelAction -sealed interface RoomPollsAction : VectorViewModelAction +sealed interface RoomPollsAction : VectorViewModelAction { + object LoadMorePolls : RoomPollsAction +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsLoadingError.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsLoadingError.kt new file mode 100644 index 0000000000..71365087f1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsLoadingError.kt @@ -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() diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewEvent.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewEvent.kt index 231123563a..cb2069d824 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewEvent.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewEvent.kt @@ -18,4 +18,6 @@ package im.vector.app.features.roomprofile.polls import im.vector.app.core.platform.VectorViewEvents -sealed class RoomPollsViewEvent : VectorViewEvents +sealed class RoomPollsViewEvent : VectorViewEvents { + object LoadingError : RoomPollsViewEvent() +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt index 95cb4717ca..b634881f70 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewModel.kt @@ -23,12 +23,20 @@ import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.roomprofile.polls.list.domain.GetLoadedPollsStatusUseCase +import im.vector.app.features.roomprofile.polls.list.domain.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.onEach +import kotlinx.coroutines.launch class RoomPollsViewModel @AssistedInject constructor( @Assisted initialState: RoomPollsViewState, private val getPollsUseCase: GetPollsUseCase, + private val getLoadedPollsStatusUseCase: GetLoadedPollsStatusUseCase, + private val loadMorePollsUseCase: LoadMorePollsUseCase, + private val syncPollsUseCase: SyncPollsUseCase, ) : VectorViewModel(initialState) { @AssistedFactory @@ -39,16 +47,63 @@ class RoomPollsViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() init { - observePolls() + val roomId = initialState.roomId + updateLoadedPollStatus(roomId) + syncPolls(roomId) + observePolls(roomId) } - private fun observePolls() { - getPollsUseCase.execute() + private fun updateLoadedPollStatus(roomId: String) { + 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) } } .launchIn(viewModelScope) } 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) } + } } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt index 74794c99b1..fa985c5c76 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/RoomPollsViewState.kt @@ -18,11 +18,19 @@ package im.vector.app.features.roomprofile.polls import com.airbnb.mvrx.MavericksState import im.vector.app.features.roomprofile.RoomProfileArgs +import im.vector.app.features.roomprofile.polls.list.ui.PollSummary data class RoomPollsViewState( val roomId: String, val polls: List = emptyList(), + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = true, + val nbLoadedDays: Int = 0, + val isSyncing: Boolean = false, ) : MavericksState { constructor(roomProfileArgs: RoomProfileArgs) : this(roomId = roomProfileArgs.roomId) + + fun hasNoPolls() = polls.isEmpty() + fun hasNoPollsAndCanLoadMore() = !isSyncing && hasNoPolls() && canLoadMore } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/active/RoomActivePollsFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/active/RoomActivePollsFragment.kt index 1c6a03c480..441a4489b3 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/active/RoomActivePollsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/active/RoomActivePollsFragment.kt @@ -19,13 +19,17 @@ package im.vector.app.features.roomprofile.polls.active import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R 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 class RoomActivePollsFragment : RoomPollsListFragment() { - override fun getEmptyListTitle(): String { - return getString(R.string.room_polls_active_no_item) + override fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String { + 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 { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/ended/RoomEndedPollsFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/ended/RoomEndedPollsFragment.kt index 8dd0cadadf..53f61126b5 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/ended/RoomEndedPollsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/ended/RoomEndedPollsFragment.kt @@ -19,13 +19,17 @@ package im.vector.app.features.roomprofile.polls.ended import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R 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 class RoomEndedPollsFragment : RoomPollsListFragment() { - override fun getEmptyListTitle(): String { - return getString(R.string.room_polls_ended_no_item) + override fun getEmptyListTitle(canLoadMore: Boolean, nbLoadedDays: Int): String { + 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 { diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/RoomPollsListFragment.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/RoomPollsListFragment.kt deleted file mode 100644 index 0d97bd8dcb..0000000000 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/RoomPollsListFragment.kt +++ /dev/null @@ -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(), - 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) { - roomPollsController.setData(polls) - views.roomPollsEmptyTitle.isVisible = polls.isEmpty() - } - - override fun onPollClicked(pollId: String) { - // TODO navigate to details - Timber.d("poll with id $pollId clicked") - } -} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/LoadedPollsStatus.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/LoadedPollsStatus.kt new file mode 100644 index 0000000000..c3971bb289 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/LoadedPollsStatus.kt @@ -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, +) diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/GetPollsUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSource.kt similarity index 64% rename from vector/src/main/java/im/vector/app/features/roomprofile/polls/GetPollsUseCase.kt rename to vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSource.kt index 6f2a757ed7..c0efb1efa1 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/GetPollsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollDataSource.kt @@ -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"); * you may not use this file except in compliance with the License. @@ -14,23 +14,60 @@ * 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.roomprofile.polls.list.ui.PollSummary +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import timber.log.Timber import javax.inject.Inject +import javax.inject.Singleton -class GetPollsUseCase @Inject constructor() { +@Singleton +class RoomPollDataSource @Inject constructor() { - fun execute(): Flow> { - // TODO unmock and add unit tests - return flowOf(getActivePolls() + getEndedPolls()) - .map { it.sortedByDescending { poll -> poll.creationTimestamp } } + private val pollsFlow = MutableSharedFlow>(replay = 1) + private val polls = mutableListOf() + private var fakeLoadCounter = 0 + + // TODO + // unmock using SDK service + add unit tests + // after unmock, expose domain layer model (entity) and do the mapping to PollSummary in the UI layer + fun getPolls(roomId: String): Flow> { + Timber.d("roomId=$roomId") + return pollsFlow.asSharedFlow() } - private fun getActivePolls(): List { + 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 { return listOf( PollSummary.ActivePoll( id = "id1", @@ -44,6 +81,11 @@ class GetPollsUseCase @Inject constructor() { creationTimestamp = 1656194400000, title = "Which sport should the pupils do this year?" ), + ) + } + + private fun getActivePollsPart2(): List { + return listOf( PollSummary.ActivePoll( id = "id3", // 2022/06/24 UTC+1 @@ -59,7 +101,7 @@ class GetPollsUseCase @Inject constructor() { ) } - private fun getEndedPolls(): List { + private fun getEndedPollsPart1(): List { return listOf( PollSummary.EndedPoll( id = "id1-ended", @@ -77,6 +119,11 @@ class GetPollsUseCase @Inject constructor() { ) ), ), + ) + } + + private fun getEndedPollsPart2(): List { + return listOf( PollSummary.EndedPoll( id = "id2-ended", // 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) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepository.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepository.kt new file mode 100644 index 0000000000..d3577df6c1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/data/RoomPollRepository.kt @@ -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> { + 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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCase.kt new file mode 100644 index 0000000000..55324b253f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetLoadedPollsStatusUseCase.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCase.kt new file mode 100644 index 0000000000..be2afb226f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/GetPollsUseCase.kt @@ -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> { + return roomPollRepository.getPolls(roomId) + .map { it.sortedByDescending { poll -> poll.creationTimestamp } } + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCase.kt new file mode 100644 index 0000000000..df3270552d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/LoadMorePollsUseCase.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCase.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCase.kt new file mode 100644 index 0000000000..b6a344f7f8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/domain/SyncPollsUseCase.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/PollSummary.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummary.kt similarity index 92% rename from vector/src/main/java/im/vector/app/features/roomprofile/polls/PollSummary.kt rename to vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummary.kt index f24ac8b8a6..5c1eee0d00 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/PollSummary.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/PollSummary.kt @@ -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"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * 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 diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/RoomPollItem.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollItem.kt similarity index 96% rename from vector/src/main/java/im/vector/app/features/roomprofile/polls/list/RoomPollItem.kt rename to vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollItem.kt index da00fedddb..d675fe9bce 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/RoomPollItem.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollItem.kt @@ -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"); * you may not use this file except in compliance with the License. @@ -14,7 +14,7 @@ * 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.TextView diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollLoadMoreItem.kt b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollLoadMoreItem.kt new file mode 100644 index 0000000000..f16b9fa5a0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/roomprofile/polls/list/ui/RoomPollLoadMoreItem.kt @@ -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(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