From 7dcd88f8e1f1493d0797fc4bcc056d4ffac0d910 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 12 Jan 2026 09:18:26 +0100 Subject: [PATCH 01/29] Ensure that log files are not too big, else the rageshake server will reject the request. See https://github.com/element-hq/element-android/issues/9096#issuecomment-3480128082 Closes #5983 --- .../io/element/android/appconfig/RageshakeConfig.kt | 5 +++++ .../rageshake/impl/reporter/DefaultBugReporter.kt | 8 +++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt index 1f6609ecc3..38dcd67c9e 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt @@ -25,4 +25,9 @@ object RageshakeConfig { * The maximum size of the upload request. Default value is just below CloudFlare's max request size. */ const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L + + /** + * The maximum size of a single log file. + */ + const val MAX_LOG_CONTENT_SIZE = 100 * 1024 * 1024L } diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index f51d699350..f238f9d6ff 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -158,6 +158,7 @@ class DefaultBugReporter( } if (withCrashLogs || withDevicesLogs) { saveLogCat() + ?.takeIf { it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE } ?.let { logCatFile -> compressFile(logCatFile).also { logCatFile.safeDelete() @@ -191,6 +192,7 @@ class DefaultBugReporter( .addFormDataPart("label", buildMeta.versionName) .addFormDataPart("label", buildMeta.flavorDescription) .addFormDataPart("branch_name", buildMeta.gitBranchName) + userId?.let { matrixClientProvider.getOrNull(it)?.let { client -> val curveKey = client.encryptionService.deviceCurve25519() @@ -390,7 +392,11 @@ class DefaultBugReporter( ) { val logDirectory = logDirectory() logDirectory.listFiles() - ?.filter { it.isFile && !it.name.endsWith(LOG_CAT_FILENAME) } + ?.filter { + it.isFile && + !it.name.endsWith(LOG_CAT_FILENAME) && + it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE + } }.orEmpty() } From b7ff884838ae2ec387e1027872e21951a8d204e2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Tue, 13 Jan 2026 22:08:48 +0100 Subject: [PATCH 02/29] Add manage mode to space view for removing child rooms, wip. --- .../features/space/impl/root/SpaceEvents.kt | 9 + .../space/impl/root/SpacePresenter.kt | 53 ++++++ .../features/space/impl/root/SpaceState.kt | 8 + .../space/impl/root/SpaceStateProvider.kt | 29 +++- .../features/space/impl/root/SpaceView.kt | 157 +++++++++++++++--- .../impl/settings/SpaceSettingsPermissions.kt | 4 + .../space/impl/root/SpacePresenterTest.kt | 4 + .../matrix/api/spaces/SpaceService.kt | 8 + .../matrix/impl/spaces/RustSpaceService.kt | 6 + .../matrix/test/spaces/FakeSpaceService.kt | 5 + .../src/main/res/values/localazy.xml | 1 + 11 files changed, 263 insertions(+), 21 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt index 16a6ad1c3f..0f17a2f6f7 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt @@ -8,6 +8,7 @@ package io.element.android.features.space.impl.root +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom sealed interface SpaceEvents { @@ -19,4 +20,12 @@ sealed interface SpaceEvents { data class ShowTopicViewer(val topic: String) : SpaceEvents data object HideTopicViewer : SpaceEvents + + // Manage mode events + data object EnterManageMode : SpaceEvents + data object ExitManageMode : SpaceEvents + data class ToggleRoomSelection(val roomId: RoomId) : SpaceEvents + data object ConfirmRoomRemoval : SpaceEvents + data object RemoveSelectedRooms : SpaceEvents + data object ClearRemoveAction : SpaceEvents } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 309747d2c9..abcb4b444e 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -40,6 +40,7 @@ import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -48,6 +49,8 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlin.jvm.optionals.getOrNull @@ -62,6 +65,7 @@ class SpacePresenter( private val acceptDeclineInvitePresenter: Presenter, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val featureFlagService: FeatureFlagService, + private val spaceService: SpaceService, ) : Presenter { private var children by mutableStateOf>(persistentListOf()) @@ -104,6 +108,11 @@ class SpacePresenter( var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) } + // Manage mode state + var isManageMode by remember { mutableStateOf(false) } + var selectedRoomIds by remember { mutableStateOf>(emptySet()) } + var removeRoomsAction by remember { mutableStateOf>(AsyncAction.Uninitialized) } + LaunchedEffect(children) { // Remove joined children from the join actions val joinedChildren = children @@ -138,6 +147,46 @@ class SpacePresenter( } SpaceEvents.HideTopicViewer -> topicViewerState = TopicViewerState.Hidden is SpaceEvents.ShowTopicViewer -> topicViewerState = TopicViewerState.Shown(event.topic) + + // Manage mode events + SpaceEvents.EnterManageMode -> { + isManageMode = true + selectedRoomIds = emptySet() + } + SpaceEvents.ExitManageMode -> { + isManageMode = false + selectedRoomIds = emptySet() + } + is SpaceEvents.ToggleRoomSelection -> { + selectedRoomIds = if (event.roomId in selectedRoomIds) { + selectedRoomIds - event.roomId + } else { + selectedRoomIds + event.roomId + } + } + SpaceEvents.RemoveSelectedRooms -> { + removeRoomsAction = AsyncAction.ConfirmingNoParams + } + SpaceEvents.ConfirmRoomRemoval -> { + localCoroutineScope.launch { + removeRoomsAction = AsyncAction.Loading + val spaceId = spaceRoomList.roomId + val results = selectedRoomIds.map { roomId -> + async { spaceService.removeChildFromSpace(spaceId, roomId) } + } + val hasError = results.awaitAll().any { it.isFailure } + if (hasError) { + removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms")) + } else { + removeRoomsAction = AsyncAction.Success(Unit) + isManageMode = false + selectedRoomIds = emptySet() + } + } + } + SpaceEvents.ClearRemoveAction -> { + removeRoomsAction = AsyncAction.Uninitialized + } } } return SpaceState( @@ -150,6 +199,10 @@ class SpacePresenter( acceptDeclineInviteState = acceptDeclineInviteState, topicViewerState = topicViewerState, canAccessSpaceSettings = canAccessSpaceSettings, + isManageMode = isManageMode, + selectedRoomIds = selectedRoomIds.toImmutableSet(), + canManageRooms = permissions.canManageRooms, + removeRoomsAction = removeRoomsAction, eventSink = ::handleEvent, ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt index cceda62806..e33d4730d1 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -27,12 +27,20 @@ data class SpaceState( val acceptDeclineInviteState: AcceptDeclineInviteState, val topicViewerState: TopicViewerState, val canAccessSpaceSettings: Boolean, + val isManageMode: Boolean, + val selectedRoomIds: ImmutableSet, + val canManageRooms: Boolean, + val removeRoomsAction: AsyncAction, val eventSink: (SpaceEvents) -> Unit ) { fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading val hasAnyFailure: Boolean = joinActions.values.any { it is AsyncAction.Failure } + + val showManageRoomsAction: Boolean = canManageRooms && children.isNotEmpty() + val selectedCount: Int = selectedRoomIds.size + val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty() } @Immutable diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt index 52894ad599..ea59bf6e2e 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -39,7 +39,26 @@ open class SpaceStateProvider : PreviewParameterProvider { aSpaceState( topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()), ), - // Add other states here + // Manage mode states + aSpaceState( + parentSpace = aParentSpace(), + children = aListOfSpaceRooms(), + isManageMode = true, + selectedRoomIds = emptySet(), + ), + aSpaceState( + parentSpace = aParentSpace(), + children = aListOfSpaceRooms(), + isManageMode = true, + selectedRoomIds = setOf(RoomId("!spaceId0:example.com"), RoomId("!spaceId1:example.com")), + ), + aSpaceState( + parentSpace = aParentSpace(), + children = aListOfSpaceRooms(), + isManageMode = true, + selectedRoomIds = setOf(RoomId("!spaceId0:example.com")), + removeRoomsAction = AsyncAction.ConfirmingNoParams, + ), ) } @@ -54,6 +73,10 @@ fun aSpaceState( acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), topicViewerState: TopicViewerState = TopicViewerState.Hidden, canAccessSpaceSettings: Boolean = true, + isManageMode: Boolean = false, + selectedRoomIds: Set = emptySet(), + canManageRooms: Boolean = true, + removeRoomsAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (SpaceEvents) -> Unit = { }, ) = SpaceState( currentSpace = parentSpace, @@ -65,6 +88,10 @@ fun aSpaceState( acceptDeclineInviteState = acceptDeclineInviteState, topicViewerState = topicViewerState, canAccessSpaceSettings = canAccessSpaceSettings, + isManageMode = isManageMode, + selectedRoomIds = selectedRoomIds.toImmutableSet(), + canManageRooms = canManageRooms, + removeRoomsAction = removeRoomsAction, eventSink = eventSink, ) 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 769b608e8e..56c932f968 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 @@ -40,7 +40,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet import io.element.android.libraries.designsystem.components.async.AsyncIndicator @@ -56,9 +59,11 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem +import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar @@ -88,15 +93,26 @@ fun SpaceView( Scaffold( modifier = modifier, topBar = { - SpaceViewTopBar( - currentSpace = state.currentSpace, - canAccessSpaceSettings = state.canAccessSpaceSettings, - onBackClick = onBackClick, - onLeaveSpaceClick = onLeaveSpaceClick, - onShareSpace = onShareSpace, - onSettingsClick = onSettingsClick, - onViewMembersClick = onViewMembersClick, - ) + if (state.isManageMode) { + ManageModeTopBar( + selectedCount = state.selectedCount, + isRemoveButtonEnabled = state.isRemoveButtonEnabled, + onCancelClick = { state.eventSink(SpaceEvents.ExitManageMode) }, + onRemoveClick = { state.eventSink(SpaceEvents.RemoveSelectedRooms) }, + ) + } else { + SpaceViewTopBar( + currentSpace = state.currentSpace, + canAccessSpaceSettings = state.canAccessSpaceSettings, + showManageRoomsAction = state.showManageRoomsAction, + onBackClick = onBackClick, + onLeaveSpaceClick = onLeaveSpaceClick, + onShareSpace = onShareSpace, + onSettingsClick = onSettingsClick, + onViewMembersClick = onViewMembersClick, + onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) }, + ) + } }, content = { padding -> Box( @@ -104,7 +120,13 @@ fun SpaceView( ) { SpaceViewContent( state = state, - onRoomClick = onRoomClick, + onRoomClick = { spaceRoom -> + if (state.isManageMode) { + state.eventSink(SpaceEvents.ToggleRoomSelection(spaceRoom.roomId)) + } else { + onRoomClick(spaceRoom) + } + }, onTopicClick = { topic -> state.eventSink(SpaceEvents.ShowTopicViewer(topic)) } @@ -125,6 +147,14 @@ fun SpaceView( } ) } + + // Confirmation dialog for removing rooms + RemoveRoomsConfirmationDialog( + removeRoomsAction = state.removeRoomsAction, + selectedCount = state.selectedCount, + onConfirm = { state.eventSink(SpaceEvents.ConfirmRoomRemoval) }, + onDismiss = { state.eventSink(SpaceEvents.ClearRemoveAction) }, + ) } @Composable @@ -200,6 +230,7 @@ private fun SpaceViewContent( ) { index, spaceRoom -> val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED val isCurrentlyJoining = state.isJoining(spaceRoom.roomId) + val isSelected = spaceRoom.roomId in state.selectedRoomIds SpaceRoomItemView( spaceRoom = spaceRoom, showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, @@ -210,17 +241,30 @@ private fun SpaceViewContent( onLongClick = { // TODO }, - trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) { - state.eventSink(SpaceEvents.Join(spaceRoom)) - }, - bottomAction = spaceRoom.inviteButtons( - onAcceptClick = { - state.eventSink(SpaceEvents.AcceptInvite(spaceRoom)) - }, - onDeclineClick = { - state.eventSink(SpaceEvents.DeclineInvite(spaceRoom)) + trailingAction = if (state.isManageMode) { + { + Checkbox( + checked = isSelected, + onCheckedChange = null, + ) } - ) + } else { + spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) { + state.eventSink(SpaceEvents.Join(spaceRoom)) + } + }, + bottomAction = if (state.isManageMode) { + null + } else { + spaceRoom.inviteButtons( + onAcceptClick = { + state.eventSink(SpaceEvents.AcceptInvite(spaceRoom)) + }, + onDeclineClick = { + state.eventSink(SpaceEvents.DeclineInvite(spaceRoom)) + } + ) + } ) if (index != state.children.lastIndex) { HorizontalDivider() @@ -259,11 +303,13 @@ private fun LoadingMoreIndicator( private fun SpaceViewTopBar( currentSpace: SpaceRoom?, canAccessSpaceSettings: Boolean, + showManageRoomsAction: Boolean, onBackClick: () -> Unit, onLeaveSpaceClick: () -> Unit, onSettingsClick: () -> Unit, onShareSpace: () -> Unit, onViewMembersClick: () -> Unit, + onManageRoomsClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( @@ -313,6 +359,16 @@ private fun SpaceViewTopBar( onShareSpace() } ) + if (showManageRoomsAction) { + SpaceMenuItem( + titleRes = CommonStrings.action_manage_rooms, + icon = CompoundIcons.Edit(), + onClick = { + showMenu = false + onManageRoomsClick() + } + ) + } if (canAccessSpaceSettings) { SpaceMenuItem( titleRes = CommonStrings.common_settings, @@ -337,6 +393,39 @@ private fun SpaceViewTopBar( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ManageModeTopBar( + selectedCount: Int, + isRemoveButtonEnabled: Boolean, + onCancelClick: () -> Unit, + onRemoveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton( + onClick = onCancelClick, + imageVector = CompoundIcons.Close() + ) + }, + title = { + Text( + text = "$selectedCount selected", + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_remove), + onClick = onRemoveClick, + enabled = isRemoveButtonEnabled, + ) + }, + ) +} + @Composable private fun SpaceMenuItem( @StringRes titleRes: Int, @@ -425,6 +514,34 @@ private fun SpaceRoom.inviteButtons( } } +@Composable +private fun RemoveRoomsConfirmationDialog( + removeRoomsAction: AsyncAction, + selectedCount: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + when (removeRoomsAction) { + AsyncAction.ConfirmingNoParams -> { + ConfirmationDialog( + title = "Remove $selectedCount rooms from space?", + content = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security.", + submitText = stringResource(CommonStrings.action_remove), + onSubmitClick = onConfirm, + onDismiss = onDismiss, + destructiveSubmit = true, + ) + } + else -> { + AsyncActionView( + async = removeRoomsAction, + onSuccess = { onDismiss() }, + onErrorDismiss = onDismiss, + ) + } + } +} + @PreviewsDayNight @Composable internal fun SpaceViewPreview( diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt index e3ec70a51d..8297992b49 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt @@ -11,6 +11,7 @@ import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermission import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions +import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions @@ -19,6 +20,7 @@ data class SpaceSettingsPermissions( val editDetailsPermissions: RoomDetailsEditPermissions, val canEditRolesAndPermissions: Boolean, val securityAndPrivacyPermissions: SecurityAndPrivacyPermissions, + val canManageRooms: Boolean, ) { fun hasAny(joinRule: JoinRule?): Boolean { return editDetailsPermissions.hasAny || @@ -31,6 +33,7 @@ data class SpaceSettingsPermissions( editDetailsPermissions = RoomDetailsEditPermissions.DEFAULT, canEditRolesAndPermissions = false, securityAndPrivacyPermissions = SecurityAndPrivacyPermissions.DEFAULT, + canManageRooms = false, ) } } @@ -40,5 +43,6 @@ fun RoomPermissions.spaceSettingsPermissions(): SpaceSettingsPermissions { editDetailsPermissions = roomDetailsEditPermissions(), canEditRolesAndPermissions = canEditRolesAndPermissions(), securityAndPrivacyPermissions = securityAndPrivacyPermissions(), + canManageRooms = canOwnUserSendState(StateEventType.SpaceChild), ) } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index 917aceb262..65a4c76783 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -28,6 +28,7 @@ import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 @@ -36,6 +37,7 @@ import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -365,6 +367,7 @@ class SpacePresenterTest { ), acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, spaceSettingsEnabled: Boolean = false, + spaceService: FakeSpaceService = FakeSpaceService(), ): SpacePresenter { return SpacePresenter( client = client, @@ -379,6 +382,7 @@ class SpacePresenterTest { FeatureFlags.SpaceSettings.key to spaceSettingsEnabled, ) ), + spaceService = spaceService, ) } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 6f5ba674ec..a89bb2da86 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -18,4 +18,12 @@ interface SpaceService { fun spaceRoomList(id: RoomId): SpaceRoomList fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle + + /** + * Remove a child room from a space. + * @param spaceId The space ID from which to remove the child. + * @param childId The room ID of the child to remove. + * @return A result indicating success or failure. + */ + suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index ba816c11c7..41aae086fa 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -79,6 +79,12 @@ class RustSpaceService( } } + override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerSpaceService.removeChildFromSpace(spaceId.value, childId.value) + } + } + init { innerSpaceService .spaceListUpdate() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt index eaa36ee750..539bb0ce6f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt @@ -23,6 +23,7 @@ class FakeSpaceService( private val joinedSpacesResult: () -> Result> = { lambdaError() }, private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() }, + private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result = { _, _ -> lambdaError() }, ) : SpaceService { private val _spaceRoomsFlow = MutableSharedFlow>() override val spaceRoomsFlow: SharedFlow> @@ -43,4 +44,8 @@ class FakeSpaceService( override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { return leaveSpaceHandleResult(spaceId) } + + override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = simulateLongTask { + removeChildFromSpaceResult(spaceId, childId) + } } diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index df47b71577..f5c3c3f12f 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -112,6 +112,7 @@ "Load more" "Manage account" "Manage devices" + "Manage rooms" "Message" "Minimise" "Next" From 25cd168d68e3cc4cd46aab7d7bab7a1b1985be42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Wed, 14 Jan 2026 13:26:32 +0100 Subject: [PATCH 03/29] Changelog for version 26.01.0 --- CHANGES.md | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 47ea7ec332..164739cb60 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,145 @@ +Changes in Element X v26.01.0 +============================= + + + +## What's Changed +### ✨ Features +* Link new device using QrCode - First version by @bmarty in https://github.com/element-hq/element-x-android/pull/5909 +* Voice message: variable play back speed by @bmarty in https://github.com/element-hq/element-x-android/pull/5963 +* Change Room’s Access to/from Space members by @ganfra in https://github.com/element-hq/element-x-android/pull/5979 +* Create spaces by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5982 +### 🙌 Improvements +* change(room member): make sure we never display name/avatar when member is banned by @ganfra in https://github.com/element-hq/element-x-android/pull/5826 +* Change : room details edit by @ganfra in https://github.com/element-hq/element-x-android/pull/5844 +* Space feature flags by @ganfra in https://github.com/element-hq/element-x-android/pull/5827 +* Update unsaved change dialog by @bmarty in https://github.com/element-hq/element-x-android/pull/5845 +* change(notification): handle invite notification for spaces by @ganfra in https://github.com/element-hq/element-x-android/pull/5854 +* Change : space settings iteration by @ganfra in https://github.com/element-hq/element-x-android/pull/5908 +* Change : add "settings" entry menu by @ganfra in https://github.com/element-hq/element-x-android/pull/5948 +* Changes : iterate again on permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5950 +### 🐛 Bugfixes +* fix: usersWithRole(Owner) returns creators only if privilegedCreatorRole is true by @ganfra in https://github.com/element-hq/element-x-android/pull/5832 +* Limit composer height dynamically by @bmarty in https://github.com/element-hq/element-x-android/pull/5835 +* Fix work requests for inaccessible sessions being re-scheduled indefinitely by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5849 +* Fix permission setting navigation by @bmarty in https://github.com/element-hq/element-x-android/pull/5877 +* URL-encode deep link path segments and decode them when parsing by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5880 +* Fix crash when calling `Room.predecessorRoom` when the room is destroyed by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5894 +* fix: edit moderators not working by @ganfra in https://github.com/element-hq/element-x-android/pull/5906 +* Use the right video preset when sharing videos by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5892 +* Add `threadInfo` field to message like timeline events by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5930 +* Fix unverified account after account creation by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5914 +* Fix class cast exception by @bmarty in https://github.com/element-hq/element-x-android/pull/5958 +* Fix : iterate on unban permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5959 +* Use `VerificationState.VERIFIED` as soon as it's available by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5973 +* Make the notification silent when the message is an outgoing message by @bmarty in https://github.com/element-hq/element-x-android/pull/5961 +* Remove previously used id filtering from `RoomSyncSubscriber` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5985 +* When handling incoming share, reuse existing room screen if possible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6001 +* When a duplicate room list entry is found, report it and remove it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6006 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5860 +* Sync Strings - Adding translations for Croatian by @ElementBot in https://github.com/element-hq/element-x-android/pull/5904 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5946 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5956 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5971 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5994 +### 🧱 Build +* Restore `no-unused-imports` behaviour for `ktlintFormat` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5847 +* Fix: use the right `BuildTimeConfig` field for the SDK DSN by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5856 +* Add a way to configure value of useLegacyPackaging by @bmarty in https://github.com/element-hq/element-x-android/pull/5862 +* Improve proguard config to keep the names in the classes in our packages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5882 +* Fix crash when changing the push provider in nightlies by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5951 +### Dependency upgrades +* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5846 +* fix(deps): update metro to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5833 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5831 +* chore(deps): update plugin sonarqube to v7.2.0.6526 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5851 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5855 +* fix(deps): update dependency io.sentry:sentry-android to v8.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5853 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5852 +* Update dependency io.mockk:mockk to v1.14.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5866 +* Update metro to v0.8.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5867 +* Update peter-evans/create-pull-request action to v7.0.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5865 +* Update camera to v1.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5857 +* fix(deps): update showkase to v1.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5868 +* chore(deps): update codecov/codecov-action action to v5.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5874 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5876 +* fix(deps): update dependency net.zetetic:sqlcipher-android to v4.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5872 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5881 +* Update android.gradle.plugin to v8.13.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5887 +* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5875 +* Update Compose BOM to version 2025.12.00. by @bmarty in https://github.com/element-hq/element-x-android/pull/5179 +* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.3 by @bmarty in https://github.com/element-hq/element-x-android/pull/5913 +* fix(deps): update lifecycle to v2.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5240 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5911 +* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5417 +* fix(deps): update activity to v1.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5770 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5912 +* fix(deps): update dependency io.sentry:sentry-android to v8.29.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5918 +* fix(deps): update dependency com.google.firebase:firebase-bom to v34.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5915 +* fix(deps): update haze to v1.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5712 +* chore(deps): update peter-evans/create-pull-request action to v8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5878 +* fix(deps): update dependency com.posthog:posthog-android to v3.27.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5871 +* chore(deps): update plugin sonarqube to v7.2.1.6560 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5905 +* fix(deps): update metro to v0.9.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5920 +* fix(deps): update activity to v1.12.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5924 +* Update plugin sonarqube to v7.2.2.6593 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5927 +* fix(deps): update media3 to v1.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5931 +* fix(deps): update metro to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5940 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5939 +* fix(deps): update dependency com.google.zxing:core to v3.5.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5935 +* Upgrade robolectric to version 4.16 by @bmarty in https://github.com/element-hq/element-x-android/pull/5923 +* fix(deps): update dependency androidx.webkit:webkit to v1.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5925 +* chore(deps): update github artifact actions (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5932 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5883 +* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5916 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5943 +* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5917 +* fix(deps): update dependency com.posthog:posthog-android to v3.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5941 +* fix(deps): update wysiwyg to v2.41.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5921 +* fix(deps): update roborazzi to v1.53.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5962 +* fix(deps): update roborazzi to v1.54.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5970 +* fix(deps): update dependency org.unifiedpush.android:connector to v3.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5972 +* fix(deps): update metro to v0.9.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5967 +* Upgrade compose to 2025.12.01 by @bmarty in https://github.com/element-hq/element-x-android/pull/5969 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5977 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5986 +* fix(deps): update roborazzi to v1.56.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5987 +* fix(deps): update dependency com.posthog:posthog-android to v3.28.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5988 +* fix(deps): update metro to v0.9.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5991 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.12 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5999 +### Others +* Enable Sentry in the SDK and allow bridging spans by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5808 +* Add alert to encrypted rooms with visible history (Android). by @kaylendog in https://github.com/element-hq/element-x-android/pull/5709 +* Add accessibility to the "sending" picto. by @bmarty in https://github.com/element-hq/element-x-android/pull/5869 +* Add SDK database vacuuming operations by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5858 +* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.2 by @bmarty in https://github.com/element-hq/element-x-android/pull/5897 +* RoomSummary: move the icon related to the last message state on start of the message. by @bmarty in https://github.com/element-hq/element-x-android/pull/5888 +* Qr code scanner cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/5891 +* Design : update user rows by @ganfra in https://github.com/element-hq/element-x-android/pull/5900 +* misc : rework power levels apis by @ganfra in https://github.com/element-hq/element-x-android/pull/5879 +* Fix preview name by @bmarty in https://github.com/element-hq/element-x-android/pull/5919 +* Allow uploading extra data to Sentry when analytics is enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5910 +* Show history visibility banner strictly for `shared` rooms instead of `invited`. by @kaylendog in https://github.com/element-hq/element-x-android/pull/5936 +* Simplify the copy of the history visibility settings by @bmarty in https://github.com/element-hq/element-x-android/pull/5942 +* Use only font from compound by @bmarty in https://github.com/element-hq/element-x-android/pull/5945 +* Cleanup FFI object fixtures. by @bmarty in https://github.com/element-hq/element-x-android/pull/5957 +* Add variable playback speed feature for voice messages by @Medformatik in https://github.com/element-hq/element-x-android/pull/5504 +* Ensure that avatars always have a content description. by @bmarty in https://github.com/element-hq/element-x-android/pull/5968 +* Ensure space feature is enabled by @ganfra in https://github.com/element-hq/element-x-android/pull/5960 +* Adjust metrics to the new specifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5937 +* Use `TextFieldState` for room list search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5975 +* fix(deps): update roborazzi to v1.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5976 +* Iterate on verification screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5981 +* Add preview with a11y details. by @bmarty in https://github.com/element-hq/element-x-android/pull/5984 +* Change the title for `AnalyticsTransactions.coldStart` and `.catchUp` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5998 +* [a11y] voice message improvements by @bmarty in https://github.com/element-hq/element-x-android/pull/5980 + +## New Contributors +* @Medformatik made their first contribution in https://github.com/element-hq/element-x-android/pull/5504 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.12.0...v26.01.0 + Changes in Element X v25.12.0 ============================= From df3fe6d6d68cfe1cef597a51869a2b916ba9bfd2 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 14 Jan 2026 15:27:06 +0100 Subject: [PATCH 04/29] Fix wrong param order for removeChildFromSpace --- .../android/libraries/matrix/impl/spaces/RustSpaceService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 41aae086fa..17a9186351 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -81,7 +81,7 @@ class RustSpaceService( override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = withContext(sessionDispatcher) { runCatchingExceptions { - innerSpaceService.removeChildFromSpace(spaceId.value, childId.value) + innerSpaceService.removeChildFromSpace(childId = childId.value, spaceId = spaceId.value) } } From f50165807daaa7dde5550775367077b294eef137 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 14 Jan 2026 15:29:18 +0100 Subject: [PATCH 05/29] Iterate on removing space child rooms. --- .../space/impl/root/SpacePresenter.kt | 34 ++++++++++++++++--- .../features/space/impl/root/SpaceState.kt | 3 +- .../features/space/impl/root/SpaceView.kt | 27 ++++++++++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index abcb4b444e..e18f086834 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -112,6 +112,23 @@ class SpacePresenter( var isManageMode by remember { mutableStateOf(false) } var selectedRoomIds by remember { mutableStateOf>(emptySet()) } var removeRoomsAction by remember { mutableStateOf>(AsyncAction.Uninitialized) } + var removedRoomIds by remember { mutableStateOf>(emptySet()) } + + val filteredChildren by remember { + derivedStateOf { + children + .filterNot { it.roomId in removedRoomIds } + .let { list -> + if (isManageMode) { + // In manage mode, only show rooms (not spaces) + list.filter { !it.isSpace } + } else { + list + } + } + .toImmutableList() + } + } LaunchedEffect(children) { // Remove joined children from the join actions @@ -171,10 +188,19 @@ class SpacePresenter( localCoroutineScope.launch { removeRoomsAction = AsyncAction.Loading val spaceId = spaceRoomList.roomId - val results = selectedRoomIds.map { roomId -> - async { spaceService.removeChildFromSpace(spaceId, roomId) } + val roomsToRemove = selectedRoomIds.toSet() + val successfullyRemoved = mutableSetOf() + val results = roomsToRemove.map { roomId -> + async { + spaceService.removeChildFromSpace(spaceId, roomId) + .onSuccess { successfullyRemoved.add(roomId) } + } } - val hasError = results.awaitAll().any { it.isFailure } + results.awaitAll() + if (successfullyRemoved.isNotEmpty()) { + removedRoomIds = removedRoomIds + successfullyRemoved + } + val hasError = successfullyRemoved.size < roomsToRemove.size if (hasError) { removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms")) } else { @@ -191,7 +217,7 @@ class SpacePresenter( } return SpaceState( currentSpace = currentSpace.getOrNull(), - children = children, + children = filteredChildren, seenSpaceInvites = seenSpaceInvites, hideInvitesAvatar = hideInvitesAvatar, hasMoreToLoad = hasMoreToLoad, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt index e33d4730d1..05004ddace 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -34,11 +34,12 @@ data class SpaceState( val eventSink: (SpaceEvents) -> Unit ) { fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading + fun isSelected(spaceId: RoomId): Boolean = selectedRoomIds.contains(spaceId) val hasAnyFailure: Boolean = joinActions.values.any { it is AsyncAction.Failure } - val showManageRoomsAction: Boolean = canManageRooms && children.isNotEmpty() + val showManageRoomsAction: Boolean = canManageRooms && children.any { spaceRoom -> !spaceRoom.isSpace } val selectedCount: Int = selectedRoomIds.size val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty() } 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 56c932f968..541c225b99 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 @@ -8,6 +8,7 @@ package io.element.android.features.space.impl.root +import androidx.activity.compose.BackHandler import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -42,10 +43,9 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule -import io.element.android.libraries.designsystem.components.async.AsyncActionView -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet +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 import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState @@ -54,18 +54,19 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem -import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.IconButton -import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.spaces.SpaceRoom @@ -90,6 +91,15 @@ fun SpaceView( modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { + + BackHandler { + if (state.isManageMode) { + state.eventSink(SpaceEvents.ExitManageMode) + } else { + onBackClick() + } + } + Scaffold( modifier = modifier, topBar = { @@ -230,7 +240,7 @@ private fun SpaceViewContent( ) { index, spaceRoom -> val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED val isCurrentlyJoining = state.isJoining(spaceRoom.roomId) - val isSelected = spaceRoom.roomId in state.selectedRoomIds + val isSelected = state.isSelected(spaceRoom.roomId) SpaceRoomItemView( spaceRoom = spaceRoom, showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, @@ -530,6 +540,13 @@ private fun RemoveRoomsConfirmationDialog( onSubmitClick = onConfirm, onDismiss = onDismiss, destructiveSubmit = true, + icon = { + Icon( + imageVector = CompoundIcons.Error(), + tint = ElementTheme.colors.textCriticalPrimary, + contentDescription = null + ) + } ) } else -> { From 15c7cbade6d3ca22a548128e3e1c2f50035aabcb Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 14 Jan 2026 16:39:57 +0100 Subject: [PATCH 06/29] Animate transition from/to space manage rooms mode. --- .../features/space/impl/root/SpaceView.kt | 91 ++++++++++++------- 1 file changed, 58 insertions(+), 33 deletions(-) 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 541c225b99..c733029d51 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 @@ -10,9 +10,17 @@ package io.element.android.features.space.impl.root import androidx.activity.compose.BackHandler import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideIn +import androidx.compose.animation.veilOut import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -103,25 +111,36 @@ fun SpaceView( Scaffold( modifier = modifier, topBar = { - if (state.isManageMode) { - ManageModeTopBar( - selectedCount = state.selectedCount, - isRemoveButtonEnabled = state.isRemoveButtonEnabled, - onCancelClick = { state.eventSink(SpaceEvents.ExitManageMode) }, - onRemoveClick = { state.eventSink(SpaceEvents.RemoveSelectedRooms) }, - ) - } else { - SpaceViewTopBar( - currentSpace = state.currentSpace, - canAccessSpaceSettings = state.canAccessSpaceSettings, - showManageRoomsAction = state.showManageRoomsAction, - onBackClick = onBackClick, - onLeaveSpaceClick = onLeaveSpaceClick, - onShareSpace = onShareSpace, - onSettingsClick = onSettingsClick, - onViewMembersClick = onViewMembersClick, - onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) }, - ) + Box { + AnimatedVisibility( + visible = state.isManageMode, + enter = fadeIn(), + exit = fadeOut() + ) { + ManageModeTopBar( + selectedCount = state.selectedCount, + isRemoveButtonEnabled = state.isRemoveButtonEnabled, + onCancelClick = { state.eventSink(SpaceEvents.ExitManageMode) }, + onRemoveClick = { state.eventSink(SpaceEvents.RemoveSelectedRooms) }, + ) + } + AnimatedVisibility( + visible = !state.isManageMode, + enter = fadeIn(), + exit = fadeOut() + ) { + SpaceViewTopBar( + currentSpace = state.currentSpace, + canAccessSpaceSettings = state.canAccessSpaceSettings, + showManageRoomsAction = state.showManageRoomsAction, + onBackClick = onBackClick, + onLeaveSpaceClick = onLeaveSpaceClick, + onShareSpace = onShareSpace, + onSettingsClick = onSettingsClick, + onViewMembersClick = onViewMembersClick, + onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) }, + ) + } } }, content = { padding -> @@ -218,20 +237,26 @@ private fun SpaceViewContent( LazyColumn(modifier.fillMaxSize()) { val currentSpace = state.currentSpace if (currentSpace != null) { - item { - SpaceHeaderView( - avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader), - name = currentSpace.displayName, - topic = currentSpace.topic, - topicMaxLines = 2, - visibility = currentSpace.visibility, - heroes = currentSpace.heroes.toImmutableList(), - numberOfMembers = currentSpace.numJoinedMembers, - onTopicClick = onTopicClick - ) - } - item { - HorizontalDivider() + item(key = "space_header") { + AnimatedVisibility( + !state.isManageMode, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + SpaceHeaderView( + avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader), + name = currentSpace.displayName, + topic = currentSpace.topic, + topicMaxLines = 2, + visibility = currentSpace.visibility, + heroes = currentSpace.heroes.toImmutableList(), + numberOfMembers = currentSpace.numJoinedMembers, + onTopicClick = onTopicClick + ) + HorizontalDivider() + } + } } } itemsIndexed( From 525e9b5d5078ff4e95d65c48b8e5d712d24e5c6c Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 14 Jan 2026 17:46:59 +0100 Subject: [PATCH 07/29] Hide unread count in manage space rooms mode --- .../io/element/android/features/space/impl/root/SpaceView.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 c733029d51..31e70c5ff7 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 @@ -266,9 +266,10 @@ private fun SpaceViewContent( val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED val isCurrentlyJoining = state.isJoining(spaceRoom.roomId) val isSelected = state.isSelected(spaceRoom.roomId) + val showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites && !state.isManageMode SpaceRoomItemView( spaceRoom = spaceRoom, - showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, + showUnreadIndicator = showUnreadIndicator, hideAvatars = isInvitation && state.hideInvitesAvatar, onClick = { onRoomClick(spaceRoom) From 8b8151722a9908dcf6fa8b4cafa7c76377f715bc Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 14 Jan 2026 17:47:20 +0100 Subject: [PATCH 08/29] Add tests for space manage rooms mode --- .../space/impl/root/SpacePresenterTest.kt | 211 +++++++++++++++++- .../space/impl/root/SpaceStateTest.kt | 78 +++++++ .../features/space/impl/root/SpaceViewTest.kt | 68 ++++++ 3 files changed, 356 insertions(+), 1 deletion(-) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index 65a4c76783..08fb977d1b 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -22,16 +22,18 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList -import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID_3 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom @@ -355,6 +357,213 @@ class SpacePresenterTest { } } + @Test + fun `present - enter manage mode`() = runTest { + val presenter = createSpacePresenter() + presenter.test { + val state = awaitItem() + assertThat(state.isManageMode).isFalse() + state.eventSink(SpaceEvents.EnterManageMode) + val manageModeState = awaitItem() + assertThat(manageModeState.isManageMode).isTrue() + assertThat(manageModeState.selectedRoomIds).isEmpty() + } + } + + @Test + fun `present - exit manage mode clears selection`() = runTest { + val presenter = createSpacePresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(SpaceEvents.EnterManageMode) + initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + initialState.eventSink(SpaceEvents.ExitManageMode) + val finalState = expectMostRecentItem() + assertThat(finalState.isManageMode).isFalse() + assertThat(finalState.selectedRoomIds).isEmpty() + } + } + + @Test + fun `present - toggle room selection`() = runTest { + val presenter = createSpacePresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(SpaceEvents.EnterManageMode) + // Select a room + initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + var latestState = expectMostRecentItem() + assertThat(latestState.selectedRoomIds).containsExactly(A_ROOM_ID) + // Deselect the room + latestState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + latestState = expectMostRecentItem() + assertThat(latestState.selectedRoomIds).isEmpty() + } + } + + @Test + fun `present - remove rooms success`() = runTest { + val removeChildFromSpaceResult = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val aRoom = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = fakeSpaceRoomList, + spaceService = FakeSpaceService( + removeChildFromSpaceResult = removeChildFromSpaceResult, + ), + ) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + assertThat(stateWithChildren.children).hasSize(1) + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms) + stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval) + advanceUntilIdle() + val successState = expectMostRecentItem() + assertThat(successState.removeRoomsAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(successState.isManageMode).isFalse() + assertThat(successState.children).isEmpty() + removeChildFromSpaceResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - remove rooms partial failure`() = runTest { + val aRoom1 = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val aRoom2 = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Room, + ) + val removeChildFromSpaceResult = lambdaRecorder> { _, childId -> + if (childId == A_ROOM_ID_2) Result.failure(AN_EXCEPTION) + else Result.success(Unit) + } + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom1, aRoom2), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = fakeSpaceRoomList, + spaceService = FakeSpaceService( + removeChildFromSpaceResult = removeChildFromSpaceResult, + ), + ) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + assertThat(stateWithChildren.children).hasSize(2) + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID_2)) + stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms) + stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval) + advanceUntilIdle() + val failureState = expectMostRecentItem() + assertThat(failureState.removeRoomsAction.isFailure()).isTrue() + // Successfully removed room should be filtered out + assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID) + // Failed room should still be present + assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2) + removeChildFromSpaceResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - children filtered in manage mode shows only rooms`() = runTest { + val aRoom = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val aSubSpace = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Space, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom, aSubSpace), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + // Both room and space visible initially + assertThat(stateWithChildren.children).hasSize(2) + assertThat(stateWithChildren.isManageMode).isFalse() + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + val manageModeState = expectMostRecentItem() + // Only rooms visible in manage mode + assertThat(manageModeState.children).hasSize(1) + assertThat(manageModeState.children.first().roomId).isEqualTo(A_ROOM_ID) + assertThat(manageModeState.children.first().isSpace).isFalse() + } + } + + @Test + fun `present - removed rooms persist after flow update`() = runTest { + val removeChildFromSpaceResult = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val aRoom1 = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val aRoom2 = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Room, + ) + val aRoom3 = aSpaceRoom( + roomId = A_ROOM_ID_3, + roomType = RoomType.Room, + ) + val spaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom1, aRoom2), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = spaceRoomList, + spaceService = FakeSpaceService( + removeChildFromSpaceResult = removeChildFromSpaceResult, + ), + ) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms) + stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval) + advanceUntilIdle() + val successState = expectMostRecentItem() + assertThat(successState.children.map { it.roomId }).doesNotContain(A_ROOM_ID) + // Emit new flow update with a new room added (simulating server refresh) + spaceRoomList.emitSpaceRooms(listOf(aRoom1, aRoom2, aRoom3)) + advanceUntilIdle() + val afterFlowUpdate = awaitItem() + // A_ROOM_ID should still be filtered out even though it's in the new emission + assertThat(afterFlowUpdate.children.map { it.roomId }).doesNotContain(A_ROOM_ID) + // But the other rooms should be present + assertThat(afterFlowUpdate.children.map { it.roomId }).contains(A_ROOM_ID_2) + assertThat(afterFlowUpdate.children.map { it.roomId }).contains(A_ROOM_ID_3) + } + } + private fun TestScope.createSpacePresenter( client: MatrixClient = FakeMatrixClient(), room: BaseRoom = FakeBaseRoom(), diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt index 440ec1b6a5..a0c3635baf 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt @@ -14,6 +14,8 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.previewutils.room.aSpaceRoom import org.junit.Test class SpaceStateTest { @@ -45,4 +47,80 @@ class SpaceStateTest { ) assertThat(state.isJoining(A_ROOM_ID)).isTrue() } + + @Test + fun `test isSelected returns true for selected room`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID) + ) + assertThat(state.isSelected(A_ROOM_ID)).isTrue() + } + + @Test + fun `test isSelected returns false for non-selected room`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID) + ) + assertThat(state.isSelected(A_ROOM_ID_2)).isFalse() + } + + @Test + fun `test showManageRoomsAction true when canManageRooms and has room children`() { + val state = aSpaceState( + canManageRooms = true, + children = listOf(aSpaceRoom(roomType = RoomType.Room)) + ) + assertThat(state.showManageRoomsAction).isTrue() + } + + @Test + fun `test showManageRoomsAction false when canManageRooms but children empty`() { + val state = aSpaceState( + canManageRooms = true, + children = emptyList() + ) + assertThat(state.showManageRoomsAction).isFalse() + } + + @Test + fun `test showManageRoomsAction false when canManageRooms but only space children`() { + val state = aSpaceState( + canManageRooms = true, + children = listOf(aSpaceRoom(roomType = RoomType.Space)) + ) + assertThat(state.showManageRoomsAction).isFalse() + } + + @Test + fun `test showManageRoomsAction false when has room children but canManageRooms false`() { + val state = aSpaceState( + canManageRooms = false, + children = listOf(aSpaceRoom(roomType = RoomType.Room)) + ) + assertThat(state.showManageRoomsAction).isFalse() + } + + @Test + fun `test selectedCount returns correct count`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3) + ) + assertThat(state.selectedCount).isEqualTo(3) + } + + @Test + fun `test isRemoveButtonEnabled true when selectedRoomIds not empty`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID) + ) + assertThat(state.isRemoveButtonEnabled).isTrue() + } + + @Test + fun `test isRemoveButtonEnabled false when selectedRoomIds empty`() { + val state = aSpaceState( + selectedRoomIds = emptySet() + ) + assertThat(state.isRemoveButtonEnabled).isFalse() + } } 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 406b5d17e8..3ef5151a50 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 @@ -12,9 +12,11 @@ import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -29,6 +31,7 @@ import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -132,6 +135,71 @@ class SpaceViewTest { rule.onNodeWithText(A_ROOM_TOPIC).performClick() eventsRecorder.assertSingle(SpaceEvents.ShowTopicViewer(A_ROOM_TOPIC)) } + + @Test + fun `clicking back in manage mode emits ExitManageMode event`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + hasMoreToLoad = false, + isManageMode = true, + eventSink = eventsRecorder, + ) + ) + rule.pressBackKey() + eventsRecorder.assertSingle(SpaceEvents.ExitManageMode) + } + + @Test + fun `clicking on room in manage mode emits ToggleRoomSelection event`() { + val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, displayName = A_ROOM_NAME) + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom), + hasMoreToLoad = false, + isManageMode = true, + eventSink = eventsRecorder, + ) + ) + rule.onNodeWithText(A_ROOM_NAME).performClick() + eventsRecorder.assertSingle(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + } + + @Test + fun `clicking remove button emits RemoveSelectedRooms event`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom(roomId = A_ROOM_ID)), + hasMoreToLoad = false, + isManageMode = true, + selectedRoomIds = setOf(A_ROOM_ID), + eventSink = eventsRecorder, + ) + ) + rule.clickOn(CommonStrings.action_remove) + eventsRecorder.assertSingle(SpaceEvents.RemoveSelectedRooms) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking confirm in removal dialog emits ConfirmRoomRemoval event`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom(roomId = A_ROOM_ID)), + hasMoreToLoad = false, + isManageMode = true, + selectedRoomIds = setOf(A_ROOM_ID), + removeRoomsAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder, + ) + ) + // Click on the Remove button in the confirmation dialog + rule.clickOn(CommonStrings.action_remove, inDialog = true) + eventsRecorder.assertSingle(SpaceEvents.ConfirmRoomRemoval) + } } private fun AndroidComposeTestRule.setSpaceView( From 619098009747da8ca40aee20b5e13f9b9a8ce0e2 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Fri, 2 Jan 2026 16:03:31 +0100 Subject: [PATCH 09/29] Signin with Element Classic WIP --- .../login/impl/src/main/AndroidManifest.xml | 2 + .../features/login/impl/di/LoginModule.kt | 5 + .../screens/onboarding/OnBoardingPresenter.kt | 5 + .../screens/onboarding/OnBoardingState.kt | 2 + .../onboarding/OnBoardingStateProvider.kt | 4 + .../impl/screens/onboarding/OnBoardingView.kt | 56 ++++ .../ConfirmingLoginWithElementClassic.kt | 15 ++ .../classic/ElementClassicConnection.kt | 251 ++++++++++++++++++ .../classic/LoginWithClassicEvent.kt | 15 ++ .../classic/LoginWithClassicPresenter.kt | 103 +++++++ .../classic/LoginWithClassicState.kt | 16 ++ .../classic/LoginWithClassicStateProvider.kt | 20 ++ .../onboarding/OnBoardingPresenterTest.kt | 7 +- .../classic/FakeElementClassicConnection.kt | 29 ++ .../classic/LoginWithClassicPresenterTest.kt | 213 +++++++++++++++ .../libraries/featureflag/api/FeatureFlags.kt | 7 + 16 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt create mode 100644 features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt create mode 100644 features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml index 453cf05132..f2d84131a7 100644 --- a/features/login/impl/src/main/AndroidManifest.xml +++ b/features/login/impl/src/main/AndroidManifest.xml @@ -15,4 +15,6 @@ + + diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt index 4523e6f45e..12b9106b71 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt @@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds import dev.zacsweers.metro.ContributesTo import io.element.android.features.login.impl.changeserver.ChangeServerPresenter import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicPresenter +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.Presenter @ContributesTo(AppScope::class) @@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter interface LoginModule { @Binds fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter + + @Binds + fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 4d83c45a44..741f65234e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -26,6 +26,7 @@ import io.element.android.features.enterprise.api.canConnectToAnyHomeserver import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta @@ -44,6 +45,7 @@ class OnBoardingPresenter( private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider, private val sessionStore: SessionStore, private val accountProviderDataSource: AccountProviderDataSource, + private val loginWithClassicPresenter: Presenter, ) : Presenter { @AssistedFactory interface Factory { @@ -99,6 +101,8 @@ class OnBoardingPresenter( val loginMode by loginHelper.collectLoginMode() + val loginWithClassicState = loginWithClassicPresenter.present() + fun handleEvent(event: OnBoardingEvents) { when (event) { is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch { @@ -132,6 +136,7 @@ class OnBoardingPresenter( loginMode = loginMode, version = buildMeta.versionName, onBoardingLogoResId = onBoardingLogoResId, + loginWithClassicState = loginWithClassicState, eventSink = ::handleEvent, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index db6c3573f9..703120b260 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -10,6 +10,7 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.annotation.DrawableRes import io.element.android.features.login.impl.login.LoginMode +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.AsyncData data class OnBoardingState( @@ -24,6 +25,7 @@ data class OnBoardingState( @DrawableRes val onBoardingLogoResId: Int?, val loginMode: AsyncData, + val loginWithClassicState: LoginWithClassicState, val eventSink: (OnBoardingEvents) -> Unit, ) { val submitEnabled: Boolean diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt index d7db27ca0b..76f8eb3513 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -11,6 +11,8 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.annotation.DrawableRes import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.login.impl.login.LoginMode +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState +import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.R @@ -44,6 +46,7 @@ fun anOnBoardingState( @DrawableRes customLogoResId: Int? = null, loginMode: AsyncData = AsyncData.Uninitialized, + loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(), eventSink: (OnBoardingEvents) -> Unit = {}, ) = OnBoardingState( isAddingAccount = isAddingAccount, @@ -56,5 +59,6 @@ fun anOnBoardingState( version = version, loginMode = loginMode, onBoardingLogoResId = customLogoResId, + loginWithClassicState = loginWithClassicState, eventSink = eventSink, ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index 977c6de71c..d590f1fec8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -31,10 +31,15 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.login.impl.R import io.element.android.features.login.impl.login.LoginModeView +import io.element.android.features.login.impl.screens.onboarding.classic.ConfirmingLoginWithElementClassic +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicEvent +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize @@ -42,6 +47,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button @@ -109,6 +116,43 @@ fun OnBoardingView( buttons = buttons, ) } + + LoginWithElementClassicView( + state = state.loginWithClassicState, + ) +} + +@Composable +private fun LoginWithElementClassicView( + state: LoginWithClassicState, +) { + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + state.eventSink(LoginWithClassicEvent.RefreshData) + } + AsyncActionView( + async = state.loginWithClassicAction, + confirmationDialog = { confirming -> + when (confirming) { + is ConfirmingLoginWithElementClassic -> { + // TODO i18n + ConfirmationDialog( + title = "Sign in with Element Classic", + content = "You are signing in as ${confirming.userId} on Element Classic." + + " Your existing session on Element Classic will not be signed out. Do you want to continue?", + submitText = stringResource(CommonStrings.action_continue), + onSubmitClick = { state.eventSink(LoginWithClassicEvent.DoLoginWithClassic) }, + onDismiss = { state.eventSink(LoginWithClassicEvent.CloseDialog) }, + ) + } + } + }, + onErrorDismiss = { + state.eventSink(LoginWithClassicEvent.CloseDialog) + }, + onSuccess = { + // noop, the view will be closed + } + ) } @Composable @@ -239,6 +283,18 @@ private fun OnBoardingButtons( } else { CommonStrings.action_continue } + if (state.loginWithClassicState.canLoginWithClassic) { + Button( + text = "Sign in with Element Classic", + leadingIcon = IconSource.Vector(CompoundIcons.Mobile()), + onClick = { + state.loginWithClassicState.eventSink( + LoginWithClassicEvent.StartLoginWithClassic + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } if (state.canLoginWithQrCode) { Button( text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code), diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt new file mode 100644 index 0000000000..5fae0afdd5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt @@ -0,0 +1,15 @@ +/* + * 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.login.impl.screens.onboarding.classic + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.UserId + +class ConfirmingLoginWithElementClassic( + val userId: UserId, +) : AsyncAction.Confirming diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt new file mode 100644 index 0000000000..29a4f9b3fc --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt @@ -0,0 +1,251 @@ +/* + * 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.login.impl.screens.onboarding.classic + +import android.content.ComponentName +import android.content.Context +import android.content.Context.BIND_AUTO_CREATE +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +interface ElementClassicConnection { + fun start() + fun stop() + fun requestData() + val state: StateFlow +} + +sealed interface ElementClassicConnectionState { + object Idle : ElementClassicConnectionState + object ElementClassicNotFound : ElementClassicConnectionState + object ElementClassicReadyNoSession : ElementClassicConnectionState + data class ElementClassicReady(val userId: UserId) : ElementClassicConnectionState + data class Error(val error: String) : ElementClassicConnectionState +} + +private val loggerTag = LoggerTag("ECConnection") + +@ContributesBinding(AppScope::class) +class DefaultElementClassicConnection( + @ApplicationContext + private val context: Context, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val buildMeta: BuildMeta, +) : ElementClassicConnection { + // Messenger for communicating with the service. + private var messenger: Messenger? = null + + // Target we publish for external service to send messages to IncomingHandler. + private val incomingMessenger: Messenger = Messenger(IncomingHandler()) + + // Flag indicating whether we have called bind on the service. + private var bound: Boolean = false + + /** + * Class for interacting with the main interface of the service. + */ + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + Timber.tag(loggerTag.value).d("onServiceConnected") + // This is called when the connection with the service has been + // established, giving us the object we can use to + // interact with the service. We are communicating with the + // service using a Messenger, so here we get a client-side + // representation of that from the raw IBinder object. + messenger = Messenger(service) + bound = true + // Request the data as soon as possible + requestData() + } + + override fun onServiceDisconnected(className: ComponentName) { + Timber.tag(loggerTag.value).d("onServiceDisconnected") + // This is called when the connection with the service has been + // unexpectedly disconnected—that is, its process crashed. + messenger = null + bound = false + } + } + + override fun start() { + Timber.tag(loggerTag.value).w("start()") + coroutineScope.launch { + // Establish a connection with the service. We use an explicit + // class name because there is no reason to be able to let other + // applications replace our component. + try { + val intentService = Intent() + intentService.setComponent(getElementClassicComponent(buildMeta)) + if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { + Timber.tag(loggerTag.value).d("Binding returned true") + } else { + // This happen when the app is not installed + Timber.tag(loggerTag.value).d("Binding returned false") + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) + } + } catch (e: SecurityException) { + Timber.tag(loggerTag.value).e(e, "Can't bind to Service") + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + + override fun stop() { + Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") + if (bound) { + // Detach our existing connection. + context.unbindService(serviceConnection) + bound = false + } + coroutineScope.launch { + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Idle) + } + } + + override fun requestData() { + Timber.tag(loggerTag.value).w("requestData()") + coroutineScope.launch { + val finalMessenger = messenger + if (finalMessenger == null) { + Timber.tag(loggerTag.value).w("The messenger is null, can't request data") + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data")) + } else { + try { + // Get the data + val msg = Message.obtain(null, MSG_GET_DATA) + msg.replyTo = incomingMessenger + finalMessenger.send(msg) + } catch (e: RemoteException) { + // In this case the service has crashed before we could even + // do anything with it; we can count on soon being + // disconnected (and then reconnected if it can be restarted) + // so there is no need to do anything here. + Timber.tag(loggerTag.value).e(e, "RemoteException") + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + } + + private val elementClassicConnectionStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + + override val state: StateFlow + get() = elementClassicConnectionStateFlow.asStateFlow() + + /** + * Handler of incoming messages from service. + */ + @Suppress("DEPRECATION") + inner class IncomingHandler : Handler() { + override fun handleMessage(msg: Message) { + Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}") + when (msg.what) { + MSG_GET_DATA -> { + // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied + val state = msg.data.toElementClassicConnectionState() + emitElementClassicState(state) + } + else -> { + super.handleMessage(msg) + } + } + } + } + + private fun emitElementClassicState(state: ElementClassicConnectionState) = coroutineScope.launch { + when (state) { + is ElementClassicConnectionState.Error -> { + Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error) + elementClassicConnectionStateFlow.emit(state) + } + is ElementClassicConnectionState.ElementClassicReady -> { + Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId) + elementClassicConnectionStateFlow.emit(state) + } + ElementClassicConnectionState.ElementClassicReadyNoSession -> { + Timber.tag(loggerTag.value).d("Received no session from Element Classic") + elementClassicConnectionStateFlow.emit(state) + } + else -> { + // Should not happen + Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state) + elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Idle) + } + } + } + + private fun getElementClassicComponent(buildMeta: BuildMeta) = ComponentName( + buildString { + append(ELEMENT_CLASSIC_APP_ID) + append( + when (buildMeta.buildType) { + BuildType.DEBUG -> ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX + BuildType.NIGHTLY -> ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX + BuildType.RELEASE -> ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX + } + ) + }, + ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, + ) + + private fun Bundle?.toElementClassicConnectionState(): ElementClassicConnectionState { + return if (this == null) { + ElementClassicConnectionState.Error("No data received from Element Classic") + } else { + val error = getString(KEY_ERROR_STR) + if (error != null) { + ElementClassicConnectionState.Error(error) + } else { + val userId = getString(KEY_USER_ID_STR)?.let(::UserId) + if (userId != null) { + ElementClassicConnectionState.ElementClassicReady(userId) + } else { + ElementClassicConnectionState.ElementClassicReadyNoSession + } + } + } + } + + // Everything in this companion object must match what is defined in Element Classic + private companion object { + // Command to the service to get the data. + const val MSG_GET_DATA = 1 + + const val ELEMENT_CLASSIC_APP_ID = "im.vector.app" + const val ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX = ".debug" + const val ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX = ".nightly" + const val ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX = "" + + const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService" + + // Keys for the bundle returned from the service + const val KEY_ERROR_STR = "error" + const val KEY_USER_ID_STR = "userId" + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt new file mode 100644 index 0000000000..75a9496a02 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt @@ -0,0 +1,15 @@ +/* + * 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.login.impl.screens.onboarding.classic + +sealed interface LoginWithClassicEvent { + data object RefreshData : LoginWithClassicEvent + data object StartLoginWithClassic : LoginWithClassicEvent + data object DoLoginWithClassic : LoginWithClassicEvent + data object CloseDialog : LoginWithClassicEvent +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt new file mode 100644 index 0000000000..d962c5978a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt @@ -0,0 +1,103 @@ +/* + * 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.login.impl.screens.onboarding.classic + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.toUserListFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Inject +class LoginWithClassicPresenter( + private val elementClassicConnection: ElementClassicConnection, + private val sessionStore: SessionStore, + private val featureFlagService: FeatureFlagService, +) : Presenter { + @Composable + override fun present(): LoginWithClassicState { + val coroutineScope = rememberCoroutineScope() + + val isSignInWithClassicEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.SignInWithClassic) + }.collectAsState(initial = false) + + if (isSignInWithClassicEnabled) { + DisposableEffect(Unit) { + elementClassicConnection.start() + onDispose { + elementClassicConnection.stop() + } + } + } + + val state by elementClassicConnection.state.collectAsState() + val loginWithClassicAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + val existingSession by remember { + sessionStore.sessionsFlow().toUserListFlow() + }.collectAsState(emptyList()) + + val canLoginWithClassic by remember { + derivedStateOf { + when (val finalState = state) { + is ElementClassicConnectionState.ElementClassicReady -> { + // Ensure there is no existing session with the same Id. + finalState.userId.value !in existingSession && isSignInWithClassicEnabled + } + else -> false + } + } + } + + fun handleEvent(event: LoginWithClassicEvent) { + when (event) { + LoginWithClassicEvent.RefreshData -> { + elementClassicConnection.requestData() + } + LoginWithClassicEvent.StartLoginWithClassic -> { + val currentState = elementClassicConnection.state.value + if (currentState is ElementClassicConnectionState.ElementClassicReady) { + loginWithClassicAction.value = ConfirmingLoginWithElementClassic( + userId = currentState.userId, + ) + } else { + loginWithClassicAction.value = AsyncAction.Failure(IllegalStateException("Element Classic is not ready")) + } + } + LoginWithClassicEvent.DoLoginWithClassic -> coroutineScope.launch { + // TODO Implement real login logic here + loginWithClassicAction.value = AsyncAction.Loading + delay(1000) + loginWithClassicAction.value = AsyncAction.Success(Unit) + } + LoginWithClassicEvent.CloseDialog -> { + loginWithClassicAction.value = AsyncAction.Uninitialized + } + } + } + + return LoginWithClassicState( + canLoginWithClassic = canLoginWithClassic, + loginWithClassicAction = loginWithClassicAction.value, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt new file mode 100644 index 0000000000..d2706fc24a --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt @@ -0,0 +1,16 @@ +/* + * 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.login.impl.screens.onboarding.classic + +import io.element.android.libraries.architecture.AsyncAction + +data class LoginWithClassicState( + val canLoginWithClassic: Boolean, + val loginWithClassicAction: AsyncAction, + val eventSink: (LoginWithClassicEvent) -> Unit, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt new file mode 100644 index 0000000000..73f68e5d61 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt @@ -0,0 +1,20 @@ +/* + * 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.login.impl.screens.onboarding.classic + +import io.element.android.libraries.architecture.AsyncAction + +fun aLoginWithClassicState( + canLoginWithClassic: Boolean = false, + loginWithClassicAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (LoginWithClassicEvent) -> Unit = {}, +) = LoginWithClassicState( + canLoginWithClassic = canLoginWithClassic, + loginWithClassicAction = loginWithClassicAction, + eventSink = eventSink, +) diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 1d434997ca..1e971ef265 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.wellknown.test.FakeWellknownRetriever @@ -88,7 +89,10 @@ class OnBoardingPresenterTest { assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canReportBug).isFalse() assertThat(initialState.isAddingAccount).isFalse() - assertThat(awaitItem().canLoginWithQrCode).isTrue() + assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse() + val finalState = awaitItem() + assertThat(finalState.canLoginWithQrCode).isTrue() + assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse() } } @@ -283,6 +287,7 @@ private fun createPresenter( onBoardingLogoResIdProvider = onBoardingLogoResIdProvider, sessionStore = sessionStore, accountProviderDataSource = accountProviderDataSource, + loginWithClassicPresenter = { aLoginWithClassicState() }, ) fun createLoginHelper( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt new file mode 100644 index 0000000000..9f2e76e75d --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt @@ -0,0 +1,29 @@ +/* + * 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.login.impl.screens.onboarding.classic + +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeElementClassicConnection( + private val startResult: () -> Unit = { lambdaError() }, + private val stopResult: () -> Unit = { lambdaError() }, + private val requestDataResult: () -> Unit = { lambdaError() }, + initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle +) : ElementClassicConnection { + override fun start() = startResult() + override fun stop() = stopResult() + override fun requestData() = requestDataResult() + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + suspend fun emitState(state: ElementClassicConnectionState) { + _state.emit(state) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt new file mode 100644 index 0000000000..8a8e4985c9 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt @@ -0,0 +1,213 @@ +/* + * 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.login.impl.screens.onboarding.classic + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LoginWithClassicPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state - feature disabled - start is not invoked`() = runTest { + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = { + error("start should not be invoked when feature is disabled") + }, + ) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canLoginWithClassic).isFalse() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - feature enabled - start is invoked`() = runTest { + val startResult = lambdaRecorder {} + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = startResult, + ), + isFeatureEnabled = true, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canLoginWithClassic).isFalse() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + val finalState = awaitItem() + assertThat(finalState.canLoginWithClassic).isFalse() + } + startResult.assertions().isCalledOnce() + } + + @Test + fun `present - emit request data invokes the expected method`() = runTest { + val requestDataResult = lambdaRecorder {} + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + requestDataResult = requestDataResult, + ), + isFeatureEnabled = true, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canLoginWithClassic).isFalse() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + val nextState = awaitItem() + assertThat(nextState.canLoginWithClassic).isFalse() + nextState.eventSink(LoginWithClassicEvent.RefreshData) + } + requestDataResult.assertions().isCalledOnce() + } + + @Test + fun `present - start login with wrong state emits an error`() = runTest { + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ), + isFeatureEnabled = true, + ) + presenter.test { + skipItems(1) + val state = awaitItem() + state.eventSink(LoginWithClassicEvent.StartLoginWithClassic) + val errorState = awaitItem() + assertThat(errorState.loginWithClassicAction.isFailure()).isTrue() + } + } + + @Test + fun `present - start login with correct state - user cancel`() = runTest { + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = true, + ) + presenter.test { + skipItems(2) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + val readyState = awaitItem() + assertThat(readyState.canLoginWithClassic).isTrue() + readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic) + val confirmingState = awaitItem() + assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue() + assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID) + confirmingState.eventSink(LoginWithClassicEvent.CloseDialog) + val finalState = awaitItem() + assertThat(finalState.loginWithClassicAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - start login with correct state - user confirms`() = runTest { + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = true, + ) + presenter.test { + skipItems(2) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + val readyState = awaitItem() + assertThat(readyState.canLoginWithClassic).isTrue() + readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic) + val confirmingState = awaitItem() + assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue() + assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID) + confirmingState.eventSink(LoginWithClassicEvent.DoLoginWithClassic) + val loadingState = awaitItem() + assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue() + val finalState = awaitItem() + assertThat(finalState.loginWithClassicAction.isSuccess()).isTrue() + } + } + + @Test + fun `present - cannot sign in if a session with the same account already exists`() = runTest { + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = true, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ) + ) + ), + ) + presenter.test { + skipItems(2) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + // No new item, because canLoginWithClassic is still false + } + } + + @Test + fun `present - cannot sign in if the feature is disabled`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = false, + ) + presenter.test { + skipItems(1) + // Note: it should not happen IRL + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + // No new item, because canLoginWithClassic is still false + } + } +} + +private fun createPresenter( + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), + sessionStore: SessionStore = InMemorySessionStore(), + isFeatureEnabled: Boolean = false, + featureFlagService: FeatureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.SignInWithClassic.key to isFeatureEnabled) + ), +) = LoginWithClassicPresenter( + elementClassicConnection = elementClassicConnection, + sessionStore = sessionStore, + featureFlagService = featureFlagService, +) diff --git a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt index a77e09711f..4422330924 100644 --- a/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt +++ b/libraries/featureflag/api/src/main/kotlin/io/element/android/libraries/featureflag/api/FeatureFlags.kt @@ -133,4 +133,11 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), + SignInWithClassic( + key = "feature.signin_with_classic", + title = "Sign in with Element Classic", + description = "Allow the application to sign in to the current Element Classic account.", + defaultValue = { false }, + isFinished = false, + ), } From 57b89d241f928d21a0d0f60ec60671800d09205d Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 15 Jan 2026 12:14:08 +0100 Subject: [PATCH 10/29] Add proper localization for space room removal UI --- .../space/impl/root/SpacePresenter.kt | 1 + .../features/space/impl/root/SpaceState.kt | 5 +- .../space/impl/root/SpaceStateProvider.kt | 3 +- .../features/space/impl/root/SpaceView.kt | 62 ++++++++++--------- .../src/main/res/values-bg/translations.xml | 1 + .../src/main/res/values-cs/translations.xml | 1 + .../src/main/res/values-da/translations.xml | 1 + .../src/main/res/values-de/translations.xml | 6 ++ .../src/main/res/values-et/translations.xml | 1 + .../src/main/res/values-fa/translations.xml | 1 + .../src/main/res/values-fi/translations.xml | 1 + .../src/main/res/values-fr/translations.xml | 1 + .../src/main/res/values-hr/translations.xml | 1 + .../src/main/res/values-hu/translations.xml | 1 + .../src/main/res/values-it/translations.xml | 1 + .../src/main/res/values-nb/translations.xml | 1 + .../main/res/values-pt-rBR/translations.xml | 1 + .../src/main/res/values-ro/translations.xml | 1 + .../src/main/res/values-ru/translations.xml | 1 + .../src/main/res/values-sk/translations.xml | 1 + .../main/res/values-zh-rTW/translations.xml | 1 + .../src/main/res/values-zh/translations.xml | 1 + .../impl/src/main/res/values/localazy.xml | 6 ++ .../space/impl/root/SpaceStateTest.kt | 4 +- .../src/main/res/values-bg/translations.xml | 1 - .../src/main/res/values-cs/translations.xml | 1 - .../src/main/res/values-da/translations.xml | 1 - .../src/main/res/values-de/translations.xml | 6 -- .../src/main/res/values-et/translations.xml | 1 - .../src/main/res/values-fa/translations.xml | 1 - .../src/main/res/values-fi/translations.xml | 1 - .../src/main/res/values-fr/translations.xml | 1 - .../src/main/res/values-hr/translations.xml | 1 - .../src/main/res/values-hu/translations.xml | 1 - .../src/main/res/values-it/translations.xml | 1 - .../src/main/res/values-nb/translations.xml | 1 - .../main/res/values-pt-rBR/translations.xml | 1 - .../src/main/res/values-ro/translations.xml | 1 - .../src/main/res/values-ru/translations.xml | 1 - .../src/main/res/values-sk/translations.xml | 1 - .../main/res/values-zh-rTW/translations.xml | 1 - .../src/main/res/values-zh/translations.xml | 1 - .../src/main/res/values/localazy.xml | 10 ++- tools/localazy/config.json | 3 +- 44 files changed, 77 insertions(+), 63 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index e18f086834..43febab8c1 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -216,6 +216,7 @@ class SpacePresenter( } } return SpaceState( + currentSpaceId = spaceRoomList.roomId, currentSpace = currentSpace.getOrNull(), children = filteredChildren, seenSpaceInvites = seenSpaceInvites, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt index 05004ddace..28b04a9a21 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -18,6 +18,7 @@ import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet data class SpaceState( + private val currentSpaceId: RoomId, val currentSpace: SpaceRoom?, val children: ImmutableList, val seenSpaceInvites: ImmutableSet, @@ -35,10 +36,12 @@ data class SpaceState( ) { fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading fun isSelected(spaceId: RoomId): Boolean = selectedRoomIds.contains(spaceId) - val hasAnyFailure: Boolean = joinActions.values.any { + val hasAnyJoinFailures: Boolean = joinActions.values.any { it is AsyncAction.Failure } + val currentSpaceDisplayName = currentSpace?.displayName ?: currentSpaceId.value + val showManageRoomsAction: Boolean = canManageRooms && children.any { spaceRoom -> !spaceRoom.isSpace } val selectedCount: Int = selectedRoomIds.size val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt index ea59bf6e2e..d70cf1f1b9 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -63,7 +63,7 @@ open class SpaceStateProvider : PreviewParameterProvider { } fun aSpaceState( - parentSpace: SpaceRoom? = aParentSpace(), + parentSpace: SpaceRoom = aParentSpace(), children: List = emptyList(), seenSpaceInvites: Set = emptySet(), joiningRooms: Set = emptySet(), @@ -79,6 +79,7 @@ fun aSpaceState( removeRoomsAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (SpaceEvents) -> Unit = { }, ) = SpaceState( + currentSpaceId = parentSpace.roomId, currentSpace = parentSpace, children = children.toImmutableList(), seenSpaceInvites = seenSpaceInvites.toImmutableSet(), 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 31e70c5ff7..cb19636dfd 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 @@ -15,8 +15,6 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically -import androidx.compose.animation.slideIn -import androidx.compose.animation.veilOut import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -40,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -49,6 +48,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.space.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule import io.element.android.libraries.designsystem.components.ClickableLinkText @@ -82,6 +82,7 @@ import io.element.android.libraries.matrix.ui.components.JoinButton import io.element.android.libraries.matrix.ui.components.SpaceHeaderView import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @@ -160,10 +161,17 @@ fun SpaceView( state.eventSink(SpaceEvents.ShowTopicViewer(topic)) } ) - JoinRoomFailureEffect( - hasAnyFailure = state.hasAnyFailure, + JoinFailuresEffect( + hasAnyFailure = state.hasAnyJoinFailures, eventSink = state.eventSink ) + RemoveRoomsActionView( + spaceDisplayName = state.currentSpaceDisplayName, + removeRoomsAction = state.removeRoomsAction, + selectedCount = state.selectedCount, + onConfirm = { state.eventSink(SpaceEvents.ConfirmRoomRemoval) }, + onDismiss = { state.eventSink(SpaceEvents.ClearRemoveAction) }, + ) acceptDeclineInviteView() } }, @@ -176,18 +184,10 @@ fun SpaceView( } ) } - - // Confirmation dialog for removing rooms - RemoveRoomsConfirmationDialog( - removeRoomsAction = state.removeRoomsAction, - selectedCount = state.selectedCount, - onConfirm = { state.eventSink(SpaceEvents.ConfirmRoomRemoval) }, - onDismiss = { state.eventSink(SpaceEvents.ClearRemoveAction) }, - ) } @Composable -private fun JoinRoomFailureEffect( +private fun JoinFailuresEffect( hasAnyFailure: Boolean, eventSink: (SpaceEvents) -> Unit, ) { @@ -380,7 +380,7 @@ private fun SpaceViewTopBar( onDismissRequest = { showMenu = false } ) { SpaceMenuItem( - titleRes = CommonStrings.screen_space_menu_action_members, + titleRes = R.string.screen_space_menu_action_members, icon = CompoundIcons.User(), onClick = { showMenu = false @@ -448,7 +448,7 @@ private fun ManageModeTopBar( }, title = { Text( - text = "$selectedCount selected", + text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount), style = ElementTheme.typography.fontBodyLgMedium, ) }, @@ -551,17 +551,19 @@ private fun SpaceRoom.inviteButtons( } @Composable -private fun RemoveRoomsConfirmationDialog( +private fun RemoveRoomsActionView( + spaceDisplayName: String, removeRoomsAction: AsyncAction, selectedCount: Int, onConfirm: () -> Unit, onDismiss: () -> Unit, ) { - when (removeRoomsAction) { - AsyncAction.ConfirmingNoParams -> { + AsyncActionView( + async = removeRoomsAction, + confirmationDialog = { ConfirmationDialog( - title = "Remove $selectedCount rooms from space?", - content = "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security.", + title = pluralStringResource(R.plurals.screen_space_remove_rooms_confirmation_title, selectedCount, selectedCount, spaceDisplayName), + content = stringResource(R.string.screen_space_remove_rooms_confirmation_content), submitText = stringResource(CommonStrings.action_remove), onSubmitClick = onConfirm, onDismiss = onDismiss, @@ -574,15 +576,17 @@ private fun RemoveRoomsConfirmationDialog( ) } ) - } - else -> { - AsyncActionView( - async = removeRoomsAction, - onSuccess = { onDismiss() }, - onErrorDismiss = onDismiss, - ) - } - } + }, + onRetry = onConfirm, + errorTitle = { + stringResource(CommonStrings.common_something_went_wrong) + }, + errorMessage = { + stringResource(CommonStrings.error_network_or_server_issue) + }, + onSuccess = { onDismiss() }, + onErrorDismiss = onDismiss, + ) } @PreviewsDayNight diff --git a/features/space/impl/src/main/res/values-bg/translations.xml b/features/space/impl/src/main/res/values-bg/translations.xml index 0759934bbd..de0870f3f4 100644 --- a/features/space/impl/src/main/res/values-bg/translations.xml +++ b/features/space/impl/src/main/res/values-bg/translations.xml @@ -1,5 +1,6 @@ + "Преглед на членовете" "Напускане на пространството" "Роли и разрешения" "Защита и поверителност" diff --git a/features/space/impl/src/main/res/values-cs/translations.xml b/features/space/impl/src/main/res/values-cs/translations.xml index d4730fc62d..d98124c714 100644 --- a/features/space/impl/src/main/res/values-cs/translations.xml +++ b/features/space/impl/src/main/res/values-cs/translations.xml @@ -11,6 +11,7 @@ "Z následujících místností nebudete odstraněni, protože jste jediným administrátorem:" "Opustit %1$s?" "Jste jediným administrátorem pro %1$s" + "Zobrazit členy" "Opustit prostor" "Role a oprávnění" "Zabezpečení a soukromí" diff --git a/features/space/impl/src/main/res/values-da/translations.xml b/features/space/impl/src/main/res/values-da/translations.xml index 6422b9635d..068712629d 100644 --- a/features/space/impl/src/main/res/values-da/translations.xml +++ b/features/space/impl/src/main/res/values-da/translations.xml @@ -10,6 +10,7 @@ "Du vil ikke blive fjernet fra følgende rum, fordi du er den eneste administrator:" "Forlad %1$s?" "Du er den eneste administrator for %1$s" + "Vis medlemmer" "Forlad gruppe" "Roller og tilladelser" "Sikkerhed og privatliv" diff --git a/features/space/impl/src/main/res/values-de/translations.xml b/features/space/impl/src/main/res/values-de/translations.xml index a001756c6c..1d0238cf7f 100644 --- a/features/space/impl/src/main/res/values-de/translations.xml +++ b/features/space/impl/src/main/res/values-de/translations.xml @@ -10,6 +10,12 @@ "Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:" "%1$s verlassen?" "Du bist der einzige Administrator für %1$s" + "Mitglieder anzeigen" + "Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\"" + + "%1$d chat aus %2$s entfernen" + "%1$d chats aus %2$s entfernen" + "Space verlassen" "Rollen und Berechtigungen" "Sicherheit & Datenschutz" diff --git a/features/space/impl/src/main/res/values-et/translations.xml b/features/space/impl/src/main/res/values-et/translations.xml index 43eaade351..fdee05be05 100644 --- a/features/space/impl/src/main/res/values-et/translations.xml +++ b/features/space/impl/src/main/res/values-et/translations.xml @@ -10,6 +10,7 @@ "Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:" "Kas lahkud %1$s kogukonnast?" "Sa oled siin ainus peakasutaja: %1$s" + "Vaata liikmeid" "Lahku kogukonnast" "Rollid ja õigused" "Turvalisus ja privaatsus" diff --git a/features/space/impl/src/main/res/values-fa/translations.xml b/features/space/impl/src/main/res/values-fa/translations.xml index bda53d0947..0f42a9f65f 100644 --- a/features/space/impl/src/main/res/values-fa/translations.xml +++ b/features/space/impl/src/main/res/values-fa/translations.xml @@ -5,6 +5,7 @@ "از اتاق(های) زیر برداشته نخواهید شد؛ چرا که تنها مدیر هستید:" "ترک %1$s؟" "تنها مدیر %1$s هستید" + "دیدن اعضا" "ترک فضا" "نقش‌ها و اجازه‌ها" "امنیت و محرمانگی" diff --git a/features/space/impl/src/main/res/values-fi/translations.xml b/features/space/impl/src/main/res/values-fi/translations.xml index e43a4ae7f9..77771cf383 100644 --- a/features/space/impl/src/main/res/values-fi/translations.xml +++ b/features/space/impl/src/main/res/values-fi/translations.xml @@ -10,6 +10,7 @@ "Sinua ei poisteta seuraavista huoneista, koska olet ainoa ylläpitäjä:" "Haluatko poistua tilasta %1$s?" "Olet ainoa ylläpitäjä tilassa %1$s" + "Näytä jäsenet" "Poistu tilasta" "Roolit ja oikeudet" "Turvallisuus ja yksityisyys" diff --git a/features/space/impl/src/main/res/values-fr/translations.xml b/features/space/impl/src/main/res/values-fr/translations.xml index befd4a7c92..89cc3e619f 100644 --- a/features/space/impl/src/main/res/values-fr/translations.xml +++ b/features/space/impl/src/main/res/values-fr/translations.xml @@ -10,6 +10,7 @@ "Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:" "Quitter %1$s?" "Vous êtes le seul administrateur de %1$s" + "Voir les membres" "Quitter l’espace" "Rôles & autorisations" "Sécurité & confidentialité" diff --git a/features/space/impl/src/main/res/values-hr/translations.xml b/features/space/impl/src/main/res/values-hr/translations.xml index 9babbb3d69..5bd5400124 100644 --- a/features/space/impl/src/main/res/values-hr/translations.xml +++ b/features/space/impl/src/main/res/values-hr/translations.xml @@ -11,6 +11,7 @@ "Nećete biti uklonjeni iz sljedećih soba jer ste jedini administrator:" "Želite li napustiti %1$s?" "Vi ste jedini administrator za %1$s" + "Prikaži članove" "Napusti prostor" "Uloge i dopuštenja" "Sigurnost i privatnost" diff --git a/features/space/impl/src/main/res/values-hu/translations.xml b/features/space/impl/src/main/res/values-hu/translations.xml index 3ddbe6c822..670e14cc3c 100644 --- a/features/space/impl/src/main/res/values-hu/translations.xml +++ b/features/space/impl/src/main/res/values-hu/translations.xml @@ -10,6 +10,7 @@ "Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:" "Kilép innen: %1$s?" "Ön az egyetlen adminisztrátor itt: %1$s" + "Tagok megtekintése" "Tér elhagyása" "Szerepkörök és jogosultságok" "Biztonság és adatvédelem" diff --git a/features/space/impl/src/main/res/values-it/translations.xml b/features/space/impl/src/main/res/values-it/translations.xml index e483f98513..f358c96d0d 100644 --- a/features/space/impl/src/main/res/values-it/translations.xml +++ b/features/space/impl/src/main/res/values-it/translations.xml @@ -10,6 +10,7 @@ "Non verrai rimosso dalle seguenti stanze perché sei l\'unico amministratore:" "Uscire da %1$s?" "Sei l\'unico amministratore di %1$s" + "Visualizza membri" "Esci dallo spazio" "Ruoli e autorizzazioni" "Sicurezza e privacy" diff --git a/features/space/impl/src/main/res/values-nb/translations.xml b/features/space/impl/src/main/res/values-nb/translations.xml index 0e0709f80e..ebbe7be342 100644 --- a/features/space/impl/src/main/res/values-nb/translations.xml +++ b/features/space/impl/src/main/res/values-nb/translations.xml @@ -10,6 +10,7 @@ "Du vil ikke bli fjernet fra følgende rom fordi du er den eneste administratoren:" "Forlat %1$s?" "Du er den eneste administratoren for %1$s" + "Vis medlemmer" "Forlat område" "Roller og tillatelser" "Sikkerhet og personvern" diff --git a/features/space/impl/src/main/res/values-pt-rBR/translations.xml b/features/space/impl/src/main/res/values-pt-rBR/translations.xml index 3329be1097..c509b8caf8 100644 --- a/features/space/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/space/impl/src/main/res/values-pt-rBR/translations.xml @@ -10,6 +10,7 @@ "Você não será removido das seguintes salas porque você é o único administrador:" "Sair de %1$s?" "Você é o único administrador de %1$s" + "Ver membros" "Sair do espaço" "Cargos e permissões" "Segurança e privacidade" diff --git a/features/space/impl/src/main/res/values-ro/translations.xml b/features/space/impl/src/main/res/values-ro/translations.xml index 588518a249..7640d873a8 100644 --- a/features/space/impl/src/main/res/values-ro/translations.xml +++ b/features/space/impl/src/main/res/values-ro/translations.xml @@ -11,6 +11,7 @@ "Nu veți părăsi următoarele camere deoarece sunteți singurul administrator:" "Părăsiți %1$s?" "Sunteți singurul administrator pentru %1$s" + "Vizualizați membrii" "Părăsiți spațiul" "Roluri și permisiuni" "Securitate & confidențialitate" diff --git a/features/space/impl/src/main/res/values-ru/translations.xml b/features/space/impl/src/main/res/values-ru/translations.xml index 47cd467725..090c551fd5 100644 --- a/features/space/impl/src/main/res/values-ru/translations.xml +++ b/features/space/impl/src/main/res/values-ru/translations.xml @@ -11,6 +11,7 @@ "Вы не будете удалены из следующих комнат, поскольку вы являетесь единственным администратором:" "Выйти из %1$s?" "Вы единственный администратор для %1$s" + "Просмотреть участников" "Покинуть пространство" "Роли и разрешения" "Безопасность и конфиденциальность" diff --git a/features/space/impl/src/main/res/values-sk/translations.xml b/features/space/impl/src/main/res/values-sk/translations.xml index 2fd11ba58b..79b8fbfbb6 100644 --- a/features/space/impl/src/main/res/values-sk/translations.xml +++ b/features/space/impl/src/main/res/values-sk/translations.xml @@ -11,6 +11,7 @@ "Z nasledujúcich miestností nebudete odstránený/á, pretože ste jediným správcom:" "Opustiť %1$s?" "Ste jediným administrátorom pre %1$s" + "Zobraziť členov" "Opustiť priestor" "Roly a povolenia" "Bezpečnosť a súkromie" diff --git a/features/space/impl/src/main/res/values-zh-rTW/translations.xml b/features/space/impl/src/main/res/values-zh-rTW/translations.xml index abf495860f..54da45642c 100644 --- a/features/space/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/space/impl/src/main/res/values-zh-rTW/translations.xml @@ -9,6 +9,7 @@ "您不會被從以下聊天室移除,因為您是唯一的管理員:" "離開 %1$s?" "您是 %1$s 唯一的管理員" + "檢視成員" "離開空間" "角色與權限" "安全與隱私" diff --git a/features/space/impl/src/main/res/values-zh/translations.xml b/features/space/impl/src/main/res/values-zh/translations.xml index f0afff02f4..ea7011c942 100644 --- a/features/space/impl/src/main/res/values-zh/translations.xml +++ b/features/space/impl/src/main/res/values-zh/translations.xml @@ -9,6 +9,7 @@ "您不会从以下房间中被移除,因为您是唯一的管理员:" "离开%1$s?" "您是 %1$s 的唯一管理员" + "查看成员" "离开空间" "角色与权限" "安全与隐私" diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml index a4df5e767d..10aa0fb28c 100644 --- a/features/space/impl/src/main/res/values/localazy.xml +++ b/features/space/impl/src/main/res/values/localazy.xml @@ -10,6 +10,12 @@ "You will not be removed from the following room(s) because you\'re the only administrator:" "Leave %1$s?" "You are the only admin for %1$s" + "View members" + "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security." + + "Remove %1$d room from %2$s" + "Remove %1$d rooms from %2$s" + "Leave space" "Roles & permissions" "Security & privacy" diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt index a0c3635baf..ea9e5a2a30 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt @@ -22,7 +22,7 @@ class SpaceStateTest { @Test fun `test default state`() { val state = aSpaceState() - assertThat(state.hasAnyFailure).isFalse() + assertThat(state.hasAnyJoinFailures).isFalse() assertThat(state.isJoining(A_ROOM_ID)).isFalse() } @@ -35,7 +35,7 @@ class SpaceStateTest { A_ROOM_ID_3 to AsyncAction.Success(Unit), ) ) - assertThat(state.hasAnyFailure).isTrue() + assertThat(state.hasAnyJoinFailures).isTrue() } @Test diff --git a/libraries/ui-strings/src/main/res/values-bg/translations.xml b/libraries/ui-strings/src/main/res/values-bg/translations.xml index 483c47fbea..d6e314044b 100644 --- a/libraries/ui-strings/src/main/res/values-bg/translations.xml +++ b/libraries/ui-strings/src/main/res/values-bg/translations.xml @@ -333,7 +333,6 @@ "Споделяне на това местоположение" "%1$s пространство" "Пространства" - "Преглед на членовете" "Местоположение" "Версия: %1$s (%2$s)" "bg" diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index 45823e3a0d..fafb59d4fb 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -483,7 +483,6 @@ Opravdu chcete pokračovat?" "%1$s • %2$s" "%1$s prostor" "Prostory" - "Zobrazit členy" "Zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila." "Zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení." "Zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení." diff --git a/libraries/ui-strings/src/main/res/values-da/translations.xml b/libraries/ui-strings/src/main/res/values-da/translations.xml index 6a230d4dfe..6dc36fac5a 100644 --- a/libraries/ui-strings/src/main/res/values-da/translations.xml +++ b/libraries/ui-strings/src/main/res/values-da/translations.xml @@ -469,7 +469,6 @@ Er du sikker på, at du vil fortsætte?" "%1$s•%2$s" "%1$s gruppe" "Grupper" - "Vis medlemmer" "Beskeden blev ikke sendt fordi %1$s s bekræftede identitet blev nulstillet." "Meddelelsen er ikke sendt, fordi %1$s ikke har bekræftet alle enheder." "Beskeden er ikke sendt, fordi du ikke har verificeret en eller flere af dine enheder." diff --git a/libraries/ui-strings/src/main/res/values-de/translations.xml b/libraries/ui-strings/src/main/res/values-de/translations.xml index 05650799ec..60a77143d6 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -483,12 +483,6 @@ Möchtest du wirklich fortfahren?" "Erstelle einen Space, um Chats zu organisieren" "%1$s Space" "Spaces" - "Mitglieder anzeigen" - "Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\"" - - "Chat aus %1$s entfernen" - "%1$d chats aus %2$s entfernen" - "Nachricht nicht gesendet, weil sich die verifizierte Identität von %1$s geändert hat." "Die Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat." "Die Nachricht wurde nicht gesendet, weil du eines oder mehrere deiner Geräte nicht verifiziert hast." diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml index 6e344cd6b2..9bd2ef325c 100644 --- a/libraries/ui-strings/src/main/res/values-et/translations.xml +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -476,7 +476,6 @@ Kas sa oled kindel, et soovid jätkata?" "%1$s • %2$s" "Kogukond: %1$s" "Kogukonnad" - "Vaata liikmeid" "Sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud." "Sõnum on saatmata, kuna %1$s pole verifitseerinud kõiki oma seadmeid." "Kuna sa pole üks või enamgi oma seadet verifitseerinud, siis sinu sõnum on saatmata." diff --git a/libraries/ui-strings/src/main/res/values-fa/translations.xml b/libraries/ui-strings/src/main/res/values-fa/translations.xml index 16872a2e91..083f84ddc6 100644 --- a/libraries/ui-strings/src/main/res/values-fa/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fa/translations.xml @@ -400,7 +400,6 @@ "%1$s • %2$s" "‏%1$s فضا" "فضاها" - "دیدن اعضا" "مکان" "نگارش : %1$s (%2$s)" "fa" diff --git a/libraries/ui-strings/src/main/res/values-fi/translations.xml b/libraries/ui-strings/src/main/res/values-fi/translations.xml index 4aa4797e3c..7d71dca9ef 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -470,7 +470,6 @@ Haluatko varmasti jatkaa?" "%1$s • %2$s" "%1$s tila" "Tilat" - "Näytä jäsenet" "Viestiä ei lähetetty, koska käyttäjän %1$s vahvistettu identiteetti nollattiin." "Viestiä ei lähetetty, koska %1$s ei ole vahvistanut kaikkia laitteitaan." "Viestiä ei lähetetty, koska et ole vahvistanut yhtä tai useampaa laitettasi." diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 46e0b3dfb1..be99a39c7d 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -483,7 +483,6 @@ Raison : %1$s." "Créer des espaces pour organiser les salons" "Espace %1$s" "Espaces" - "Voir les membres" "Le message n’a pas été envoyé car l’identité vérifiée de %1$s a été réinitialisée." "Le message n’a pas été envoyé car %1$s n’a pas vérifié tous ses appareils." "Message non envoyé car vous n’avez pas vérifié tous vos appareils." diff --git a/libraries/ui-strings/src/main/res/values-hr/translations.xml b/libraries/ui-strings/src/main/res/values-hr/translations.xml index 20aa4a2f66..2d9676c36a 100644 --- a/libraries/ui-strings/src/main/res/values-hr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hr/translations.xml @@ -485,7 +485,6 @@ Jeste li sigurni da želite nastaviti?" "%1$s • %2$s" "Prostor %1$s" "Prostori" - "Prikaži članove" "Poruka nije poslana jer je poništen potvrđeni identitet korisnika %1$s." "Poruka nije poslana jer %1$s nije potvrdio sve uređaje." "Poruka nije poslana jer niste potvrdili jedan svoj uređaj ili više njih." diff --git a/libraries/ui-strings/src/main/res/values-hu/translations.xml b/libraries/ui-strings/src/main/res/values-hu/translations.xml index be1cc82207..259ef3dd15 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -469,7 +469,6 @@ Biztos, hogy folytatja?" "%1$s • %2$s" "%1$s tér" "Terek" - "Tagok megtekintése" "Az üzenet nem lett elküldve, mert %1$s ellenőrzött személyazonossága megváltozott." "Az üzenet nem lett elküldve, mert %1$s nem ellenőrizte az összes eszközét." "Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte." diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml index 01eb441661..c309732f11 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -470,7 +470,6 @@ Sei sicuro di voler continuare?" "%1$s • %2$s" "%1$s spazio" "Spazi" - "Visualizza membri" "Messaggio non inviato perché l\'identità verificata di %1$s è stata reimpostata." "Messaggio non inviato perché %1$s non ha verificato tutti i dispositivi." "Messaggio non inviato perché non hai verificato uno o più dispositivi." diff --git a/libraries/ui-strings/src/main/res/values-nb/translations.xml b/libraries/ui-strings/src/main/res/values-nb/translations.xml index 09d3eab84c..05673571c6 100644 --- a/libraries/ui-strings/src/main/res/values-nb/translations.xml +++ b/libraries/ui-strings/src/main/res/values-nb/translations.xml @@ -467,7 +467,6 @@ Er du sikker på at du vil fortsette?" "%1$s • %2$s" "%1$s område" "Områder" - "Vis medlemmer" "Meldingen ble ikke sendt fordi %1$ss verifiserte identitet er tilbakestilt." "Meldingen ble ikke sendt fordi %1$s ikke har verifisert alle enheter." "Meldingen ble ikke sendt fordi du ikke har verifisert en eller flere av enhetene dine." diff --git a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml index 74acd35b3e..3c87408694 100644 --- a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml @@ -479,7 +479,6 @@ Você tem certeza de que deseja continuar?" "%1$s • %2$s" "Espaço %1$s" "Espaços" - "Ver membros" "Mensagem não enviada porque a identidade verificada de %1$s foi redefinida." "A mensagem não foi enviada porque %1$s não verificou todos os dispositivos." "Mensagem não enviada porque você não verificou um ou mais dos seus dispositivos." diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index e8727a98ae..0851be7648 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -484,7 +484,6 @@ Sunteți sigur că doriți să continuați?" "%1$s • %2$s" "Spațiu %1$s" "Spații" - "Vizualizați membrii" "Mesajul nu a fost trimis deoarece identitatea verificată a lui %1$s s-a schimbat." "Mesajul nu a fost trimis deoarece %1$s nu a verificat toate dispozitivele." "Mesajul nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive." diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 77dde7078d..1078a92fd6 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -479,7 +479,6 @@ "%1$s • %2$s" "%1$s пространство" "Пространства" - "Просмотреть участников" "Сообщение не отправлено, потому что подтвержденная личность %1$s была сброшена." "Сообщение не отправлено, потому что %1$s не проверил одно или несколько устройств." "Сообщение не отправлено, поскольку вы не подтвердили одно или несколько своих устройств." diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index 747ac45132..e209bd0a21 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -481,7 +481,6 @@ Naozaj chcete pokračovať?" "%1$s • %2$s" "%1$s priestor" "Priestory" - "Zobraziť členov" "Správa nebola odoslaná, pretože sa zmenila overená totožnosť používateľa %1$s." "Správa nebola odoslaná, pretože %1$s neoveril/a všetky zariadenia." "Správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení." diff --git a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml index 67815cfbc6..e70937357d 100644 --- a/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh-rTW/translations.xml @@ -461,7 +461,6 @@ "%1$s • %2$s" "%1$s 空間" "空間" - "檢視成員" "因為 %1$s 的驗證身份已重設,因此未傳送訊息。" "訊息未傳送,因為 %1$s 尚未驗證所有裝置。" "因為您尚未驗證一個或多個裝置,因此未傳送訊息" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml index 774a3d3f89..7d63c0ccb9 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -460,7 +460,6 @@ "%1$s • %2$s" "%1$s空间" "空间" - "查看成员" "消息未发送,因为%1$s的已验证身份已被重置。" "消息未发送,因为%1$s尚未验证所有设备。" "消息未发送,因为您有尚未验证的设备。" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index 9a86b9c203..70dd613526 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -317,6 +317,10 @@ Reason: %1$s." "Security" "Seen by" "Select an account" + + "%1$d selected" + "%1$d selected" + "Send to" "Sending…" "Sending failed" @@ -484,12 +488,6 @@ Are you sure you want to continue?" "Create spaces to organize rooms" "%1$s space" "Spaces" - "View members" - "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security." - - "Remove room from %1$s" - "Remove %1$d rooms from %2$s" - "Message not sent because %1$s’s verified identity was reset." "Message not sent because %1$s has not verified all devices." "Message not sent because you have not verified one or more of your devices." diff --git a/tools/localazy/config.json b/tools/localazy/config.json index 3894befcb0..e6ea1df3cb 100644 --- a/tools/localazy/config.json +++ b/tools/localazy/config.json @@ -233,7 +233,8 @@ "name" : ":features:space:impl", "includeRegex" : [ "screen\\.leave_space\\..*", - "screen\\.space_settings\\..*" + "screen\\.space_settings\\..*", + "screen\\.space\\..*" ] }, { From e5e4b18b80e22964ef3748c7ba7b552e1d62e4e0 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 15 Jan 2026 12:40:47 +0100 Subject: [PATCH 11/29] Rename canManageRooms to canEditSpaceGraph --- .../android/features/space/impl/root/SpacePresenter.kt | 2 +- .../element/android/features/space/impl/root/SpaceState.kt | 4 ++-- .../android/features/space/impl/root/SpaceStateProvider.kt | 2 +- .../space/impl/settings/SpaceSettingsPermissions.kt | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 43febab8c1..475920c0fc 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -228,7 +228,7 @@ class SpacePresenter( canAccessSpaceSettings = canAccessSpaceSettings, isManageMode = isManageMode, selectedRoomIds = selectedRoomIds.toImmutableSet(), - canManageRooms = permissions.canManageRooms, + canEditSpaceGraph = permissions.canEditSpaceGraph, removeRoomsAction = removeRoomsAction, eventSink = ::handleEvent, ) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt index 28b04a9a21..df384e68f2 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -30,7 +30,7 @@ data class SpaceState( val canAccessSpaceSettings: Boolean, val isManageMode: Boolean, val selectedRoomIds: ImmutableSet, - val canManageRooms: Boolean, + val canEditSpaceGraph: Boolean, val removeRoomsAction: AsyncAction, val eventSink: (SpaceEvents) -> Unit ) { @@ -42,7 +42,7 @@ data class SpaceState( val currentSpaceDisplayName = currentSpace?.displayName ?: currentSpaceId.value - val showManageRoomsAction: Boolean = canManageRooms && children.any { spaceRoom -> !spaceRoom.isSpace } + val showManageRoomsAction: Boolean = canEditSpaceGraph && children.any { spaceRoom -> !spaceRoom.isSpace } val selectedCount: Int = selectedRoomIds.size val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty() } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt index d70cf1f1b9..90095e9436 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -91,7 +91,7 @@ fun aSpaceState( canAccessSpaceSettings = canAccessSpaceSettings, isManageMode = isManageMode, selectedRoomIds = selectedRoomIds.toImmutableSet(), - canManageRooms = canManageRooms, + canEditSpaceGraph = canManageRooms, removeRoomsAction = removeRoomsAction, eventSink = eventSink, ) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt index 8297992b49..c02593f953 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt @@ -20,7 +20,7 @@ data class SpaceSettingsPermissions( val editDetailsPermissions: RoomDetailsEditPermissions, val canEditRolesAndPermissions: Boolean, val securityAndPrivacyPermissions: SecurityAndPrivacyPermissions, - val canManageRooms: Boolean, + val canEditSpaceGraph: Boolean, ) { fun hasAny(joinRule: JoinRule?): Boolean { return editDetailsPermissions.hasAny || @@ -33,7 +33,7 @@ data class SpaceSettingsPermissions( editDetailsPermissions = RoomDetailsEditPermissions.DEFAULT, canEditRolesAndPermissions = false, securityAndPrivacyPermissions = SecurityAndPrivacyPermissions.DEFAULT, - canManageRooms = false, + canEditSpaceGraph = false, ) } } @@ -43,6 +43,6 @@ fun RoomPermissions.spaceSettingsPermissions(): SpaceSettingsPermissions { editDetailsPermissions = roomDetailsEditPermissions(), canEditRolesAndPermissions = canEditRolesAndPermissions(), securityAndPrivacyPermissions = securityAndPrivacyPermissions(), - canManageRooms = canOwnUserSendState(StateEventType.SpaceChild), + canEditSpaceGraph = canOwnUserSendState(StateEventType.SpaceChild) || canOwnUserSendState(StateEventType.SpaceParent), ) } From 8dc7caa737ed8a0a2f6e58bc17973bd08a690a4f Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 15 Jan 2026 12:41:11 +0100 Subject: [PATCH 12/29] Move manage rooms menu item to top of space menu --- .../features/space/impl/root/SpaceView.kt | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) 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 cb19636dfd..5dcb57dfdb 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 @@ -136,8 +136,8 @@ fun SpaceView( showManageRoomsAction = state.showManageRoomsAction, onBackClick = onBackClick, onLeaveSpaceClick = onLeaveSpaceClick, - onShareSpace = onShareSpace, onSettingsClick = onSettingsClick, + onShareSpace = onShareSpace, onViewMembersClick = onViewMembersClick, onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) }, ) @@ -379,6 +379,17 @@ private fun SpaceViewTopBar( expanded = showMenu, onDismissRequest = { showMenu = false } ) { + if (showManageRoomsAction) { + SpaceMenuItem( + titleRes = CommonStrings.action_manage_rooms, + icon = CompoundIcons.Edit(), + onClick = { + showMenu = false + onManageRoomsClick() + } + ) + HorizontalDivider() + } SpaceMenuItem( titleRes = R.string.screen_space_menu_action_members, icon = CompoundIcons.User(), @@ -395,16 +406,6 @@ private fun SpaceViewTopBar( onShareSpace() } ) - if (showManageRoomsAction) { - SpaceMenuItem( - titleRes = CommonStrings.action_manage_rooms, - icon = CompoundIcons.Edit(), - onClick = { - showMenu = false - onManageRoomsClick() - } - ) - } if (canAccessSpaceSettings) { SpaceMenuItem( titleRes = CommonStrings.common_settings, @@ -415,6 +416,7 @@ private fun SpaceViewTopBar( } ) } + HorizontalDivider() SpaceMenuItem( titleRes = CommonStrings.action_leave_space, icon = CompoundIcons.Leave(), From f645922bd8aca803a2924ff29b2880c2e23d02ba Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Jan 2026 15:20:48 +0100 Subject: [PATCH 13/29] Rename the state flow. Also let `stateFlow` be a real `val`. --- .../classic/ElementClassicConnection.kt | 26 +++++++++---------- .../classic/LoginWithClassicPresenter.kt | 4 +-- .../classic/FakeElementClassicConnection.kt | 6 ++--- 3 files changed, 17 insertions(+), 19 deletions(-) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt index 29a4f9b3fc..c983ea04ba 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt @@ -37,7 +37,7 @@ interface ElementClassicConnection { fun start() fun stop() fun requestData() - val state: StateFlow + val stateFlow: StateFlow } sealed interface ElementClassicConnectionState { @@ -107,11 +107,11 @@ class DefaultElementClassicConnection( } else { // This happen when the app is not installed Timber.tag(loggerTag.value).d("Binding returned false") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) + mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) } } catch (e: SecurityException) { Timber.tag(loggerTag.value).e(e, "Can't bind to Service") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) } } } @@ -124,7 +124,7 @@ class DefaultElementClassicConnection( bound = false } coroutineScope.launch { - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Idle) + mutableStateFlow.emit(ElementClassicConnectionState.Idle) } } @@ -134,7 +134,7 @@ class DefaultElementClassicConnection( val finalMessenger = messenger if (finalMessenger == null) { Timber.tag(loggerTag.value).w("The messenger is null, can't request data") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data")) + mutableStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data")) } else { try { // Get the data @@ -147,16 +147,14 @@ class DefaultElementClassicConnection( // disconnected (and then reconnected if it can be restarted) // so there is no need to do anything here. Timber.tag(loggerTag.value).e(e, "RemoteException") - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) } } } } - private val elementClassicConnectionStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) - - override val state: StateFlow - get() = elementClassicConnectionStateFlow.asStateFlow() + private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + override val stateFlow = mutableStateFlow.asStateFlow() /** * Handler of incoming messages from service. @@ -182,20 +180,20 @@ class DefaultElementClassicConnection( when (state) { is ElementClassicConnectionState.Error -> { Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error) - elementClassicConnectionStateFlow.emit(state) + mutableStateFlow.emit(state) } is ElementClassicConnectionState.ElementClassicReady -> { Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId) - elementClassicConnectionStateFlow.emit(state) + mutableStateFlow.emit(state) } ElementClassicConnectionState.ElementClassicReadyNoSession -> { Timber.tag(loggerTag.value).d("Received no session from Element Classic") - elementClassicConnectionStateFlow.emit(state) + mutableStateFlow.emit(state) } else -> { // Should not happen Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state) - elementClassicConnectionStateFlow.emit(ElementClassicConnectionState.Idle) + mutableStateFlow.emit(ElementClassicConnectionState.Idle) } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt index d962c5978a..ef352794cb 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt @@ -48,7 +48,7 @@ class LoginWithClassicPresenter( } } - val state by elementClassicConnection.state.collectAsState() + val state by elementClassicConnection.stateFlow.collectAsState() val loginWithClassicAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val existingSession by remember { @@ -73,7 +73,7 @@ class LoginWithClassicPresenter( elementClassicConnection.requestData() } LoginWithClassicEvent.StartLoginWithClassic -> { - val currentState = elementClassicConnection.state.value + val currentState = elementClassicConnection.stateFlow.value if (currentState is ElementClassicConnectionState.ElementClassicReady) { loginWithClassicAction.value = ConfirmingLoginWithElementClassic( userId = currentState.userId, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt index 9f2e76e75d..2c41d2ed0f 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt @@ -21,9 +21,9 @@ class FakeElementClassicConnection( override fun start() = startResult() override fun stop() = stopResult() override fun requestData() = requestDataResult() - private val _state = MutableStateFlow(initialState) - override val state: StateFlow = _state.asStateFlow() + private val mutableStateFlow = MutableStateFlow(initialState) + override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() suspend fun emitState(state: ElementClassicConnectionState) { - _state.emit(state) + mutableStateFlow.emit(state) } } From 66180201e5c516cea065bdb6f3879a81aae5a665 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Jan 2026 16:22:44 +0100 Subject: [PATCH 14/29] Make the number view scrollable Fixes #6009 --- .../linknewdevice/impl/screens/number/EnterNumberView.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt index 92b3447615..240a3143af 100644 --- a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt @@ -54,6 +54,7 @@ fun EnterNumberView( subTitle = stringResource(R.string.screen_link_new_device_enter_number_subtitle), iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), modifier = modifier, + isScrollable = true, buttons = { Button( text = stringResource(CommonStrings.action_continue), From e7789ef8696a599ca01537bd6a0cc93d4e2d9bef Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 15 Jan 2026 16:41:33 +0100 Subject: [PATCH 15/29] Move canEditSpaceGraph out of SettingsPermissions to his own Permissions --- .../space/impl/root/SpacePermissions.kt | 33 +++++++++++++++++++ .../space/impl/root/SpacePresenter.kt | 6 ++-- .../impl/settings/SpaceSettingsPermissions.kt | 4 --- 3 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt new file mode 100644 index 0000000000..90ee8e27f1 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.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.space.impl.root + +import io.element.android.features.space.impl.settings.SpaceSettingsPermissions +import io.element.android.features.space.impl.settings.spaceSettingsPermissions +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +data class SpacePermissions( + val settingsPermissions: SpaceSettingsPermissions, + val canEditSpaceGraph: Boolean, +) { + companion object { + val DEFAULT = SpacePermissions( + settingsPermissions = SpaceSettingsPermissions.DEFAULT, + canEditSpaceGraph = false, + ) + } +} + +fun RoomPermissions.spacePermissions(): SpacePermissions { + return SpacePermissions( + settingsPermissions = spaceSettingsPermissions(), + canEditSpaceGraph = canOwnUserSendState(StateEventType.SpaceChild) || canOwnUserSendState(StateEventType.SpaceParent), + ) +} + diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 475920c0fc..9ca68e866f 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -92,8 +92,8 @@ class SpacePresenter( } }.collectAsState() - val permissions by room.permissionsAsState(SpaceSettingsPermissions.DEFAULT) { perms -> - perms.spaceSettingsPermissions() + val permissions by room.permissionsAsState(SpacePermissions.DEFAULT) { perms -> + perms.spacePermissions() } val isSpaceSettingsEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.SpaceSettings) @@ -101,7 +101,7 @@ class SpacePresenter( val roomInfo by room.roomInfoFlow.collectAsState() val canAccessSpaceSettings by remember { - derivedStateOf { isSpaceSettingsEnabled && permissions.hasAny(roomInfo.joinRule) } + derivedStateOf { isSpaceSettingsEnabled && permissions.settingsPermissions.hasAny(roomInfo.joinRule) } } val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap>()) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt index c02593f953..e3ec70a51d 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt @@ -11,7 +11,6 @@ import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermission import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions -import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions @@ -20,7 +19,6 @@ data class SpaceSettingsPermissions( val editDetailsPermissions: RoomDetailsEditPermissions, val canEditRolesAndPermissions: Boolean, val securityAndPrivacyPermissions: SecurityAndPrivacyPermissions, - val canEditSpaceGraph: Boolean, ) { fun hasAny(joinRule: JoinRule?): Boolean { return editDetailsPermissions.hasAny || @@ -33,7 +31,6 @@ data class SpaceSettingsPermissions( editDetailsPermissions = RoomDetailsEditPermissions.DEFAULT, canEditRolesAndPermissions = false, securityAndPrivacyPermissions = SecurityAndPrivacyPermissions.DEFAULT, - canEditSpaceGraph = false, ) } } @@ -43,6 +40,5 @@ fun RoomPermissions.spaceSettingsPermissions(): SpaceSettingsPermissions { editDetailsPermissions = roomDetailsEditPermissions(), canEditRolesAndPermissions = canEditRolesAndPermissions(), securityAndPrivacyPermissions = securityAndPrivacyPermissions(), - canEditSpaceGraph = canOwnUserSendState(StateEventType.SpaceChild) || canOwnUserSendState(StateEventType.SpaceParent), ) } From 656a85a4eac82318c295103eb1beafdbad628d50 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:55:35 +0100 Subject: [PATCH 16/29] fix(deps): update dependency androidx.compose:compose-bom to v2026 (#6010) 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 14dde810a9..de053afe92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ camera = "1.5.2" work = "2.11.0" # Compose -compose_bom = "2025.12.01" +compose_bom = "2026.01.00" # Coroutines coroutines = "1.10.2" From 4a01ed4378065b8c7ac763b0e6018c28d82505a3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:56:01 +0100 Subject: [PATCH 17/29] fix(deps): update dependency io.sentry:sentry-android to v8.30.0 (#6014) 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 de053afe92..7fb95ec2d7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -219,7 +219,7 @@ color_picker = "io.mhssn:colorpicker:1.0.0" # Analytics posthog = "com.posthog:posthog-android:3.28.1" -sentry = "io.sentry:sentry-android:8.29.0" +sentry = "io.sentry:sentry-android:8.30.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2" From 6f0f4b3eeb9badddbd5d3285b201be3a841f2a09 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 15:57:49 +0000 Subject: [PATCH 18/29] fix(deps): update dependency com.google.firebase:firebase-bom to v34.8.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 7fb95ec2d7..e4bbe4ad7a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,7 +78,7 @@ kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlin kover_gradle_plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" } ksp_gradle_plugin = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } # https://firebase.google.com/docs/android/setup#available-libraries -google_firebase_bom = "com.google.firebase:firebase-bom:34.7.0" +google_firebase_bom = "com.google.firebase:firebase-bom:34.8.0" firebase_appdistribution_gradle = { module = "com.google.firebase:firebase-appdistribution-gradle", version.ref = "firebaseAppDistribution" } autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:dependency-analysis-gradle-plugin", version.ref = "dependencyAnalysis" } ksp_plugin = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } From 158e779bdb5da2e84fae7f7f02eb08c55b1e49e9 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Jan 2026 17:07:25 +0100 Subject: [PATCH 19/29] Ensure that room with long names renders correctly in the room list. --- .../android/features/home/impl/components/RoomSummaryRow.kt | 5 +++-- .../android/libraries/core/extensions/BasicExtensions.kt | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt index 50b9559e94..d8426b5321 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/components/RoomSummaryRow.kt @@ -46,6 +46,7 @@ import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.roomlist.RoomListEvents import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.core.extensions.toSafeLength import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -227,7 +228,7 @@ private fun NameAndTimestampRow( // Name Text( style = ElementTheme.typography.fontBodyLgMedium, - text = name ?: stringResource(id = CommonStrings.common_no_room_name), + text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name), fontStyle = FontStyle.Italic.takeIf { name == null }, color = ElementTheme.colors.roomListRoomName, maxLines = 1, @@ -380,7 +381,7 @@ private fun InviteNameAndIndicatorRow( Text( modifier = Modifier.weight(1f), style = ElementTheme.typography.fontBodyLgMedium, - text = name ?: stringResource(id = CommonStrings.common_no_room_name), + text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name), fontStyle = FontStyle.Italic.takeIf { name == null }, color = ElementTheme.colors.roomListRoomName, maxLines = 1, diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index d3a2805df1..d13777842d 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -100,6 +100,8 @@ fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR) fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR } +const val DEFAULT_SAFE_LENGTH = 500 + /** * This works around https://github.com/element-hq/element-x-android/issues/2105. * @param maxLength Max characters to retrieve. Defaults to `500`. @@ -107,7 +109,7 @@ fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || i * @return The string truncated to [maxLength] characters, with an optional ellipsis if larger. */ fun String.toSafeLength( - maxLength: Int = 500, + maxLength: Int = DEFAULT_SAFE_LENGTH, ellipsize: Boolean = false, ): String { return if (ellipsize) { From 51a92eb20c8fe9e486c999a4e4483d8d03d69263 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Jan 2026 17:07:53 +0100 Subject: [PATCH 20/29] Avoid creating a new constant for the same goal. --- .../impl/DefaultRoomLatestEventFormatter.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 7e1ffc3ee6..009547f9eb 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.eventformatter.impl import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.DEFAULT_SAFE_LENGTH import io.element.android.libraries.di.SessionScope import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.impl.mode.RenderingMode @@ -54,11 +55,6 @@ class DefaultRoomLatestEventFormatter( private val stateContentFormatter: StateContentFormatter, private val permalinkParser: PermalinkParser, ) : RoomLatestEventFormatter { - companion object { - // Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105 - private const val MAX_SAFE_LENGTH = 500 - } - override fun format( latestEvent: LatestEventValue.Local, isDmRoom: Boolean, @@ -121,7 +117,7 @@ class DefaultRoomLatestEventFormatter( } is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) is CallNotifyContent -> sp.getString(CommonStrings.common_call_started) - }?.take(MAX_SAFE_LENGTH) + }?.take(DEFAULT_SAFE_LENGTH) } private fun MessageContent.process( From 76bd4f043b23e2e49a9799d12ff1f30fd4386fb7 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 15 Jan 2026 17:34:54 +0100 Subject: [PATCH 21/29] Upgrade androidx.biometric:biometric-ktx to 1.4.0-alpha02 --- 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 7fb95ec2d7..b35608a791 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -109,7 +109,7 @@ androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "medi androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" } androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" } androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" } -androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05" +androidx_biometric = "androidx.biometric:biometric-ktx:1.4.0-alpha02" androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } androidx_activity_compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } From 3757ac144caefbf98f632072edd94b3b0b011d16 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 15 Jan 2026 17:37:34 +0100 Subject: [PATCH 22/29] Replace SpaceState.currentSpace with spaceInfo (RoomInfo) --- .../space/impl/root/SpacePresenter.kt | 5 +- .../features/space/impl/root/SpaceState.kt | 6 +- .../space/impl/root/SpaceStateProvider.kt | 64 +++++++++++++----- .../features/space/impl/root/SpaceView.kt | 66 +++++++++---------- .../space/impl/root/SpacePresenterTest.kt | 20 +----- .../features/space/impl/root/SpaceViewTest.kt | 3 +- 6 files changed, 86 insertions(+), 78 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 9ca68e866f..60db576df8 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -53,7 +53,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlin.jvm.optionals.getOrNull @Inject class SpacePresenter( @@ -103,7 +102,6 @@ class SpacePresenter( val canAccessSpaceSettings by remember { derivedStateOf { isSpaceSettingsEnabled && permissions.settingsPermissions.hasAny(roomInfo.joinRule) } } - val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap>()) } var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) } @@ -216,8 +214,7 @@ class SpacePresenter( } } return SpaceState( - currentSpaceId = spaceRoomList.roomId, - currentSpace = currentSpace.getOrNull(), + spaceInfo = roomInfo, children = filteredChildren, seenSpaceInvites = seenSpaceInvites, hideInvitesAvatar = hideInvitesAvatar, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt index df384e68f2..a669c294b5 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -12,14 +12,14 @@ import androidx.compose.runtime.Immutable import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.spaces.SpaceRoom import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet data class SpaceState( - private val currentSpaceId: RoomId, - val currentSpace: SpaceRoom?, + val spaceInfo: RoomInfo, val children: ImmutableList, val seenSpaceInvites: ImmutableSet, val hideInvitesAvatar: Boolean, @@ -40,8 +40,6 @@ data class SpaceState( it is AsyncAction.Failure } - val currentSpaceDisplayName = currentSpace?.displayName ?: currentSpaceId.value - val showManageRoomsAction: Boolean = canEditSpaceGraph && children.any { spaceRoom -> !spaceRoom.isSpace } val selectedCount: Int = selectedRoomIds.size val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty() diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt index 90095e9436..9641e17625 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -15,6 +15,8 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom @@ -27,11 +29,11 @@ open class SpaceStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aSpaceState(), - aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Public)), - aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Restricted(persistentListOf()))), + aSpaceState(spaceInfo = aSpaceInfo(joinRule = JoinRule.Public)), + aSpaceState(spaceInfo = aSpaceInfo(joinRule = JoinRule.Restricted(persistentListOf()))), aSpaceState(children = aListOfSpaceRooms()), aSpaceState( - parentSpace = aParentSpace(), + spaceInfo = aSpaceInfo(), children = aListOfSpaceRooms(), joiningRooms = setOf(RoomId("!spaceId0:example.com")), hasMoreToLoad = false @@ -41,19 +43,19 @@ open class SpaceStateProvider : PreviewParameterProvider { ), // Manage mode states aSpaceState( - parentSpace = aParentSpace(), + spaceInfo = aSpaceInfo(), children = aListOfSpaceRooms(), isManageMode = true, selectedRoomIds = emptySet(), ), aSpaceState( - parentSpace = aParentSpace(), + spaceInfo = aSpaceInfo(), children = aListOfSpaceRooms(), isManageMode = true, selectedRoomIds = setOf(RoomId("!spaceId0:example.com"), RoomId("!spaceId1:example.com")), ), aSpaceState( - parentSpace = aParentSpace(), + spaceInfo = aSpaceInfo(), children = aListOfSpaceRooms(), isManageMode = true, selectedRoomIds = setOf(RoomId("!spaceId0:example.com")), @@ -63,7 +65,7 @@ open class SpaceStateProvider : PreviewParameterProvider { } fun aSpaceState( - parentSpace: SpaceRoom = aParentSpace(), + spaceInfo: RoomInfo = aSpaceInfo(), children: List = emptyList(), seenSpaceInvites: Set = emptySet(), joiningRooms: Set = emptySet(), @@ -79,8 +81,7 @@ fun aSpaceState( removeRoomsAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (SpaceEvents) -> Unit = { }, ) = SpaceState( - currentSpaceId = parentSpace.roomId, - currentSpace = parentSpace, + spaceInfo = spaceInfo, children = children.toImmutableList(), seenSpaceInvites = seenSpaceInvites.toImmutableSet(), hideInvitesAvatar = hideInvitesAvatar, @@ -96,16 +97,45 @@ fun aSpaceState( eventSink = eventSink, ) -private fun aParentSpace( +private fun aSpaceInfo( joinRule: JoinRule? = null, -): SpaceRoom { - return aSpaceRoom( - numJoinedMembers = 5, - childrenCount = 10, - worldReadable = true, - joinRule = joinRule, - roomId = RoomId("!spaceId0:example.com"), +): RoomInfo { + return RoomInfo( + id = RoomId("!spaceId0:example.com"), + name = "A Space", + rawName = "A Space", topic = "Space description goes here. " + LoremIpsum(20).values.first(), + avatarUrl = null, + isPublic = true, + isDirect = false, + isEncrypted = false, + joinRule = joinRule, + isSpace = true, + isFavorite = false, + canonicalAlias = null, + alternativeAliases = persistentListOf(), + currentUserMembership = CurrentUserMembership.JOINED, + inviter = null, + activeMembersCount = 5, + invitedMembersCount = 0, + joinedMembersCount = 5, + roomPowerLevels = null, + highlightCount = 0, + notificationCount = 0, + userDefinedNotificationMode = null, + hasRoomCall = false, + activeRoomCallParticipants = persistentListOf(), + isMarkedUnread = false, + numUnreadMessages = 0, + numUnreadNotifications = 0, + numUnreadMentions = 0, + heroes = persistentListOf(), + pinnedEventIds = persistentListOf(), + creators = persistentListOf(), + historyVisibility = RoomHistoryVisibility.Joined, + successorRoom = null, + roomVersion = "11", + privilegedCreatorRole = false, ) } 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 5dcb57dfdb..0887e69e7f 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 @@ -77,7 +77,9 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility import io.element.android.libraries.matrix.ui.components.JoinButton import io.element.android.libraries.matrix.ui.components.SpaceHeaderView import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView @@ -131,7 +133,7 @@ fun SpaceView( exit = fadeOut() ) { SpaceViewTopBar( - currentSpace = state.currentSpace, + spaceInfo = state.spaceInfo, canAccessSpaceSettings = state.canAccessSpaceSettings, showManageRoomsAction = state.showManageRoomsAction, onBackClick = onBackClick, @@ -166,7 +168,7 @@ fun SpaceView( eventSink = state.eventSink ) RemoveRoomsActionView( - spaceDisplayName = state.currentSpaceDisplayName, + spaceDisplayName = state.spaceInfo.name ?: state.spaceInfo.id.value, removeRoomsAction = state.removeRoomsAction, selectedCount = state.selectedCount, onConfirm = { state.eventSink(SpaceEvents.ConfirmRoomRemoval) }, @@ -235,27 +237,25 @@ private fun SpaceViewContent( modifier: Modifier = Modifier, ) { LazyColumn(modifier.fillMaxSize()) { - val currentSpace = state.currentSpace - if (currentSpace != null) { - item(key = "space_header") { - AnimatedVisibility( - !state.isManageMode, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically() - ) { - Column { - SpaceHeaderView( - avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader), - name = currentSpace.displayName, - topic = currentSpace.topic, - topicMaxLines = 2, - visibility = currentSpace.visibility, - heroes = currentSpace.heroes.toImmutableList(), - numberOfMembers = currentSpace.numJoinedMembers, - onTopicClick = onTopicClick - ) - HorizontalDivider() - } + val spaceInfo = state.spaceInfo + item(key = "space_header") { + AnimatedVisibility( + !state.isManageMode, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + SpaceHeaderView( + avatarData = spaceInfo.getAvatarData(AvatarSize.SpaceHeader), + name = spaceInfo.name, + topic = spaceInfo.topic, + topicMaxLines = 2, + visibility = SpaceRoomVisibility.fromJoinRule(spaceInfo.joinRule), + heroes = spaceInfo.heroes, + numberOfMembers = spaceInfo.joinedMembersCount.toInt(), + onTopicClick = onTopicClick + ) + HorizontalDivider() } } } @@ -337,7 +337,7 @@ private fun LoadingMoreIndicator( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SpaceViewTopBar( - currentSpace: SpaceRoom?, + spaceInfo: RoomInfo, canAccessSpaceSettings: Boolean, showManageRoomsAction: Boolean, onBackClick: () -> Unit, @@ -354,16 +354,14 @@ private fun SpaceViewTopBar( BackButton(onClick = onBackClick) }, title = { - if (currentSpace != null) { - val roundedCornerShape = RoundedCornerShape(8.dp) - SpaceAvatarAndNameRow( - name = currentSpace.displayName, - avatarData = currentSpace.getAvatarData(AvatarSize.TimelineRoom), - modifier = Modifier - .clip(roundedCornerShape) - .clickable(enabled = canAccessSpaceSettings, onClick = onSettingsClick) - ) - } + val roundedCornerShape = RoundedCornerShape(8.dp) + SpaceAvatarAndNameRow( + name = spaceInfo.name, + avatarData = spaceInfo.getAvatarData(AvatarSize.TimelineRoom), + modifier = Modifier + .clip(roundedCornerShape) + .clickable(enabled = canAccessSpaceSettings, onClick = onSettingsClick) + ) }, actions = { var showMenu by remember { mutableStateOf(false) } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index 08fb977d1b..f5d9f96e27 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList @@ -63,7 +64,7 @@ class SpacePresenterTest { val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) presenter.test { val state = awaitItem() - assertThat(state.currentSpace).isNull() + assertThat(state.spaceInfo).isNotNull() assertThat(state.children).isEmpty() assertThat(state.seenSpaceInvites).isEmpty() assertThat(state.hideInvitesAvatar).isFalse() @@ -143,23 +144,6 @@ class SpacePresenterTest { } } - @Test - fun `present - current space value`() = runTest { - val paginateResult = lambdaRecorder> { - Result.success(Unit) - } - val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) - val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) - presenter.test { - val state = awaitItem() - advanceUntilIdle() - assertThat(state.currentSpace).isNull() - val aSpace = aSpaceRoom() - spaceRoomList.emitCurrentSpace(aSpace) - assertThat(awaitItem().currentSpace).isEqualTo(aSpace) - } - } - @Test fun `present - children value`() = runTest { val paginateResult = lambdaRecorder> { 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 3ef5151a50..57e14b05e5 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 @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_TOPIC +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled @@ -127,7 +128,7 @@ class SpaceViewTest { val eventsRecorder = EventsRecorder() rule.setSpaceView( aSpaceState( - parentSpace = aSpaceRoom(topic = A_ROOM_TOPIC), + spaceInfo = aRoomInfo(topic = A_ROOM_TOPIC), hasMoreToLoad = false, eventSink = eventsRecorder, ) From b4f15e595d0c2360295d5038efe24597e7bb7199 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 15 Jan 2026 17:43:44 +0100 Subject: [PATCH 23/29] Change canEditSpaceGraph to observe space settings feature flag --- .../android/features/space/impl/root/SpacePresenter.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 60db576df8..753dcae72a 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -102,6 +102,9 @@ class SpacePresenter( val canAccessSpaceSettings by remember { derivedStateOf { isSpaceSettingsEnabled && permissions.settingsPermissions.hasAny(roomInfo.joinRule) } } + val canEditSpaceGraph by remember { + derivedStateOf { isSpaceSettingsEnabled && permissions.canEditSpaceGraph } + } val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap>()) } var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) } @@ -225,7 +228,7 @@ class SpacePresenter( canAccessSpaceSettings = canAccessSpaceSettings, isManageMode = isManageMode, selectedRoomIds = selectedRoomIds.toImmutableSet(), - canEditSpaceGraph = permissions.canEditSpaceGraph, + canEditSpaceGraph = canEditSpaceGraph, removeRoomsAction = removeRoomsAction, eventSink = ::handleEvent, ) From 9d5b2c57bcbf66b75a2ffe70be371f42491cc9b3 Mon Sep 17 00:00:00 2001 From: ganfra Date: Thu, 15 Jan 2026 18:18:27 +0100 Subject: [PATCH 24/29] Remove unused imports and fix code style issues --- .../android/features/space/impl/root/SpacePermissions.kt | 1 - .../android/features/space/impl/root/SpacePresenter.kt | 2 -- .../element/android/features/space/impl/root/SpaceView.kt | 2 -- .../features/space/impl/root/SpacePresenterTest.kt | 8 +++++--- .../android/features/space/impl/root/SpaceStateTest.kt | 2 +- .../android/features/space/impl/root/SpaceViewTest.kt | 1 - 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt index 90ee8e27f1..4a43aceb86 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt @@ -30,4 +30,3 @@ fun RoomPermissions.spacePermissions(): SpacePermissions { canEditSpaceGraph = canOwnUserSendState(StateEventType.SpaceChild) || canOwnUserSendState(StateEventType.SpaceParent), ) } - diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index 753dcae72a..a5bc2ec52e 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -23,8 +23,6 @@ import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.features.invite.api.toInviteData -import io.element.android.features.space.impl.settings.SpaceSettingsPermissions -import io.element.android.features.space.impl.settings.spaceSettingsPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState 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 0887e69e7f..695b887496 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 @@ -86,7 +86,6 @@ import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @@ -102,7 +101,6 @@ fun SpaceView( modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { - BackHandler { if (state.isManageMode) { state.eventSink(SpaceEvents.ExitManageMode) diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index f5d9f96e27..811e9158b9 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -36,7 +36,6 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeBaseRoom -import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList @@ -433,8 +432,11 @@ class SpacePresenterTest { roomType = RoomType.Room, ) val removeChildFromSpaceResult = lambdaRecorder> { _, childId -> - if (childId == A_ROOM_ID_2) Result.failure(AN_EXCEPTION) - else Result.success(Unit) + if (childId == A_ROOM_ID_2) { + Result.failure(AN_EXCEPTION) + } else { + Result.success(Unit) + } } val fakeSpaceRoomList = FakeSpaceRoomList( initialSpaceRoomsValue = listOf(aRoom1, aRoom2), diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt index ea9e5a2a30..65bb740541 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt @@ -10,11 +10,11 @@ package io.element.android.features.space.impl.root import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 -import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.previewutils.room.aSpaceRoom import org.junit.Test 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 57e14b05e5..27970e93f8 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 @@ -12,7 +12,6 @@ import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 From d7c771f431f34790bba129bc0109cbfaaa58d815 Mon Sep 17 00:00:00 2001 From: ElementBot Date: Thu, 15 Jan 2026 17:56:01 +0000 Subject: [PATCH 25/29] Update screenshots --- .../images/features.space.impl.root_SpaceView_Day_0_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_1_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_2_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_3_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_4_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_5_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Day_6_en.png | 3 +++ .../images/features.space.impl.root_SpaceView_Day_7_en.png | 3 +++ .../images/features.space.impl.root_SpaceView_Day_8_en.png | 3 +++ .../images/features.space.impl.root_SpaceView_Night_0_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_1_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_2_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_3_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_4_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_5_en.png | 4 ++-- .../images/features.space.impl.root_SpaceView_Night_6_en.png | 3 +++ .../images/features.space.impl.root_SpaceView_Night_7_en.png | 3 +++ .../images/features.space.impl.root_SpaceView_Night_8_en.png | 3 +++ 18 files changed, 42 insertions(+), 24 deletions(-) create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_8_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_6_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_7_en.png create mode 100644 tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_8_en.png diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png index 0f62df3d20..f69878ffd5 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c55277089a4447b618a0e8c058718ecf9d3da6d437322f0e23e5fd70019f6b00 -size 34585 +oid sha256:e59a9e2ae6ef36f28e61534b5639314cc840953df51bb1660e77e8d565865357 +size 32998 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png index bb1c9d1947..7f66fa5a85 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:241f5500cb7212fac174466bbe7855ccf39de3e3764a83202388b947d90ae807 -size 34770 +oid sha256:b854ce2b0618ebcbc88eff9952d6869bceeb8b43f9eccb8f8e5feef225e0a4c2 +size 33181 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png index 6f624546ab..b0f6f289a0 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:92785cf3a4010779b0fbcd58be3437a22808b0a2f02a19a5cfd50eb3bd58ed26 -size 35058 +oid sha256:e1cf063ee5c9fbc50a53445050780ba239d4fb0fd1e9903a578eab8dd3bfc257 +size 33496 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png index 8b5b2f5f27..330b4d2dca 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:de26882f13bac98b2cb5365d98e06e781d516d179adb8328cf22cf524e6fd79e -size 62568 +oid sha256:669287557e8effe8154682d33de45430fc852ddefa25ed7399aa3917730e2893 +size 61083 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png index cfa8381d77..53464e9cd5 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a121fdb9473512b0264e48294df1799a7a6bf9b469df973fbe41f31bbf98f1d0 -size 63248 +oid sha256:d2ef07a1af8f872a5b7bb708314bc180d4eefe9545c763a0860f895af6e3ce37 +size 61755 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png index f20b7c4048..97dbb10528 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3506b4f646408262450ae51b612f86c1171ed972c1d7ea8871c4dc090556c7a -size 59702 +oid sha256:525059397001897f705630b8ac5a661439a502f2e623fdca252f9e86f97133e4 +size 58181 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_6_en.png new file mode 100644 index 0000000000..bc44bb4ce4 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59af097a2152b235c7df83aa76eea880ceef65edebebecb86f00745ec39712f4 +size 35937 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_7_en.png new file mode 100644 index 0000000000..68f915afff --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5151bbd3effcd6a46724a0cfb13730e845a3b8ba489826146c76d0a797403e0 +size 36502 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_8_en.png new file mode 100644 index 0000000000..64ce2a5fe0 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Day_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2b445ae9e0ff01dea22b8ffb98aaaf4c5593093e2b2bea2ff9508cc43832e687 +size 48283 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png index e8f00148a3..9d406ab88a 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f2407444889af236ef21a90c47a5d3e05df8b15b9cc9483e84377e3af8794772 -size 33996 +oid sha256:47c8bdf4d153ecebe749ae3cacfe4c9a1c59ac836106fb0903dca80305aedac2 +size 32412 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png index 6f012ad603..d178c45fec 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fee41efefc2ca1d6670d8455ac756c6b314aab54510eab8a4e597f1cc1edf3f8 -size 34141 +oid sha256:33917297cb1c6d38e5b955757de50f4ff6b73844bb87e8e88d0885daa01b266b +size 32556 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png index 24f916eac9..35ca1a2937 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ea955839cbd1aeba5de2780cee413628c7d46383398b10125cd3a900fb41d5a5 -size 34459 +oid sha256:930f763051533ea3aa4032e483e15e0c1ccb1d213d2e59bebe7f934517b500ba +size 32868 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png index 153b68c3d0..86af1703e4 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cba2c99744aeb2a869ae2ed700d7241b1d0b6ed979b16d2be9774ddbc5f8f28a -size 61381 +oid sha256:e38c4a6ff464f77da95b2aa0eaba647bc54ee590c2c4319478fcd54a2400b886 +size 59877 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png index f36d90e33c..b2628c4761 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b32d65accabc357208efeb2ec61374182479541299ade28184f82938e59bfdd5 -size 61932 +oid sha256:8d91c27ee6dc7938ce905774868b66157b06944ae7c230935c30d6be8ce189be +size 60430 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png index 66a7762467..5f2f43a920 100644 --- a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_5_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7c161ff55e8a235fe403e53ee179b299fd2563d85ef64bfe6d0dd9295228685b -size 57925 +oid sha256:dd673c2cf628285836848a732d3972a17421a2394464a3a77e7a49e4c5a862f1 +size 56636 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_6_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_6_en.png new file mode 100644 index 0000000000..3078485999 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_6_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b459fa8aa195dcf85995ac51c2f079e9d2505efede138bcef94c0a5049e1f271 +size 35266 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_7_en.png new file mode 100644 index 0000000000..7741306594 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36fd123fcff07169723857c99e4e375fe9bbb8193b2a6e97508343ea025d5787 +size 35782 diff --git a/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_8_en.png b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_8_en.png new file mode 100644 index 0000000000..5db2323ea6 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.space.impl.root_SpaceView_Night_8_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a5eed7b1690ac5fbd24312e37029ca880b5cf4424e3796b116829074ef891f2d +size 45433 From f999898b92e47e1999673a9591e0eb4b5026d9a1 Mon Sep 17 00:00:00 2001 From: ganfra Date: Fri, 16 Jan 2026 11:18:16 +0100 Subject: [PATCH 26/29] Add doc to SpacePermissions data class --- .../android/features/space/impl/root/SpacePermissions.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt index 4a43aceb86..fc9f5c9b0c 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt @@ -12,6 +12,11 @@ import io.element.android.features.space.impl.settings.spaceSettingsPermissions import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions +/** + * Permissions needed for different actions in the Space screen. + * @param settingsPermissions Permissions related to space settings. + * @param canEditSpaceGraph Whether the user can edit the space graph (add/remove children). + */ data class SpacePermissions( val settingsPermissions: SpaceSettingsPermissions, val canEditSpaceGraph: Boolean, From a464e2957057dcf15ad3265f5fabd3ad877dc1e3 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Fri, 16 Jan 2026 16:03:49 +0100 Subject: [PATCH 27/29] Create `AppMigration09` to remove the cached `well-known` config from the SDK (#6026) This value was most likely was incorrectly cached due to a previous issue in the SDK --- .../impl/migrations/AppMigration09.kt | 38 +++++++++++++++ .../impl/migrations/AppMigration09Test.kt | 46 +++++++++++++++++++ .../libraries/matrix/api/MatrixClient.kt | 8 ++++ .../libraries/matrix/impl/RustMatrixClient.kt | 7 +++ .../libraries/matrix/test/FakeMatrixClient.kt | 5 ++ 5 files changed, 104 insertions(+) create mode 100644 features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt create mode 100644 features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt new file mode 100644 index 0000000000..a420ac8e8f --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt @@ -0,0 +1,38 @@ +/* + * 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.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore + +/** + * Ensure we clear the well-known cached config, since it could be invalid due to an SDK issue. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration09( + private val sessionStore: SessionStore, + private val matrixClientProvider: MatrixClientProvider, +) : AppMigration { + override val order: Int = 9 + + override suspend fun migrate(isFreshInstall: Boolean) { + if (isFreshInstall) return + + val sessions = sessionStore.getAllSessions() + + for (session in sessions) { + val client = matrixClientProvider.getOrRestore(SessionId(session.userId)).getOrNull() ?: continue + client.resetWellKnownConfig() + } + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt new file mode 100644 index 0000000000..380ea97464 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt @@ -0,0 +1,46 @@ +/* + * 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.migration.impl.migrations + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AppMigration09Test { + @Test + fun `migration on fresh install does nothing`() = runTest { + val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())) + val getClientLambda = lambdaRecorder> { Result.success(FakeMatrixClient()) } + val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda) + val migration = AppMigration09(sessionStore, clientProvider) + migration.migrate(isFreshInstall = true) + + getClientLambda.assertions().isNeverCalled() + } + + @Test + fun `migration on upgrade should invoke the resetWellKnownConfig method`() = runTest { + val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())) + val resetWellKnownLambda = lambdaRecorder> { Result.success(Unit) } + val getClientLambda = lambdaRecorder> { + Result.success(FakeMatrixClient(resetWellKnownConfigLambda = resetWellKnownLambda)) + } + val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda) + val migration = AppMigration09(sessionStore, clientProvider) + migration.migrate(isFreshInstall = false) + + getClientLambda.assertions().isCalledOnce() + resetWellKnownLambda.assertions().isCalledOnce() + } +} 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 dca269453a..773dbaaa07 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 @@ -214,7 +214,15 @@ interface MatrixClient { */ fun createLinkDesktopHandler(): Result + /** + * Performs a database optimization that should flush cached data and improve performance. + */ suspend fun performDatabaseVacuum(): Result + + /** + * Resets the cached client `well-known` config by the SDK. + */ + suspend fun resetWellKnownConfig(): Result } /** 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 f17f92517a..b24f4b90d8 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 @@ -791,6 +791,13 @@ class RustMatrixClient( } } + override suspend fun resetWellKnownConfig(): Result { + return runCatchingExceptions { + Timber.d("Resetting well-known config for session $sessionId") + innerClient.resetWellKnown() + } + } + private suspend fun getCacheSize( includeCryptoDb: Boolean = false, ): Long = withContext(sessionDispatcher) { 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 a740e71b0c..56527574d7 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 @@ -112,6 +112,7 @@ class FakeMatrixClient( private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, private val performDatabaseVacuumLambda: () -> Result = { lambdaError() }, private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, + private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -379,4 +380,8 @@ class FakeMatrixClient( override fun createLinkMobileHandler(): Result { return createLinkMobileHandlerResult() } + + override suspend fun resetWellKnownConfig(): Result { + return resetWellKnownConfigLambda() + } } From 3974f005e4f1dca9acd5217687a1068f15054fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 16 Jan 2026 16:07:59 +0100 Subject: [PATCH 28/29] Setting version for the release 26.01.1 --- plugins/src/main/kotlin/Versions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index 8ae9e90dad..4b5f9c9c50 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -45,7 +45,7 @@ private const val versionMonth = 1 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 0 +private const val versionReleaseNumber = 1 object Versions { /** From 43d93a2c835e0b0b374f9cf8fe2ed3cd14d87188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jorge=20Mart=C3=ADn?= Date: Fri, 16 Jan 2026 16:09:21 +0100 Subject: [PATCH 29/29] Adding fastlane file for version 26.01.1 --- fastlane/metadata/android/en-US/changelogs/202601010.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/202601010.txt diff --git a/fastlane/metadata/android/en-US/changelogs/202601010.txt b/fastlane/metadata/android/en-US/changelogs/202601010.txt new file mode 100644 index 0000000000..605371b852 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202601010.txt @@ -0,0 +1,2 @@ +Main changes in this version: iterated on spaces, improved the room list stability and performance, fix an issue with the cached well-known config. +Full changelog: https://github.com/element-hq/element-x-android/releases