From 25a033d61ef25ebbbc82881aeea8429acad7d8fa Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 24 Oct 2025 02:42:00 +0000 Subject: [PATCH 1/8] fix(deps): update dependencyanalysis to v3.3.0 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c048aa6fb0..65877ff4ab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ telephoto = "0.18.0" haze = "1.6.10" # Dependency analysis -dependencyAnalysis = "3.2.0" +dependencyAnalysis = "3.3.0" # DI metro = "0.7.2" From 212084866c80212e1d0ed77d4609d2cdc530da72 Mon Sep 17 00:00:00 2001 From: ganfra Date: Mon, 27 Oct 2025 17:46:16 +0100 Subject: [PATCH 2/8] feature(space): add view members entry --- .../room/joined/JoinedRoomLoadedFlowNode.kt | 24 ++++++++++------ .../roomdetails/api/RoomDetailsEntryPoint.kt | 3 ++ .../impl/DefaultRoomDetailsEntryPoint.kt | 1 + .../features/space/api/SpaceEntryPoint.kt | 2 ++ .../features/space/impl/SpaceFlowNode.kt | 4 +++ .../features/space/impl/root/SpaceNode.kt | 5 ++++ .../features/space/impl/root/SpaceView.kt | 28 +++++++++++++++---- .../space/impl/DefaultSpaceEntryPointTest.kt | 1 + .../features/space/impl/root/SpaceViewTest.kt | 2 ++ 9 files changed, 57 insertions(+), 13 deletions(-) diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt index 93e0c64a7f..aab9590a9b 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomLoadedFlowNode.kt @@ -22,8 +22,6 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.appnav.di.RoomGraphFactory import io.element.android.appnav.room.RoomNavigationTarget -import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode.Inputs -import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode.NavTarget import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.space.api.SpaceEntryPoint @@ -154,6 +152,9 @@ class JoinedRoomLoadedFlowNode( NavTarget.RoomNotificationSettings -> { createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings) } + NavTarget.RoomMemberList -> { + createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberList) + } NavTarget.Space -> { createSpaceNode(buildContext) } @@ -169,6 +170,10 @@ class JoinedRoomLoadedFlowNode( override fun onOpenDetails() { backstack.push(NavTarget.RoomDetails) } + + override fun onOpenMemberList() { + backstack.push(NavTarget.RoomMemberList) + } } return spaceEntryPoint.nodeBuilder(this, buildContext) .inputs(SpaceEntryPoint.Inputs(roomId = inputs.room.roomId)) @@ -216,6 +221,9 @@ class JoinedRoomLoadedFlowNode( @Parcelize data object RoomDetails : NavTarget + @Parcelize + data object RoomMemberList : NavTarget + @Parcelize data class RoomMemberDetails(val userId: UserId) : NavTarget @@ -229,17 +237,17 @@ class JoinedRoomLoadedFlowNode( } } -private fun initialElement(plugins: List): NavTarget { - val input = plugins.filterIsInstance().single() +private fun initialElement(plugins: List): JoinedRoomLoadedFlowNode.NavTarget { + val input = plugins.filterIsInstance().single() return when (input.initialElement) { is RoomNavigationTarget.Root -> { if (input.room.roomInfoFlow.value.isSpace) { - NavTarget.Space + JoinedRoomLoadedFlowNode.NavTarget.Space } else { - NavTarget.Messages(input.initialElement.eventId) + JoinedRoomLoadedFlowNode.NavTarget.Messages(input.initialElement.eventId) } } - RoomNavigationTarget.Details -> NavTarget.RoomDetails - RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings + RoomNavigationTarget.Details -> JoinedRoomLoadedFlowNode.NavTarget.RoomDetails + RoomNavigationTarget.NotificationSettings -> JoinedRoomLoadedFlowNode.NavTarget.RoomNotificationSettings } } diff --git a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt index 803002d642..0d725938f6 100644 --- a/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt +++ b/features/roomdetails/api/src/main/kotlin/io/element/android/features/roomdetails/api/RoomDetailsEntryPoint.kt @@ -23,6 +23,9 @@ interface RoomDetailsEntryPoint : FeatureEntryPoint { @Parcelize data object RoomDetails : InitialTarget + @Parcelize + data object RoomMemberList : InitialTarget + @Parcelize data class RoomMemberDetails(val roomMemberId: UserId) : InitialTarget diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt index 0ae2dc09ea..5679b8c361 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/DefaultRoomDetailsEntryPoint.kt @@ -44,4 +44,5 @@ internal fun InitialTarget.toNavTarget() = when (this) { is InitialTarget.RoomDetails -> NavTarget.RoomDetails is InitialTarget.RoomMemberDetails -> NavTarget.RoomMemberDetails(roomMemberId) is InitialTarget.RoomNotificationSettings -> NavTarget.RoomNotificationSettings(showUserDefinedSettingStyle = true) + InitialTarget.RoomMemberList -> NavTarget.RoomMemberList } diff --git a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt index a5f1134bf3..9dd61c6dff 100644 --- a/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt +++ b/features/space/api/src/main/kotlin/io/element/android/features/space/api/SpaceEntryPoint.kt @@ -33,5 +33,7 @@ interface SpaceEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onOpenRoom(roomId: RoomId, viaParameters: List) fun onOpenDetails() + + fun onOpenMemberList() } } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt index 833186958b..f73a3d31ff 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/SpaceFlowNode.kt @@ -88,6 +88,10 @@ class SpaceFlowNode( callback.onOpenDetails() } + override fun onOpenMemberList() { + callback.onOpenMemberList() + } + override fun onLeaveSpace() { backstack.push(NavTarget.Leave) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt index 5266ba9f46..f02107dad2 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt @@ -42,6 +42,8 @@ class SpaceNode( interface Callback : Plugin { fun onOpenRoom(roomId: RoomId, viaParameters: List) fun onOpenDetails() + + fun onOpenMemberList() fun onLeaveSpace() } @@ -83,6 +85,9 @@ class SpaceNode( onShareSpace = { onShareRoom(context) }, + onViewMembersClick = { + callback.onOpenMemberList() + }, acceptDeclineInviteView = { acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt index b32f9b73e1..f4c415b718 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceView.kt @@ -78,6 +78,7 @@ fun SpaceView( onShareSpace: () -> Unit, onLeaveSpaceClick: () -> Unit, onDetailsClick: () -> Unit, + onViewMembersClick: () -> Unit, modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { @@ -89,7 +90,8 @@ fun SpaceView( onBackClick = onBackClick, onLeaveSpaceClick = onLeaveSpaceClick, onShareSpace = onShareSpace, - onDetailsClick = onDetailsClick + onDetailsClick = onDetailsClick, + onViewMembersClick = onViewMembersClick, ) }, content = { padding -> @@ -256,6 +258,7 @@ private fun SpaceViewTopBar( onLeaveSpaceClick: () -> Unit, onDetailsClick: () -> Unit, onShareSpace: () -> Unit, + onViewMembersClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( @@ -304,6 +307,20 @@ private fun SpaceViewTopBar( ) } ) + DropdownMenuItem( + onClick = { + showMenu = false + onViewMembersClick() + }, + text = { Text(stringResource(id = CommonStrings.screen_space_menu_action_members)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.User(), + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) DropdownMenuItem( onClick = { showMenu = false @@ -344,10 +361,10 @@ private fun SpaceAvatarAndNameRow( ) Text( modifier = Modifier - .padding(horizontal = 8.dp) - .semantics { - heading() - }, + .padding(horizontal = 8.dp) + .semantics { + heading() + }, text = name ?: stringResource(CommonStrings.common_no_space_name), style = ElementTheme.typography.fontBodyLgMedium, fontStyle = FontStyle.Italic.takeIf { name == null }, @@ -403,6 +420,7 @@ internal fun SpaceViewPreview( onLeaveSpaceClick = {}, acceptDeclineInviteView = {}, onDetailsClick = {}, + onViewMembersClick = {}, onBackClick = {}, ) } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt index 13e24259dc..2d43ef0d39 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/DefaultSpaceEntryPointTest.kt @@ -46,6 +46,7 @@ class DefaultSpaceEntryPointTest { val callback = object : SpaceEntryPoint.Callback { override fun onOpenRoom(roomId: RoomId, viaParameters: List) = lambdaError() override fun onOpenDetails() = lambdaError() + override fun onOpenMemberList() = lambdaError() } val result = entryPoint.nodeBuilder(parentNode, BuildContext.root(null)) .inputs(nodeInputs) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt index f8797916e4..44a06d378f 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt @@ -140,6 +140,7 @@ private fun AndroidComposeTestRule.setSpace onShareSpace: () -> Unit = EnsureNeverCalled(), onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(), onDetailsClick: () -> Unit = EnsureNeverCalled(), + onViewMembersClick: () -> Unit = EnsureNeverCalled(), acceptDeclineInviteView: @Composable () -> Unit = {}, ) { setContent { @@ -150,6 +151,7 @@ private fun AndroidComposeTestRule.setSpace onShareSpace = onShareSpace, onLeaveSpaceClick = onLeaveSpaceClick, onDetailsClick = onDetailsClick, + onViewMembersClick = onViewMembersClick, acceptDeclineInviteView = acceptDeclineInviteView, ) } From 63d4f7322e4aad3a0e491f411292eca45794c0d3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Oct 2025 15:49:23 +0000 Subject: [PATCH 3/8] fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.10.28 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70d8307266..dcd8b6a8b5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -171,7 +171,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.13" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.10.28" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } From 003af22c3170a60b70785c28ceccf50901bafa92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Tue, 28 Oct 2025 17:12:40 +0100 Subject: [PATCH 4/8] Fix api breaks: - `ComposerDraft` now takes a list of media attachments. - `HumanQrLoginException` has a couple of new cases. - `Client.loginWithQrCode` now returns a `LoginWithQrCodeHandle`, which we need to call using `scan` to have the same behaviour as before. --- .../matrix/api/auth/qrlogin/QrLoginException.kt | 2 ++ .../impl/auth/RustMatrixAuthenticationService.kt | 9 ++++++--- .../matrix/impl/auth/qrlogin/QrErrorMapper.kt | 2 ++ .../matrix/impl/room/draft/ComposerDraftMapper.kt | 4 +++- settings.gradle.kts | 13 +++++++------ 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt index ab278abd56..6a2871dc56 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/qrlogin/QrLoginException.kt @@ -17,4 +17,6 @@ sealed class QrLoginException : Exception() { data object SlidingSyncNotAvailable : QrLoginException() data object OtherDeviceNotSignedIn : QrLoginException() data object Unknown : QrLoginException() + data object CheckCodeAlreadySent : QrLoginException() + data object CheckCodeCannotBeSent : QrLoginException() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 6342721593..408af2eb0a 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -289,10 +289,13 @@ class RustMatrixAuthenticationService( qrCodeData = sdkQrCodeLoginData, ) client.loginWithQrCode( - qrCodeData = qrCodeData.rustQrCodeData, oidcConfiguration = oidcConfiguration, - progressListener = progressListener, - ) + ).use { + it.scan( + qrCodeData = qrCodeData.rustQrCodeData, + progressListener = progressListener, + ) + } // Ensure that the user is not already logged in with the same account ensureNotAlreadyLoggedIn(client) val sessionData = client.session() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt index 7b1c614bec..9b7a19aea4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/QrErrorMapper.kt @@ -42,5 +42,7 @@ object QrErrorMapper { is RustHumanQrLoginException.Unknown -> QrLoginException.Unknown is RustHumanQrLoginException.OidcMetadataInvalid -> QrLoginException.OidcMetadataInvalid is RustHumanQrLoginException.SlidingSyncNotAvailable -> QrLoginException.SlidingSyncNotAvailable + is RustHumanQrLoginException.CheckCodeAlreadySent -> QrLoginException.CheckCodeAlreadySent + is RustHumanQrLoginException.CheckCodeCannotBeSent -> QrLoginException.CheckCodeCannotBeSent } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/draft/ComposerDraftMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/draft/ComposerDraftMapper.kt index 1bdadb96fc..d4435b24a1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/draft/ComposerDraftMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/draft/ComposerDraftMapper.kt @@ -17,7 +17,9 @@ internal fun ComposerDraft.into(): RustComposerDraft { return RustComposerDraft( plainText = plainText, htmlText = htmlText, - draftType = draftType.into() + draftType = draftType.into(), + // TODO add media attachments to the draft + attachments = emptyList(), ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9245034508..16f24f3a10 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -5,11 +5,9 @@ * Please see LICENSE files in the repository root for full details. */ -import java.net.URI - pluginManagement { repositories { - includeBuild("plugins") + includeBuild("plugins") gradlePluginPortal() google() mavenCentral() @@ -18,14 +16,17 @@ pluginManagement { dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { - google() - mavenCentral() maven { - url = URI("https://www.jitpack.io") + url = uri("https://www.jitpack.io") content { includeModule("com.github.matrix-org", "matrix-analytics-events") } } + google() + mavenCentral() + maven { + url = uri("https://repo1.maven.org/maven2/") + } flatDir { dirs("libraries/matrix/libs") } From 08f75dda4c8cdd8a34a740093aa7695dbbfbe1a6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 28 Oct 2025 18:25:51 +0100 Subject: [PATCH 5/8] Confirm exit without saving change in room details edit screen (#5618) * Room details edit screen: add confirmation dialog when leaving without saving pending changes. * Improve preview coverage. * Update screenshots * Introduce AsyncAction.ConfirmingCancellation and use it for leaving room edition without saving change. * Fix issue in comment * Use new `ConfirmingCancellation` object in Change Roles screen --------- Co-authored-by: ElementBot --- .../impl/ChangeRolesEvent.kt | 4 +- .../impl/ChangeRolesPresenter.kt | 42 +++---- .../impl/ChangeRolesState.kt | 11 +- .../impl/ChangeRolesStateProvider.kt | 8 +- .../impl/ChangeRolesView.kt | 103 +++++++++--------- .../impl/ChangeRolesPresenterTest.kt | 37 +++---- .../impl/ChangeRolesViewTest.kt | 10 +- .../impl/edit/RoomDetailsEditEvents.kt | 3 +- .../impl/edit/RoomDetailsEditNode.kt | 3 +- .../impl/edit/RoomDetailsEditPresenter.kt | 8 +- .../impl/edit/RoomDetailsEditStateProvider.kt | 1 + .../impl/edit/RoomDetailsEditView.kt | 36 ++++-- .../members/RoomMemberListStateProvider.kt | 3 +- .../impl/edit/RoomDetailsEditPresenterTest.kt | 79 +++++++++++++- .../impl/edit/RoomDetailsEditViewTest.kt | 56 +++++++--- .../libraries/architecture/AsyncAction.kt | 5 + ...impl.edit_RoomDetailsEditView_Day_8_en.png | 3 + ...pl.edit_RoomDetailsEditView_Night_8_en.png | 3 + ...pl.members_RoomMemberListView_Day_1_en.png | 4 +- ....members_RoomMemberListView_Night_1_en.png | 4 +- 20 files changed, 270 insertions(+), 153 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Night_8_en.png diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesEvent.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesEvent.kt index ab8dbc8f22..56e1c50bcc 100644 --- a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesEvent.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesEvent.kt @@ -15,7 +15,5 @@ sealed interface ChangeRolesEvent { data class UserSelectionToggled(val matrixUser: MatrixUser) : ChangeRolesEvent data object Save : ChangeRolesEvent data object Exit : ChangeRolesEvent - data object CancelExit : ChangeRolesEvent - data object ClearError : ChangeRolesEvent - data object CancelSave : ChangeRolesEvent + data object CloseDialog : ChangeRolesEvent } diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenter.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenter.kt index 632d93f0e0..b6d865a66a 100644 --- a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenter.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenter.kt @@ -69,20 +69,19 @@ class ChangeRolesPresenter( val selectedUsers = remember { mutableStateOf>(persistentListOf()) } - val exitState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } - val saveState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } + val saveState: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val usersWithRole = produceState>(initialValue = persistentListOf()) { room.usersWithRole(role).map { members -> members.map { it.toMatrixUser() } } - .onEach { users -> - val previous = value - value = users.toImmutableList() - // Users who were selected but didn't have the role, so their role change was pending - val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } } - // Users who no longer have the role - val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet() - selectedUsers.value = (users + toAdd - toRemove).toImmutableList() - } - .launchIn(this) + .onEach { users -> + val previous = value + value = users.toImmutableList() + // Users who were selected but didn't have the role, so their role change was pending + val toAdd = selectedUsers.value.filter { user -> users.none { it.userId == user.userId } && previous.none { it.userId == user.userId } } + // Users who no longer have the role + val toRemove = previous.filter { user -> users.none { it.userId == user.userId } }.toSet() + selectedUsers.value = (users + toAdd - toRemove).toImmutableList() + } + .launchIn(this) } val roomMemberState by room.membersStateFlow.collectAsState() @@ -147,22 +146,16 @@ class ChangeRolesPresenter( } } } - is ChangeRolesEvent.ClearError -> { - saveState.value = AsyncAction.Uninitialized - } is ChangeRolesEvent.Exit -> { - exitState.value = if (exitState.value.isUninitialized() && hasPendingChanges) { + saveState.value = if (saveState.value.isUninitialized() && hasPendingChanges) { // Has pending changes, confirm exit - AsyncAction.ConfirmingNoParams + AsyncAction.ConfirmingCancellation } else { // No pending changes, exit immediately - AsyncAction.Success(Unit) + AsyncAction.Success(false) } } - is ChangeRolesEvent.CancelExit -> { - exitState.value = AsyncAction.Uninitialized - } - is ChangeRolesEvent.CancelSave -> { + is ChangeRolesEvent.CloseDialog -> { saveState.value = AsyncAction.Uninitialized } } @@ -174,7 +167,6 @@ class ChangeRolesPresenter( searchResults = searchResults, selectedUsers = selectedUsers.value, hasPendingChanges = hasPendingChanges, - exitState = exitState.value, savingState = saveState.value, canChangeMemberRole = ::canChangeMemberRole, eventSink = ::handleEvent, @@ -198,7 +190,7 @@ class ChangeRolesPresenter( private fun CoroutineScope.save( usersWithRole: ImmutableList, selectedUsers: MutableState>, - saveState: MutableState>, + saveState: MutableState>, ) = launch { saveState.value = AsyncAction.Loading @@ -221,7 +213,7 @@ class ChangeRolesPresenter( saveState.value = AsyncAction.Failure(it) } .onSuccess { - saveState.value = AsyncAction.Success(Unit) + saveState.value = AsyncAction.Success(true) // Asynchronously reload the room members launch { room.updateMembers() } } diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesState.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesState.kt index 027ef76e69..e0b3e68a9e 100644 --- a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesState.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesState.kt @@ -23,8 +23,7 @@ data class ChangeRolesState( val searchResults: SearchBarResultState, val selectedUsers: ImmutableList, val hasPendingChanges: Boolean, - val exitState: AsyncAction, - val savingState: AsyncAction, + val savingState: AsyncAction, val canChangeMemberRole: (UserId) -> Boolean, val eventSink: (ChangeRolesEvent) -> Unit, ) @@ -36,10 +35,10 @@ data class MembersByRole( val members: ImmutableList, ) { constructor(members: List) : this( - owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(), - admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(), - moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(), - members = members.filter { it.role == RoomMember.Role.User }.sorted(), + owners = members.filter { it.role is RoomMember.Role.Owner }.sorted(), + admins = members.filter { it.role == RoomMember.Role.Admin }.sorted(), + moderators = members.filter { it.role == RoomMember.Role.Moderator }.sorted(), + members = members.filter { it.role == RoomMember.Role.User }.sorted(), ) fun isEmpty() = owners.isEmpty() && admins.isEmpty() && moderators.isEmpty() && members.isEmpty() diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesStateProvider.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesStateProvider.kt index 2041c0f447..b54347f73f 100644 --- a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesStateProvider.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesStateProvider.kt @@ -38,10 +38,10 @@ class ChangeRolesStateProvider : PreviewParameterProvider { searchResults = SearchBarResultState.Results(MembersByRole(aRoomMemberList().take(1).toImmutableList())), selectedUsers = aMatrixUserList().take(1).toImmutableList(), ), - aChangeRolesStateWithSelectedUsers().copy(exitState = AsyncAction.ConfirmingNoParams), + aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingCancellation), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.ConfirmingNoParams), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Loading), - aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(Unit)), + aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Success(true)), aChangeRolesStateWithSelectedUsers().copy(savingState = AsyncAction.Failure(Exception("boom"))), aChangeRolesStateWithOwners(), aChangeRolesStateWithOwners().copy(role = RoomMember.Role.Owner(isCreator = false)), @@ -55,8 +55,7 @@ internal fun aChangeRolesState( searchResults: SearchBarResultState = SearchBarResultState.NoResultsFound(), selectedUsers: ImmutableList = persistentListOf(), hasPendingChanges: Boolean = false, - exitState: AsyncAction = AsyncAction.Uninitialized, - savingState: AsyncAction = AsyncAction.Uninitialized, + savingState: AsyncAction = AsyncAction.Uninitialized, canRemoveMember: (UserId) -> Boolean = { true }, eventSink: (ChangeRolesEvent) -> Unit = {}, ) = ChangeRolesState( @@ -66,7 +65,6 @@ internal fun aChangeRolesState( searchResults = searchResults, selectedUsers = selectedUsers, hasPendingChanges = hasPendingChanges, - exitState = exitState, savingState = savingState, canChangeMemberRole = canRemoveMember, eventSink = eventSink, diff --git a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesView.kt b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesView.kt index c6b70a82f4..f9ebd75ca2 100644 --- a/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesView.kt +++ b/features/changeroommemberroles/impl/src/main/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesView.kt @@ -29,7 +29,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment @@ -41,7 +40,6 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost @@ -52,7 +50,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarType import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog -import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Checkbox @@ -172,61 +169,59 @@ fun ChangeRolesView( AsyncIndicatorHost(modifier = Modifier.statusBarsPadding(), asyncIndicatorState) AsyncActionView( - async = state.exitState, - onSuccess = { latestNavigateUp() }, - confirmationDialog = { - ConfirmationDialog( - title = stringResource(CommonStrings.dialog_unsaved_changes_title), - content = stringResource(CommonStrings.dialog_unsaved_changes_description_android), - onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) }, - onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) } - ) - }, - onErrorDismiss = { /* Cannot happen */ }, - ) - - when (state.savingState) { - is AsyncAction.Confirming -> { - when (state.role) { - is RoomMember.Role.Owner -> { - ConfirmationDialog( - title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title), - content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description), - submitText = stringResource(CommonStrings.action_continue), - onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, - onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) }, - destructiveSubmit = true, - ) - } - is RoomMember.Role.Admin -> { - ConfirmationDialog( - title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title), - content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description), - onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, - onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) } - ) - } - else -> Unit // No confirmation needed for Moderator or User roles - } - } - is AsyncAction.Loading -> { - ProgressDialog() - } - is AsyncAction.Failure -> { - ErrorDialog( - content = stringResource(CommonStrings.error_unknown), - onSubmit = { state.eventSink(ChangeRolesEvent.ClearError) } - ) - } - is AsyncAction.Success -> { - LaunchedEffect(state.savingState) { + async = state.savingState, + onSuccess = { changeSaved -> + if (changeSaved) { asyncIndicatorState.enqueue(durationMs = AsyncIndicator.DURATION_SHORT) { AsyncIndicator.Custom(text = stringResource(CommonStrings.common_saved_changes)) } + } else { + latestNavigateUp() } - } - else -> Unit - } + }, + confirmationDialog = { confirming -> + when (confirming) { + is AsyncAction.ConfirmingCancellation -> { + ConfirmationDialog( + title = stringResource(CommonStrings.dialog_unsaved_changes_title), + content = stringResource(CommonStrings.dialog_unsaved_changes_description_android), + onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) }, + onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) } + ) + } + else -> { + when (state.role) { + is RoomMember.Role.Owner -> { + ConfirmationDialog( + title = stringResource(R.string.screen_room_change_role_confirm_change_owners_title), + content = stringResource(R.string.screen_room_change_role_confirm_change_owners_description), + submitText = stringResource(CommonStrings.action_continue), + onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, + onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }, + destructiveSubmit = true, + ) + } + is RoomMember.Role.Admin -> { + ConfirmationDialog( + title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title), + content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description), + onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, + onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) } + ) + } + // No confirmation needed for Moderator or User roles + else -> Unit + } + } + } + }, + errorMessage = { + stringResource(CommonStrings.error_unknown) + }, + onErrorDismiss = { + state.eventSink(ChangeRolesEvent.CloseDialog) + }, + ) } } diff --git a/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenterTest.kt b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenterTest.kt index 41b6acd60c..a5e4cfaccb 100644 --- a/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenterTest.kt +++ b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesPresenterTest.kt @@ -52,7 +52,6 @@ class ChangeRolesPresenterTest { assertThat(searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) assertThat(selectedUsers).isEmpty() assertThat(hasPendingChanges).isFalse() - assertThat(exitState).isEqualTo(AsyncAction.Uninitialized) assertThat(savingState).isEqualTo(AsyncAction.Uninitialized) } cancelAndIgnoreRemainingEvents() @@ -266,7 +265,7 @@ class ChangeRolesPresenterTest { } @Test - fun `present - Exit will display success if no pending changes`() = runTest { + fun `present - Exit will display success false if no pending changes`() = runTest { val room = FakeJoinedRoom().apply { givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) @@ -278,15 +277,15 @@ class ChangeRolesPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.hasPendingChanges).isFalse() - assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized) initialState.eventSink(ChangeRolesEvent.Exit) - assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit)) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false)) } } @Test - fun `present - CancelExit will remove exit confirmation`() = runTest { + fun `present - CloseDialog will remove exit confirmation`() = runTest { val room = FakeJoinedRoom().apply { givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) @@ -298,16 +297,16 @@ class ChangeRolesPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.hasPendingChanges).isFalse() - assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized) initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) awaitItem().eventSink(ChangeRolesEvent.Exit) val confirmingState = awaitItem() - assertThat(confirmingState.exitState).isEqualTo(AsyncAction.ConfirmingNoParams) + assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingCancellation) - confirmingState.eventSink(ChangeRolesEvent.CancelExit) - assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Uninitialized) + confirmingState.eventSink(ChangeRolesEvent.CloseDialog) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) } } @@ -324,7 +323,7 @@ class ChangeRolesPresenterTest { skipItems(1) val initialState = awaitItem() assertThat(initialState.hasPendingChanges).isFalse() - assertThat(initialState.exitState).isEqualTo(AsyncAction.Uninitialized) + assertThat(initialState.savingState).isEqualTo(AsyncAction.Uninitialized) initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) val updatedState = awaitItem() @@ -332,10 +331,10 @@ class ChangeRolesPresenterTest { skipItems(1) updatedState.eventSink(ChangeRolesEvent.Exit) - assertThat(awaitItem().exitState).isEqualTo(AsyncAction.ConfirmingNoParams) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.ConfirmingCancellation) updatedState.eventSink(ChangeRolesEvent.Exit) - assertThat(awaitItem().exitState).isEqualTo(AsyncAction.Success(Unit)) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(false)) } } @@ -367,12 +366,12 @@ class ChangeRolesPresenterTest { assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) skipItems(1) - assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit)) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) } } @Test - fun `present - CancelSave will remove the confirmation dialog`() = runTest { + fun `present - CloseDialog will remove the confirmation dialog`() = runTest { val room = FakeJoinedRoom().apply { givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) @@ -391,7 +390,7 @@ class ChangeRolesPresenterTest { val confirmingState = awaitItem() assertThat(confirmingState.savingState).isEqualTo(AsyncAction.ConfirmingNoParams) - confirmingState.eventSink(ChangeRolesEvent.CancelSave) + confirmingState.eventSink(ChangeRolesEvent.CloseDialog) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) } } @@ -426,7 +425,7 @@ class ChangeRolesPresenterTest { assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) skipItems(1) - assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit)) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.Moderator)) } } @@ -504,13 +503,13 @@ class ChangeRolesPresenterTest { assertThat(loadingState.savingState).isInstanceOf(AsyncAction.Loading::class.java) skipItems(1) - assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(Unit)) + assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Success(true)) assertThat(analyticsService.capturedEvents.last()).isEqualTo(RoomModeration(RoomModeration.Action.ChangeMemberRole, RoomModeration.Role.User)) } } @Test - fun `present - Save can handle failures and ClearError clears them`() = runTest { + fun `present - Save can handle failures and CloseDialog clears them`() = runTest { val room = FakeJoinedRoom( updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) } ).apply { @@ -534,7 +533,7 @@ class ChangeRolesPresenterTest { val failedState = awaitItem() assertThat(failedState.savingState).isInstanceOf(AsyncAction.Failure::class.java) - failedState.eventSink(ChangeRolesEvent.ClearError) + failedState.eventSink(ChangeRolesEvent.CloseDialog) assertThat(awaitItem().savingState).isEqualTo(AsyncAction.Uninitialized) } } diff --git a/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesViewTest.kt b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesViewTest.kt index fb1dc38aae..19d9cadfa8 100644 --- a/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesViewTest.kt +++ b/features/changeroommemberroles/impl/src/test/kotlin/io/element/android/features/changeroommemberroles/impl/ChangeRolesViewTest.kt @@ -135,7 +135,7 @@ class ChangeRolesViewTest { rule.setChangeRolesContent( state = aChangeRolesState( isSearchActive = true, - exitState = AsyncAction.ConfirmingNoParams, + savingState = AsyncAction.ConfirmingCancellation, eventSink = eventsRecorder, ), ) @@ -151,14 +151,14 @@ class ChangeRolesViewTest { rule.setChangeRolesContent( state = aChangeRolesState( isSearchActive = true, - exitState = AsyncAction.ConfirmingNoParams, + savingState = AsyncAction.ConfirmingCancellation, eventSink = eventsRecorder, ), ) rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(ChangeRolesEvent.CancelExit) + eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) } @Test @@ -209,7 +209,7 @@ class ChangeRolesViewTest { rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(ChangeRolesEvent.ClearError) + eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) } @Test @@ -225,7 +225,7 @@ class ChangeRolesViewTest { rule.clickOn(CommonStrings.action_ok) - eventsRecorder.assertSingle(ChangeRolesEvent.ClearError) + eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) } @Test diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt index 1becbe9e5c..a892de1498 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt @@ -13,6 +13,7 @@ sealed interface RoomDetailsEditEvents { data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents data class UpdateRoomName(val name: String) : RoomDetailsEditEvents data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents + data object OnBackPress : RoomDetailsEditEvents data object Save : RoomDetailsEditEvents - data object CancelSaveChanges : RoomDetailsEditEvents + data object CloseDialog : RoomDetailsEditEvents } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt index 8143f1848f..37fcfce53b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt @@ -41,8 +41,7 @@ class RoomDetailsEditNode( val state = presenter.present() RoomDetailsEditView( state = state, - onBackClick = ::navigateUp, - onRoomEditSuccess = ::navigateUp, + onDone = ::navigateUp, modifier = modifier, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt index 6e318d5f45..cc207d8b24 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -169,7 +169,13 @@ class RoomDetailsEditPresenter( is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic - RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized + RoomDetailsEditEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized + RoomDetailsEditEvents.OnBackPress -> if (saveButtonEnabled.not() || saveAction.value == AsyncAction.ConfirmingCancellation) { + // No changes to save or already confirming exit without saving + saveAction.value = AsyncAction.Success(Unit) + } else { + saveAction.value = AsyncAction.ConfirmingCancellation + } } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt index f1dcc2463d..3f58e40b98 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt @@ -26,6 +26,7 @@ open class RoomDetailsEditStateProvider : PreviewParameterProvider Unit, - onRoomEditSuccess: () -> Unit, + onDone: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -62,12 +64,21 @@ fun RoomDetailsEditView( isAvatarActionsSheetVisible.value = true } + BackHandler { + state.eventSink(RoomDetailsEditEvents.OnBackPress) + } Scaffold( modifier = modifier.clearFocusOnTap(focusManager), topBar = { TopAppBar( titleStr = stringResource(id = R.string.screen_room_details_edit_room_title), - navigationIcon = { BackButton(onClick = onBackClick) }, + navigationIcon = { + BackButton( + onClick = { + state.eventSink(RoomDetailsEditEvents.OnBackPress) + } + ) + }, actions = { TextButton( text = stringResource(CommonStrings.action_save), @@ -126,14 +137,12 @@ fun RoomDetailsEditView( ) } } - AvatarActionBottomSheet( actions = state.avatarActions, isVisible = isAvatarActionsSheetVisible.value, onDismiss = { isAvatarActionsSheetVisible.value = false }, onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } ) - AsyncActionView( async = state.saveAction, progressDialog = { @@ -141,9 +150,19 @@ fun RoomDetailsEditView( progressText = stringResource(R.string.screen_room_details_updating_room), ) }, - onSuccess = { onRoomEditSuccess() }, + confirmationDialog = { + if (state.saveAction == AsyncAction.ConfirmingCancellation) { + ConfirmationDialog( + title = stringResource(CommonStrings.dialog_unsaved_changes_title), + content = stringResource(CommonStrings.dialog_unsaved_changes_description_android), + onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) }, + onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) } + ) + } + }, + onSuccess = { onDone() }, errorMessage = { stringResource(R.string.screen_room_details_edition_error) }, - onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CancelSaveChanges) } + onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) } ) PermissionsView( @@ -156,7 +175,6 @@ fun RoomDetailsEditView( internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview { RoomDetailsEditView( state = state, - onBackClick = {}, - onRoomEditSuccess = {}, + onDone = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt index a1cc9c3b92..74e1b8548a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -41,7 +41,8 @@ internal class RoomMemberListStateProvider : PreviewParameterProvider Result.success(true) } + ) + val deleteCallback = lambdaRecorder {} + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + eventSink(RoomDetailsEditEvents.OnBackPress) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + eventSink(RoomDetailsEditEvents.CloseDialog) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - leave no changes, no confirmation`() = runTest { + val room = aJoinedRoom( + displayName = "Name", + canSendStateResult = { _, _ -> Result.success(true) } + ) + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter {}, + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + initialState.eventSink(RoomDetailsEditEvents.OnBackPress) + assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + + @Test + fun `present - leave without saving - confirm`() = runTest { + val room = aJoinedRoom( + displayName = "Name", + canSendStateResult = { _, _ -> Result.success(true) } + ) + val presenter = createRoomDetailsEditPresenter( + room = room, + temporaryUriDeleter = FakeTemporaryUriDeleter({}), + ) + presenter.test { + val initialState = awaitFirstItem() + assertThat(initialState.saveButtonEnabled).isFalse() + // Once a change is made, the save button is enabled + initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited")) + awaitItem().apply { + assertThat(saveButtonEnabled).isTrue() + eventSink(RoomDetailsEditEvents.OnBackPress) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) + eventSink(RoomDetailsEditEvents.OnBackPress) + } + awaitItem().apply { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + } + } + private suspend fun saveAndAssertFailure( room: JoinedRoom, event: RoomDetailsEditEvents, diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt index 2ac3d4397c..bfd3b5e7cc 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt @@ -38,17 +38,41 @@ class RoomDetailsEditViewTest { @get:Rule val rule = createAndroidComposeRule() @Test - fun `clicking on back invoke back callback`() { - val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnce { callback -> - rule.setRoomDetailsEditView( - aRoomDetailsEditState( - eventSink = eventsRecorder - ), - onBackClick = callback, - ) - rule.pressBack() - } + fun `clicking on back emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder + ), + ) + rule.pressBack() + eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress) + } + + @Test + fun `clicking on OK when confirming exit emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress) + } + + @Test + fun `clicking on cancel when confirming exit emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + saveAction = AsyncAction.ConfirmingCancellation, + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog) } @Test @@ -60,7 +84,7 @@ class RoomDetailsEditViewTest { eventSink = eventsRecorder, saveAction = AsyncAction.Success(Unit) ), - onRoomEdited = callback, + onDone = callback, ) } } @@ -209,20 +233,18 @@ class RoomDetailsEditViewTest { ), ) rule.clickOn(CommonStrings.action_ok) - eventsRecorder.assertSingle(RoomDetailsEditEvents.CancelSaveChanges) + eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog) } } private fun AndroidComposeTestRule.setRoomDetailsEditView( state: RoomDetailsEditState, - onBackClick: () -> Unit = EnsureNeverCalled(), - onRoomEdited: () -> Unit = EnsureNeverCalled(), + onDone: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomDetailsEditView( state = state, - onBackClick = onBackClick, - onRoomEditSuccess = onRoomEdited, + onDone = onDone, ) } } diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt index c02c64135d..88fa4079f5 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt @@ -32,6 +32,11 @@ sealed interface AsyncAction { data object ConfirmingNoParams : Confirming + /** + * User cancels the action, use this object to ask for confirmation. + */ + data object ConfirmingCancellation : Confirming + /** * Represents an operation that is currently ongoing. */ diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_en.png new file mode 100644 index 0000000000..3064144e10 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cd78ece31258d6ee07eabc282f935e6228893d52d24e00e0bbd129138ed8c31 +size 30836 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Night_8_en.png new file mode 100644 index 0000000000..7888efbfa8 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.edit_RoomDetailsEditView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bf7bc223f5d481ec8bd1f2a6711751a3bb82ac8f0d856cb5f6c8f4ee7ef50e1 +size 28891 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png index 459b225859..842147e3f0 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:37940d14ca14d65256ea220cfd7f0685ba2d100425f65af4d4cffe35be4b69fb -size 45485 +oid sha256:0cd12c68415f61b198696a9f9d8b19da5e6ced287e015071a8a850100238862b +size 49992 diff --git a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png index b063a6d741..ad5b94c86d 100644 --- a/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.roomdetails.impl.members_RoomMemberListView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c45c582fb6698e70b376f832b9564946a2271a6b3532886239dfd75d8bb9755 -size 45409 +oid sha256:be897269967951ea0ddb0cc209005d07d4904531a8ab9d865b6e622c9ba98e18 +size 49747 From 7ed888af83a70e62538b479d11638f4b9bb8f306 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Tue, 28 Oct 2025 21:06:57 +0100 Subject: [PATCH 6/8] Fix issues with WorkManager on Android 12 and below (#5606) * Add `getForegroundInfo` implementation to try to fix issues with WorkManager on Android 12 and below This may be a MIUI-only issue as I couldn't reproduce it with several emulators on Android 11, 12 and 13. * Use `setExpedited` only on Android 13 or higher, it's not needed on older versions * Use an actual string resource, fix tests * Fix review comments * Fix broken test with Element Pro: Instead of using Robolectric with API < 33 (since Pro uses minSdk 33) use a `BuildVersionSdkIntProvider` * Remove `getForegroundInfo` and the associated permission, as we expect it to be dead code * Fix lint issues * Cleanup NotificationIdProvider --------- Co-authored-by: Benoit Marty --- .../notifications/NotificationIdProvider.kt | 5 ---- .../push/impl/src/main/AndroidManifest.xml | 1 - .../NotificationResolverQueue.kt | 3 ++ .../workmanager/FetchNotificationsWorker.kt | 5 +++- .../SyncNotificationWorkManagerRequest.kt | 12 +++++++- .../push/impl/push/DefaultPushHandlerTest.kt | 2 ++ .../FetchNotificationWorkerTest.kt | 2 ++ .../SyncNotificationWorkManagerRequestTest.kt | 30 +++++++++++++++++-- 8 files changed, 49 insertions(+), 11 deletions(-) diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt index 83ab37682b..390932bb79 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationIdProvider.kt @@ -31,10 +31,6 @@ object NotificationIdProvider { return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID } - fun getCallNotificationId(sessionId: SessionId): Int { - return getOffset(sessionId) + ROOM_CALL_NOTIFICATION_ID - } - fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int { return type.id * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID } @@ -49,7 +45,6 @@ object NotificationIdProvider { private const val ROOM_MESSAGES_NOTIFICATION_ID = 1 private const val ROOM_EVENT_NOTIFICATION_ID = 2 private const val ROOM_INVITATION_NOTIFICATION_ID = 3 - private const val ROOM_CALL_NOTIFICATION_ID = 3 private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4 } diff --git a/libraries/push/impl/src/main/AndroidManifest.xml b/libraries/push/impl/src/main/AndroidManifest.xml index c08c16ed5f..ecdb3faaa2 100644 --- a/libraries/push/impl/src/main/AndroidManifest.xml +++ b/libraries/push/impl/src/main/AndroidManifest.xml @@ -7,7 +7,6 @@ - diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt index bbdd241c24..156c925202 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEv import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter import io.element.android.libraries.workmanager.api.WorkManagerScheduler +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -49,6 +50,7 @@ class DefaultNotificationResolverQueue( private val workManagerScheduler: WorkManagerScheduler, private val featureFlagService: FeatureFlagService, private val workerDataConverter: WorkerDataConverter, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, ) : NotificationResolverQueue { companion object { private const val BATCH_WINDOW_MS = 250L @@ -100,6 +102,7 @@ class DefaultNotificationResolverQueue( sessionId = sessionId, notificationEventRequests = requests, workerDataConverter = workerDataConverter, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, ) ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt index 6904b2f833..b661560cf5 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt @@ -29,6 +29,7 @@ import io.element.android.libraries.push.impl.notifications.NotificationResolver import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory import io.element.android.libraries.workmanager.api.di.WorkerKey +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext @@ -39,7 +40,7 @@ import kotlin.time.Duration.Companion.seconds @AssistedInject class FetchNotificationsWorker( @Assisted workerParams: WorkerParameters, - @ApplicationContext context: Context, + @ApplicationContext private val context: Context, private val networkMonitor: NetworkMonitor, private val eventResolver: NotifiableEventResolver, private val queue: NotificationResolverQueue, @@ -47,6 +48,7 @@ class FetchNotificationsWorker( private val syncOnNotifiableEvent: SyncOnNotifiableEvent, private val coroutineDispatchers: CoroutineDispatchers, private val workerDataConverter: WorkerDataConverter, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) { Timber.d("FetchNotificationsWorker started") @@ -88,6 +90,7 @@ class FetchNotificationsWorker( sessionId = failedSessionId, notificationEventRequests = requestsToRetry, workerDataConverter = workerDataConverter, + buildVersionSdkIntProvider = buildVersionSdkIntProvider, ) ) } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt index b0aabe1cc7..f11aabe469 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt @@ -7,6 +7,7 @@ package io.element.android.libraries.push.impl.workmanager +import android.os.Build import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkRequest @@ -15,6 +16,7 @@ import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import timber.log.Timber @@ -24,6 +26,7 @@ class SyncNotificationWorkManagerRequest( private val sessionId: SessionId, private val notificationEventRequests: List, private val workerDataConverter: WorkerDataConverter, + private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, ) : WorkManagerRequest { override fun build(): Result { if (notificationEventRequests.isEmpty()) { @@ -36,7 +39,14 @@ class SyncNotificationWorkManagerRequest( return Result.success( OneTimeWorkRequestBuilder() .setInputData(data) - .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .apply { + // Expedited workers aren't needed on Android 12 or lower: + // They force displaying a foreground sync notification for no good reason, since they sync almost immediately anyway + // See https://developer.android.com/develop/background-work/background-tasks/persistent/getting-started/define-work#backwards-compat + if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + } + } .setTraceTag(workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC)) // TODO investigate using this instead of the resolver queue // .setInputMerger() diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 870b9e1e1a..4576051be6 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -55,6 +55,7 @@ import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushSto import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.any @@ -717,6 +718,7 @@ class DefaultPushHandlerTest { workManagerScheduler = workManagerScheduler, featureFlagService = featureFlagService, workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), ), appCoroutineScope = backgroundScope, fallbackNotificationFactory = FallbackNotificationFactory( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt index 74241fecd8..c20cfbe61e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.push.test.notifications.FakeNotificationReso import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.di.MetroWorkerFactory import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -176,6 +177,7 @@ class FetchNotificationWorkerTest { syncOnNotifiableEvent = syncOnNotifiableEvent, coroutineDispatchers = testCoroutineDispatchers(), workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), ) private fun TestScope.createWorkerParams( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt index ebbe4eb865..34738854ac 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt @@ -17,15 +17,17 @@ import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.push.impl.notifications.fixtures.aNotificationEventRequest import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.workManagerTag +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import kotlinx.coroutines.test.runTest import org.junit.Test class SyncNotificationWorkManagerRequestTest { @Test - fun `build - success`() = runTest { + fun `build - success API 33`() = runTest { val request = createSyncNotificationWorkManagerRequest( sessionId = A_SESSION_ID, - notificationEventRequests = listOf(aNotificationEventRequest()) + notificationEventRequests = listOf(aNotificationEventRequest()), + sdkVersion = 33, ) val result = request.build() @@ -33,11 +35,31 @@ class SyncNotificationWorkManagerRequestTest { result.getOrNull()!!.run { assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() + // True in API 33+ assertThat(workSpec.expedited).isTrue() assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) } } + @Test + fun `build - success API 32 and lower`() = runTest { + val request = createSyncNotificationWorkManagerRequest( + sessionId = A_SESSION_ID, + notificationEventRequests = listOf(aNotificationEventRequest()), + sdkVersion = 32, + ) + + val result = request.build() + assertThat(result.isSuccess).isTrue() + result.getOrNull()!!.run { + assertThat(this).isInstanceOf(OneTimeWorkRequest::class.java) + assertThat(workSpec.input.hasKeyWithValueOfType("requests")).isTrue() + // False before API 33 + assertThat(workSpec.expedited).isFalse() + assertThat(workSpec.traceTag).isEqualTo(workManagerTag(A_SESSION_ID, WorkManagerRequestType.NOTIFICATION_SYNC)) + } + } + @Test fun `build - empty list of requests fails`() = runTest { val request = createSyncNotificationWorkManagerRequest( @@ -64,9 +86,11 @@ class SyncNotificationWorkManagerRequestTest { private fun createSyncNotificationWorkManagerRequest( sessionId: SessionId, notificationEventRequests: List, - workerDataConverter: WorkerDataConverter = WorkerDataConverter(DefaultJsonProvider()) + workerDataConverter: WorkerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + sdkVersion: Int = 33, ) = SyncNotificationWorkManagerRequest( sessionId = sessionId, notificationEventRequests = notificationEventRequests, workerDataConverter = workerDataConverter, + buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkVersion), ) From f1cce0f817bacdc2f5edc995f78bcd86f451941f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:18:39 +0100 Subject: [PATCH 7/8] fix(deps): update dependency com.github.matrix-org:matrix-analytics-events to v0.29.2 (#5621) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f139e8abe..26046986cb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -213,7 +213,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" posthog = "com.posthog:posthog-android:3.25.0" sentry = "io.sentry:sentry-android:8.24.0" # main branch can be tested replacing the version with main-SNAPSHOT -matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0" +matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2" # Emojibase matrix_emojibase_bindings = "io.element.android:emojibase-bindings:1.4.3" From 0bc71acd8a57ae023083116c519d75e7152e4e4c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:56:32 +0100 Subject: [PATCH 8/8] fix(deps): update dependencyanalysis to v3.4.0 (#5624) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 26046986cb..802455adff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ telephoto = "0.18.0" haze = "1.6.10" # Dependency analysis -dependencyAnalysis = "3.3.0" +dependencyAnalysis = "3.4.0" # DI metro = "0.7.2"