From b8752152cf8b1ccdfc7c54564aa62b3bd23316c8 Mon Sep 17 00:00:00 2001 From: Aliaksandr Daroshchanka <48487677+hghgrtut@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:43:27 +0300 Subject: [PATCH] Add read all messages feature (#7049) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add read all messages feature: read all messages using Client.markAllRoomsAsRead() method * Move the 'mark all rooms as read' footer as part of the list item * Replace `showMarkAllRoomsAsReadConfirmation` and its associated event with `MarkAllRoomsAsRead(val needsConfirmation: Boolean)` and a `AsyncAction.Confirming` state --------- Co-authored-by: Jorge Martín --- features/home/impl/build.gradle.kts | 1 + .../home/impl/roomlist/RoomListPresenter.kt | 23 ++---- .../home/impl/roomlist/FakeMarkRoomAsRead.kt | 22 ++++++ .../impl/roomlist/RoomListPresenterTest.kt | 33 +++++++- .../impl/developer/DeveloperSettingsEvents.kt | 2 + .../developer/DeveloperSettingsPresenter.kt | 28 ++++++- .../impl/developer/DeveloperSettingsState.kt | 3 +- .../DeveloperSettingsStateProvider.kt | 2 + .../impl/developer/DeveloperSettingsView.kt | 38 +++++++++ .../impl/tasks/MarkAllRoomsAsRead.kt | 33 ++++++++ .../preferences/impl/tasks/MarkRoomAsRead.kt | 45 +++++++++++ .../DeveloperSettingsPresenterTest.kt | 25 ++++++ .../developer/DeveloperSettingsViewTest.kt | 2 +- .../impl/tasks/FakeMarkAllRoomsAsRead.kt | 22 ++++++ .../impl/tasks/FakeMarkRoomAsRead.kt | 21 +++++ .../impl/tasks/MarkAllRoomsAsReadTest.kt | 61 ++++++++++++++ .../impl/tasks/MarkRoomAsReadTest.kt | 79 +++++++++++++++++++ .../libraries/matrix/api/MatrixClient.kt | 6 ++ .../libraries/matrix/impl/RustMatrixClient.kt | 6 ++ .../libraries/matrix/test/FakeMatrixClient.kt | 5 ++ 20 files changed, 433 insertions(+), 24 deletions(-) create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/FakeMarkRoomAsRead.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsRead.kt create mode 100644 features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsRead.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkAllRoomsAsRead.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkRoomAsRead.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsReadTest.kt create mode 100644 features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsReadTest.kt diff --git a/features/home/impl/build.gradle.kts b/features/home/impl/build.gradle.kts index 0635da39a5..efdc545e61 100644 --- a/features/home/impl/build.gradle.kts +++ b/features/home/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(libs.androidx.datastore.preferences) implementation(libs.haze) implementation(libs.haze.materials) + implementation(projects.features.preferences.impl) implementation(projects.features.reportroom.api) implementation(projects.features.rolesandpermissions.api) implementation(projects.libraries.previewutils) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index 128e663f61..fe09bd7cd1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -41,6 +41,7 @@ import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteE import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -51,11 +52,8 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.encryption.RecoveryState import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomListFilter -import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar -import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.push.api.battery.BatteryOptimizationState -import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -65,7 +63,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -79,12 +76,11 @@ class RoomListPresenter( private val roomListDataSource: RoomListDataSource, private val filtersPresenter: Presenter, private val searchPresenter: Presenter, - private val sessionPreferencesStore: SessionPreferencesStore, private val analyticsService: AnalyticsService, private val acceptDeclineInvitePresenter: Presenter, private val fullScreenIntentPermissionsPresenter: Presenter, private val batteryOptimizationPresenter: Presenter, - private val notificationCleaner: NotificationCleaner, + private val markRoomAsRead: MarkRoomAsRead, private val seenInvitesStore: SeenInvitesStore, private val announcementService: AnnouncementService, private val coldStartWatcher: AnalyticsColdStartWatcher, @@ -308,19 +304,10 @@ class RoomListPresenter( } private fun CoroutineScope.markAsRead(roomId: RoomId) = launch { - notificationCleaner.clearMessagesForRoom(client.sessionId, roomId) - client.getRoom(roomId)?.use { room -> - room.setUnreadFlag(isUnread = false) - val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { - ReceiptType.READ - } else { - ReceiptType.READ_PRIVATE + markRoomAsRead(roomId) + .onSuccess { + analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle) } - room.markAsRead(receiptType) - .onSuccess { - analyticsService.captureInteraction(name = Interaction.Name.MobileRoomListRoomContextMenuUnreadToggle) - } - } } private fun CoroutineScope.markAsUnread(roomId: RoomId) = launch { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/FakeMarkRoomAsRead.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/FakeMarkRoomAsRead.kt new file mode 100644 index 0000000000..ace688e664 --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/FakeMarkRoomAsRead.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.home.impl.roomlist + +import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead +import io.element.android.libraries.matrix.api.core.RoomId + +class FakeMarkRoomAsRead( + private val invokeLambda: suspend (RoomId) -> Result = { Result.success(Unit) }, +) : MarkRoomAsRead { + val invokedRoomIds = mutableListOf() + + override suspend fun invoke(roomId: RoomId): Result { + invokedRoomIds.add(roomId) + return invokeLambda(roomId) + } +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 642fd462d7..64dfc46ee2 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -30,6 +30,7 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState +import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead import io.element.android.features.rageshake.test.logs.FakeAnnouncementService import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.dateformatter.api.DateFormatter @@ -427,11 +428,17 @@ class RoomListPresenterTest { val notificationCleaner = FakeNotificationCleaner( clearMessagesForRoomLambda = clearMessagesForRoomLambda, ) + val markRoomAsRead = createTestMarkRoomAsRead( + client = matrixClient, + notificationCleaner = notificationCleaner, + sessionPreferencesStore = sessionPreferencesStore, + ) val presenter = createRoomListPresenter( client = matrixClient, sessionPreferencesStore = sessionPreferencesStore, analyticsService = analyticsService, notificationCleaner = notificationCleaner, + markRoomAsRead = markRoomAsRead, ) presenter.test { val initialState = awaitItem() @@ -623,6 +630,24 @@ class RoomListPresenterTest { } } + private fun createTestMarkRoomAsRead( + client: MatrixClient, + notificationCleaner: NotificationCleaner, + sessionPreferencesStore: SessionPreferencesStore, + ): MarkRoomAsRead = FakeMarkRoomAsRead { roomId -> + notificationCleaner.clearMessagesForRoom(client.sessionId, roomId) + val room = client.getRoom(roomId) ?: return@FakeMarkRoomAsRead Result.failure(IllegalStateException("Room not found")) + room.use { + it.setUnreadFlag(isUnread = false) + val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE + } + it.markAsRead(receiptType) + } + } + private fun TestScope.createRoomListPresenter( client: MatrixClient = FakeMatrixClient(), leaveRoomState: LeaveRoomState = aLeaveRoomState(), @@ -638,6 +663,7 @@ class RoomListPresenterTest { seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), announcementService: AnnouncementService = FakeAnnouncementService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + markRoomAsRead: MarkRoomAsRead? = null, ) = RoomListPresenter( client = client, leaveRoomPresenter = { leaveRoomState }, @@ -654,14 +680,17 @@ class RoomListPresenterTest { analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, - sessionPreferencesStore = sessionPreferencesStore, filtersPresenter = filtersPresenter, spaceFiltersPresenter = spaceFiltersPresenter, analyticsService = analyticsService, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() }, batteryOptimizationPresenter = { aBatteryOptimizationState() }, - notificationCleaner = notificationCleaner, + markRoomAsRead = markRoomAsRead ?: createTestMarkRoomAsRead( + client = client, + notificationCleaner = notificationCleaner, + sessionPreferencesStore = sessionPreferencesStore, + ), seenInvitesStore = seenInvitesStore, announcementService = announcementService, coldStartWatcher = FakeAnalyticsColdStartWatcher(), diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index 12de2be746..2d6c2b0904 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -15,4 +15,6 @@ sealed interface DeveloperSettingsEvents { data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents data object VacuumStores : DeveloperSettingsEvents + data class MarkAllRoomsAsRead(val needsConfirmation: Boolean) : DeveloperSettingsEvents + data object DismissMarkAllRoomsAsReadConfirmation : DeveloperSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt index 1598c2ef27..02d2573ed6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenter.kt @@ -22,6 +22,7 @@ import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsState import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase +import io.element.android.features.preferences.impl.tasks.MarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase import io.element.android.libraries.androidutils.filesize.FileSizeFormatter import io.element.android.libraries.architecture.AsyncAction @@ -46,6 +47,7 @@ class DeveloperSettingsPresenter( private val vacuumStoresUseCase: VacuumStoresUseCase, private val databaseSizesUseCase: GetDatabaseSizesUseCase, private val fileSizeFormatter: FileSizeFormatter, + private val markAllRoomsAsRead: MarkAllRoomsAsRead, ) : Presenter { @Composable override fun present(): DeveloperSettingsState { @@ -58,6 +60,9 @@ class DeveloperSettingsPresenter( val clearCacheAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + val markAllRoomsAsReadAction = remember { + mutableStateOf>(AsyncAction.Uninitialized) + } var showColorPicker by remember { mutableStateOf(false) } @@ -88,6 +93,18 @@ class DeveloperSettingsPresenter( DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch { vacuumStoresUseCase() } + is DeveloperSettingsEvents.MarkAllRoomsAsRead -> { + if (event.needsConfirmation) { + markAllRoomsAsReadAction.value = AsyncAction.ConfirmingNoParams + } else { + coroutineScope.markAllRoomsAsRead( + markAllRoomsAsReadAction = markAllRoomsAsReadAction, + ) + } + } + DeveloperSettingsEvents.DismissMarkAllRoomsAsReadConfirmation -> { + markAllRoomsAsReadAction.value = AsyncAction.Uninitialized + } } } @@ -97,6 +114,7 @@ class DeveloperSettingsPresenter( cacheSize = cacheSize.value, databaseSizes = databaseSizes.value, clearCacheAction = clearCacheAction.value, + markAllRoomsAsReadAction = markAllRoomsAsReadAction.value, isEnterpriseBuild = enterpriseService.isEnterpriseBuild, showColorPicker = showColorPicker, eventSink = ::handleEvent, @@ -131,8 +149,14 @@ class DeveloperSettingsPresenter( } private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { + suspend { clearCacheUseCase() }.runCatchingUpdatingState(state = clearCacheAction) + } + + private fun CoroutineScope.markAllRoomsAsRead( + markAllRoomsAsReadAction: MutableState>, + ) = launch { suspend { - clearCacheUseCase() - }.runCatchingUpdatingState(clearCacheAction) + markAllRoomsAsRead().getOrThrow() + }.runCatchingUpdatingState(state = markAllRoomsAsReadAction) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index fa5859a028..fe48e8c446 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -18,9 +18,10 @@ data class DeveloperSettingsState( val cacheSize: AsyncData, val databaseSizes: AsyncData>, val clearCacheAction: AsyncAction, + val markAllRoomsAsReadAction: AsyncAction, val isEnterpriseBuild: Boolean, val showColorPicker: Boolean, val eventSink: (DeveloperSettingsEvents) -> Unit ) { - val showLoader = clearCacheAction is AsyncAction.Loading + val showLoader = clearCacheAction is AsyncAction.Loading || markAllRoomsAsReadAction is AsyncAction.Loading } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index 28aefd3ad1..fd285b1bb9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -37,6 +37,7 @@ open class DeveloperSettingsStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, + markAllRoomsAsReadAction: AsyncAction = AsyncAction.Uninitialized, isEnterpriseBuild: Boolean = false, showColorPicker: Boolean = false, eventSink: (DeveloperSettingsEvents) -> Unit = {}, @@ -45,6 +46,7 @@ fun aDeveloperSettingsState( cacheSize = AsyncData.Success("1.2 MB"), databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")), clearCacheAction = clearCacheAction, + markAllRoomsAsReadAction = markAllRoomsAsReadAction, isEnterpriseBuild = isEnterpriseBuild, showColorPicker = showColorPicker, eventSink = eventSink, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 3adf9a13de..45009d1f71 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -18,9 +18,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme import io.element.android.features.preferences.impl.R import io.element.android.features.preferences.impl.developer.appsettings.AppDeveloperSettingsView import io.element.android.libraries.designsystem.components.ProgressDialog +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferencePage @@ -45,6 +47,15 @@ fun DeveloperSettingsView( if (state.showLoader) { ProgressDialog() } + if (state.markAllRoomsAsReadAction.isConfirming()) { + ConfirmationDialog( + title = "Are you sure you want to mark all the rooms as read?", + content = "", + submitText = stringResource(CommonStrings.action_yes), + onSubmitClick = { state.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = false)) }, + onDismiss = { state.eventSink(DeveloperSettingsEvents.DismissMarkAllRoomsAsReadConfirmation) }, + ) + } BackHandler( enabled = !state.showLoader, onBack = onBackClick, @@ -64,6 +75,7 @@ fun DeveloperSettingsView( onOpenShowkase = onOpenShowkase, ) NotificationCategory(onPushHistoryClick) + MarkAllRoomsAsReadCategory(state) if (state.isEnterpriseBuild) { PreferenceCategory(title = "Theme") { @@ -152,6 +164,32 @@ fun DeveloperSettingsView( ) } +@Composable +private fun MarkAllRoomsAsReadCategory(state: DeveloperSettingsState) { + PreferenceCategory(title = "Room list") { + ListItem( + headlineContent = { + Text("Mark all rooms as read") + }, + supportingContent = { + Text( + text = """ + This will send a private read receipt and a read marker in every room you are part of. + It's a long running operation that might get rate limited. + It will run in the background but the app must be alive for it to finish. + """.trimIndent(), + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + }, + enabled = !state.showLoader, + onClick = { + state.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = true)) + }, + ) + } +} + @Composable private fun NotificationCategory(onPushHistoryClick: () -> Unit) { PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_title)) { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsRead.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsRead.kt new file mode 100644 index 0000000000..d09ac11dd5 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsRead.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.api.notifications.NotificationCleaner +import kotlinx.coroutines.withContext + +interface MarkAllRoomsAsRead { + suspend operator fun invoke(): Result +} + +@ContributesBinding(SessionScope::class) +class DefaultMarkAllRoomsAsRead( + private val client: MatrixClient, + private val notificationCleaner: NotificationCleaner, + private val coroutineDispatchers: CoroutineDispatchers, +) : MarkAllRoomsAsRead { + override suspend fun invoke(): Result = withContext(coroutineDispatchers.io) { + client.markAllRoomsAsRead() + .onSuccess { + notificationCleaner.clearAllMessagesEvents(client.sessionId) + } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsRead.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsRead.kt new file mode 100644 index 0000000000..072fa1ea87 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsRead.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.push.api.notifications.NotificationCleaner +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +interface MarkRoomAsRead { + suspend operator fun invoke(roomId: RoomId): Result +} + +@ContributesBinding(SessionScope::class) +class DefaultMarkRoomAsRead( + private val client: MatrixClient, + private val notificationCleaner: NotificationCleaner, + private val sessionPreferencesStore: SessionPreferencesStore, + private val coroutineDispatchers: CoroutineDispatchers, +) : MarkRoomAsRead { + override suspend fun invoke(roomId: RoomId): Result = withContext(coroutineDispatchers.io) { + notificationCleaner.clearMessagesForRoom(client.sessionId, roomId) + val room = client.getRoom(roomId) ?: return@withContext Result.failure(IllegalStateException("Room not found")) + room.use { + it.setUnreadFlag(isUnread = false) + val receiptType = if (sessionPreferencesStore.isSendPublicReadReceiptsEnabled().first()) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE + } + it.markAsRead(receiptType) + } + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt index ec70b19eab..52d1247668 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsPresenterTest.kt @@ -17,6 +17,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.preferences.impl.developer.appsettings.anAppDeveloperSettingsState import io.element.android.features.preferences.impl.tasks.FakeClearCacheUseCase import io.element.android.features.preferences.impl.tasks.FakeComputeCacheSizeUseCase +import io.element.android.features.preferences.impl.tasks.FakeMarkAllRoomsAsRead import io.element.android.features.preferences.impl.tasks.VacuumStoresUseCase import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.architecture.AsyncAction @@ -127,6 +128,28 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - confirm mark all rooms as read`() = runTest { + val markAllRoomsAsRead = FakeMarkAllRoomsAsRead() + val presenter = createDeveloperSettingsPresenter(markAllRoomsAsRead = markAllRoomsAsRead) + presenter.test { + skipItems(2) + val initialState = awaitItem() + initialState.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = true)) + val stateWithConfirmation = awaitItem() + assertThat(stateWithConfirmation.markAllRoomsAsReadAction.isConfirming()).isTrue() + stateWithConfirmation.eventSink(DeveloperSettingsEvents.MarkAllRoomsAsRead(needsConfirmation = false)) + awaitItem().also { state -> + assertThat(state.markAllRoomsAsReadAction.isConfirming()).isFalse() + assertThat(state.markAllRoomsAsReadAction).isInstanceOf(AsyncAction.Loading::class.java) + } + awaitItem().also { state -> + assertThat(state.markAllRoomsAsReadAction).isInstanceOf(AsyncAction.Success::class.java) + assertThat(markAllRoomsAsRead.invokeCallCount).isEqualTo(1) + } + } + } + @Test fun `present - VacuumStores action invokes the VacuumStoresUseCase`() = runTest { var vacuumCalled = false @@ -151,6 +174,7 @@ class DeveloperSettingsPresenterTest { enterpriseService: EnterpriseService = FakeEnterpriseService(), vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {}, databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, + markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(), ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() }, @@ -161,6 +185,7 @@ class DeveloperSettingsPresenterTest { vacuumStoresUseCase = vacuumStoresUseCase, databaseSizesUseCase = databaseSizesUseCase, fileSizeFormatter = FakeFileSizeFormatter(), + markAllRoomsAsRead = markAllRoomsAsRead, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index cc9d3cdf8f..adae2ca816 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -71,7 +71,7 @@ class DeveloperSettingsViewTest : RobolectricTest() { } } - @Config(qualifiers = "h2200dp") + @Config(qualifiers = "h2400dp") @Test fun `clicking on clear cache emits the expected event`() = runAndroidComposeUiTest { val eventsRecorder = EventsRecorder() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkAllRoomsAsRead.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkAllRoomsAsRead.kt new file mode 100644 index 0000000000..cbb5992b77 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkAllRoomsAsRead.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import io.element.android.tests.testutils.simulateLongTask + +class FakeMarkAllRoomsAsRead( + private val invokeLambda: suspend () -> Result = { Result.success(Unit) }, +) : MarkAllRoomsAsRead { + var invokeCallCount = 0 + private set + + override suspend fun invoke(): Result = simulateLongTask { + invokeCallCount++ + invokeLambda() + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkRoomAsRead.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkRoomAsRead.kt new file mode 100644 index 0000000000..3c1a3687bc --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/FakeMarkRoomAsRead.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import io.element.android.libraries.matrix.api.core.RoomId + +class FakeMarkRoomAsRead( + private val invokeLambda: suspend (RoomId) -> Result = { Result.success(Unit) }, +) : MarkRoomAsRead { + val invokedRoomIds = mutableListOf() + + override suspend fun invoke(roomId: RoomId): Result { + invokedRoomIds.add(roomId) + return invokeLambda(roomId) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsReadTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsReadTest.kt new file mode 100644 index 0000000000..4079d19564 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkAllRoomsAsReadTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.preferences.impl.tasks + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MarkAllRoomsAsReadTest { + @Test + fun `invoke - delegates to client and clears all notifications`() = runTest { + val markAllRoomsAsReadLambda = lambdaRecorder> { Result.success(Unit) } + val clearAllMessagesEventsLambda = lambdaRecorder { } + val markAllRoomsAsRead = DefaultMarkAllRoomsAsRead( + client = FakeMatrixClient( + markAllRoomsAsReadResult = markAllRoomsAsReadLambda, + ), + notificationCleaner = FakeNotificationCleaner( + clearAllMessagesEventsLambda = clearAllMessagesEventsLambda, + ), + coroutineDispatchers = testCoroutineDispatchers(), + ) + + val result = markAllRoomsAsRead() + + assertThat(result.isSuccess).isTrue() + markAllRoomsAsReadLambda.assertions().isCalledOnce() + clearAllMessagesEventsLambda.assertions().isCalledOnce().with(value(A_SESSION_ID)) + } + + @Test + fun `invoke - does not clear notifications when client fails`() = runTest { + val clearAllMessagesEventsLambda = lambdaRecorder { } + val markAllRoomsAsRead = DefaultMarkAllRoomsAsRead( + client = FakeMatrixClient( + markAllRoomsAsReadResult = { Result.failure(IllegalStateException("Failed")) }, + ), + notificationCleaner = FakeNotificationCleaner( + clearAllMessagesEventsLambda = clearAllMessagesEventsLambda, + ), + coroutineDispatchers = testCoroutineDispatchers(), + ) + + val result = markAllRoomsAsRead() + + assertThat(result.isFailure).isTrue() + clearAllMessagesEventsLambda.assertions().isNeverCalled() + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsReadTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsReadTest.kt new file mode 100644 index 0000000000..301b18b285 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/tasks/MarkRoomAsReadTest.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.preferences.impl.tasks + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class MarkRoomAsReadTest { + @Test + fun `invoke - clears notifications, clears unread flag and sends public read receipt`() = runTest { + val markAsReadResult = lambdaRecorder> { Result.success(Unit) } + val room = FakeBaseRoom(markAsReadResult = markAsReadResult) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val clearMessagesForRoomLambda = lambdaRecorder { _: SessionId, _: RoomId -> } + val notificationCleaner = FakeNotificationCleaner(clearMessagesForRoomLambda = clearMessagesForRoomLambda) + val sessionPreferencesStore = InMemorySessionPreferencesStore() + val markRoomAsRead = DefaultMarkRoomAsRead( + client = matrixClient, + notificationCleaner = notificationCleaner, + sessionPreferencesStore = sessionPreferencesStore, + coroutineDispatchers = testCoroutineDispatchers(), + ) + + val result = markRoomAsRead(A_ROOM_ID) + runCurrent() + + assertThat(result.isSuccess).isTrue() + clearMessagesForRoomLambda.assertions().isCalledOnce() + .with(value(A_SESSION_ID), value(A_ROOM_ID)) + assertThat(room.setUnreadFlagCalls).isEqualTo(listOf(false)) + markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ)) + } + + @Test + fun `invoke - sends private read receipt when public receipts are disabled`() = runTest { + val markAsReadResult = lambdaRecorder> { Result.success(Unit) } + val room = FakeBaseRoom(markAsReadResult = markAsReadResult) + val matrixClient = FakeMatrixClient().apply { + givenGetRoomResult(A_ROOM_ID, room) + } + val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = false) + val markRoomAsRead = DefaultMarkRoomAsRead( + client = matrixClient, + notificationCleaner = FakeNotificationCleaner(clearMessagesForRoomLambda = { _, _ -> }), + sessionPreferencesStore = sessionPreferencesStore, + coroutineDispatchers = testCoroutineDispatchers(), + ) + + val result = markRoomAsRead(A_ROOM_ID) + runCurrent() + + assertThat(result.isSuccess).isTrue() + markAsReadResult.assertions().isCalledOnce().with(value(ReceiptType.READ_PRIVATE)) + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 8f482440e8..6546e4c41a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -203,6 +203,12 @@ interface MatrixClient { */ suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result + /** + * Mark all joined rooms as read by sending public, private and fully-read receipts + * on each room's latest event. Per-room errors are logged and skipped by the SDK. + */ + suspend fun markAllRoomsAsRead(): Result + /** * Check if linking a new device using QrCode is supported by the server. */ diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 9167b3e2f3..2cfbbbdc73 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -800,6 +800,12 @@ class RustMatrixClient( } } + override suspend fun markAllRoomsAsRead(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.markAllRoomsAsRead() + } + } + override suspend fun performDatabaseVacuum(): Result = withContext(sessionDispatcher) { runCatchingExceptions { Timber.d("Performing database vacuuming for session $sessionId...") diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index cce99e9d09..2397f12443 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -117,6 +117,7 @@ class FakeMatrixClient( private val getRecentEmojisLambda: () -> Result> = { Result.success(emptyList()) }, private val addRecentEmojiLambda: (String) -> Result = { Result.success(Unit) }, private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, + private val markAllRoomsAsReadResult: () -> Result = { Result.success(Unit) }, private val performDatabaseVacuumLambda: () -> Result = { lambdaError() }, private val getMapStyleUrlResult: () -> Result = { lambdaError() }, private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, @@ -373,6 +374,10 @@ class FakeMatrixClient( return markRoomAsFullyReadResult(roomId, eventId) } + override suspend fun markAllRoomsAsRead(): Result { + return markAllRoomsAsReadResult() + } + override suspend fun performDatabaseVacuum(): Result { return performDatabaseVacuumLambda() }