diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt index 3d62e7b5d7..844c4f1a70 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt @@ -24,6 +24,7 @@ import io.element.android.annotations.ContributesNode import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.impl.editroomaddress.EditRoomAddressNode +import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.ManageAuthorizedSpacesNode import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode @@ -58,6 +59,9 @@ class SecurityAndPrivacyFlowNode( @Parcelize data object EditRoomAddress : NavTarget + + @Parcelize + data object ManageAuthorizedSpaces : NavTarget } private val callback: SecurityAndPrivacyEntryPoint.Callback = callback() @@ -89,6 +93,9 @@ class SecurityAndPrivacyFlowNode( NavTarget.EditRoomAddress -> { createNode(buildContext, plugins = listOf(navigator)) } + NavTarget.ManageAuthorizedSpaces -> { + createNode(buildContext, plugins = listOf(navigator)) + } } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt index 092da87943..274bf0b823 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt @@ -18,6 +18,8 @@ interface SecurityAndPrivacyNavigator : Plugin { fun onDone() fun openEditRoomAddress() fun closeEditRoomAddress() + fun openManageAuthorizedSpaces() + fun closeManageAuthorizedSpaces() } class BackstackSecurityAndPrivacyNavigator( @@ -35,4 +37,12 @@ class BackstackSecurityAndPrivacyNavigator( override fun closeEditRoomAddress() { backStack.pop() } + + override fun openManageAuthorizedSpaces() { + backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces) + } + + override fun closeManageAuthorizedSpaces() { + backStack.pop() + } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt index ed49e048aa..513ecc980a 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt @@ -36,7 +36,6 @@ 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.spaces.SpaceRoom import io.element.android.libraries.matrix.ui.model.getAvatarData import io.element.android.libraries.ui.strings.CommonStrings @@ -80,11 +79,11 @@ fun ManageAuthorizedSpacesView( } ) } - if(state.unknownSpaceIds.isNotEmpty()){ + if (state.unknownSpaceIds.isNotEmpty()) { item { ListSectionHeader( title = stringResource(R.string.screen_manage_authorized_spaces_unknown_spaces_section_title), - hasDivider = false, + hasDivider = true, ) } items(items = state.unknownSpaceIds) { @@ -140,7 +139,7 @@ private fun CheckableSpaceListItem( Text(text = supportingText) } }, - leadingContent = avatarData?.let{ + leadingContent = avatarData?.let { ListItemContent.Custom { Avatar( avatarData = avatarData, @@ -158,7 +157,6 @@ private fun CheckableSpaceListItem( ) } - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ManageAuthorizedSpacesTopBar( diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvent.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvent.kt index 39abedab8c..0c56a834de 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvent.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvent.kt @@ -10,10 +10,13 @@ package io.element.android.features.securityandprivacy.impl.root sealed interface SecurityAndPrivacyEvent { data object EditRoomAddress : SecurityAndPrivacyEvent + data object ManageAuthorizedSpaces : SecurityAndPrivacyEvent data object Save : SecurityAndPrivacyEvent data object Exit : SecurityAndPrivacyEvent data object DismissExitConfirmation : SecurityAndPrivacyEvent data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvent + // Special case for "Space Members" + data object SelectSpaceMemberAccess : SecurityAndPrivacyEvent data object ToggleEncryptionState : SecurityAndPrivacyEvent data object CancelEnableEncryption : SecurityAndPrivacyEvent data object ConfirmEnableEncryption : SecurityAndPrivacyEvent diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt index e627fef40c..d9038133e6 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -37,9 +38,15 @@ import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.room.JoinedRoom 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.AllowRule import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -86,7 +93,7 @@ class SecurityAndPrivacyPresenter( } } - var editedRoomAccess by remember(savedSettings.roomAccess) { + val editedRoomAccess = remember(savedSettings.roomAccess) { mutableStateOf(savedSettings.roomAccess) } var editedHistoryVisibility by remember(savedSettings.historyVisibility) { @@ -99,13 +106,26 @@ class SecurityAndPrivacyPresenter( mutableStateOf(savedIsVisibleInRoomDirectory.value) } val editedSettings = SecurityAndPrivacySettings( - roomAccess = editedRoomAccess, + roomAccess = editedRoomAccess.value, isEncrypted = editedIsEncrypted, isVisibleInRoomDirectory = editedVisibleInRoomDirectory, historyVisibility = editedHistoryVisibility, address = savedSettings.address, ) + val selectableJoinedSpaces by produceState(persistentSetOf()) { + val joinedParentSpaces = matrixClient + .spaceService + .joinedParents(room.roomId) + .getOrDefault(emptyList()) + + val nonParentJoinedSpaces = savedSettings.roomAccess + .spaceIds() + .mapNotNull { spaceId -> matrixClient.spaceService.getSpaceRoom(spaceId) } + + value = (joinedParentSpaces + nonParentJoinedSpaces).toImmutableSet() + } + var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms -> perms.securityAndPrivacyPermissions() @@ -122,7 +142,7 @@ class SecurityAndPrivacyPresenter( ) } is SecurityAndPrivacyEvent.ChangeRoomAccess -> { - editedRoomAccess = event.roomAccess + editedRoomAccess.value = event.roomAccess } is SecurityAndPrivacyEvent.ToggleEncryptionState -> { if (editedIsEncrypted) { @@ -161,6 +181,12 @@ class SecurityAndPrivacyPresenter( SecurityAndPrivacyEvent.DismissExitConfirmation -> { saveAction.value = AsyncAction.Uninitialized } + SecurityAndPrivacyEvent.ManageAuthorizedSpaces -> navigator.openManageAuthorizedSpaces() + SecurityAndPrivacyEvent.SelectSpaceMemberAccess -> handleSpaceMemberAccessSelection( + selectableJoinedSpaces = selectableJoinedSpaces, + savedAccess = savedSettings.roomAccess, + editedAccess = editedRoomAccess, + ) } } @@ -179,13 +205,14 @@ class SecurityAndPrivacyPresenter( saveAction = saveAction.value, permissions = permissions, isSpace = roomInfo.isSpace, + selectableJoinedSpaces = selectableJoinedSpaces, eventSink = ::handleEvent, ) // Revert changes that the user is not allowed to make anymore LaunchedEffect(permissions, state.editedSettings.roomAccess) { if (!state.showRoomAccessSection) { - editedRoomAccess = savedSettings.roomAccess + editedRoomAccess.value = savedSettings.roomAccess } if (!state.showEncryptionSection) { editedIsEncrypted = savedSettings.isEncrypted @@ -202,6 +229,51 @@ class SecurityAndPrivacyPresenter( return state } + private fun handleSpaceMemberAccessSelection( + selectableJoinedSpaces: Set, + savedAccess: SecurityAndPrivacyRoomAccess, + editedAccess: MutableState, + ) { + if(editedAccess.value is SecurityAndPrivacyRoomAccess.SpaceMember) { + return + } + val spaceSelection = getSpaceSelection(selectableJoinedSpaces, savedAccess) + when(spaceSelection){ + is SpaceSelection.None -> Unit + is SpaceSelection.Multiple -> navigator.openManageAuthorizedSpaces() + is SpaceSelection.Single -> { + val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember( + spaceIds = persistentListOf(spaceSelection.spaceId) + ) + editedAccess.value = newRoomAccess + } + } + } + + private fun getSpaceSelection( + selectableJoinedSpaces: Set, + savedAccess: SecurityAndPrivacyRoomAccess, + ): SpaceSelection { + val selectableSpacesCount = (selectableJoinedSpaces.map { it.roomId } + savedAccess.spaceIds()).toSet().size + return when { + selectableSpacesCount == 0 -> SpaceSelection.None + selectableSpacesCount > 1 -> SpaceSelection.Multiple + else -> { + val joinedSpace = selectableJoinedSpaces.firstOrNull() + if (joinedSpace != null) { + SpaceSelection.Single(joinedSpace.roomId, joinedSpace) + } else { + val spaceId = savedAccess.spaceIds().firstOrNull() + if (spaceId == null) { + SpaceSelection.None + } else { + SpaceSelection.Single(spaceId, null) + } + } + } + } + } + private fun CoroutineScope.isRoomVisibleInRoomDirectory(isRoomVisible: MutableState>) = launch { isRoomVisible.runUpdatingState { room.getRoomVisibility().map { it == RoomVisibility.Public } @@ -280,7 +352,12 @@ private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess { return when (this) { JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin - is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember + is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember( + spaceIds = this.rules + .filterIsInstance() + .map { it.roomId } + .toImmutableList() + ) JoinRule.Invite -> SecurityAndPrivacyRoomAccess.InviteOnly // All other cases are not supported so we default to InviteOnly is JoinRule.Custom, @@ -294,8 +371,9 @@ private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? { SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private - // SpaceMember can't be selected in the ui - SecurityAndPrivacyRoomAccess.SpaceMember -> null + is SecurityAndPrivacyRoomAccess.SpaceMember -> JoinRule.Restricted( + rules = this.spaceIds.map { AllowRule.RoomMembership(it) }.toImmutableList() + ) } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt index e64cf633a8..b71f375fe7 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt @@ -11,6 +11,11 @@ package io.element.android.features.securityandprivacy.impl.root import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList data class SecurityAndPrivacyState( @@ -24,8 +29,10 @@ data class SecurityAndPrivacyState( val saveAction: AsyncAction, val isSpace: Boolean, private val permissions: SecurityAndPrivacyPermissions, + private val selectableJoinedSpaces: ImmutableSet, val eventSink: (SecurityAndPrivacyEvent) -> Unit ) { + val canBeSaved = savedSettings != editedSettings // Logic is in https://github.com/element-hq/element-meta/issues/3029 @@ -76,18 +83,31 @@ enum class SecurityAndPrivacyHistoryVisibility { } } -enum class SecurityAndPrivacyRoomAccess { - InviteOnly, - AskToJoin, - Anyone, - SpaceMember; +sealed interface SpaceSelection { + data object None : SpaceSelection + data class Single(val spaceId: RoomId, val spaceRoom: SpaceRoom?) : SpaceSelection + data object Multiple : SpaceSelection +} + +sealed interface SecurityAndPrivacyRoomAccess { + data object InviteOnly : SecurityAndPrivacyRoomAccess + data object AskToJoin : SecurityAndPrivacyRoomAccess + data object Anyone : SecurityAndPrivacyRoomAccess + data class SpaceMember(val spaceIds: ImmutableList) : SecurityAndPrivacyRoomAccess fun canConfigureRoomVisibility(): Boolean { return when (this) { - InviteOnly, SpaceMember -> false + InviteOnly, is SpaceMember -> false AskToJoin, Anyone -> true } } + + fun spaceIds(): ImmutableList { + return when (this) { + is SpaceMember -> spaceIds + else -> persistentListOf() + } + } } sealed class SecurityAndPrivacyFailures : Exception() { diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt index 223d524ca3..2c984fd16a 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt @@ -12,6 +12,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet open class SecurityAndPrivacyStateProvider : PreviewParameterProvider { override val values: Sequence @@ -61,7 +65,7 @@ private fun commonSecurityAndPrivacyStates(isSpace: Boolean): Sequence = emptySet(), eventSink: (SecurityAndPrivacyEvent) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, @@ -127,5 +132,6 @@ fun aSecurityAndPrivacyState( isKnockEnabled = isKnockEnabled, permissions = permissions, isSpace = isSpace, + selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), eventSink = eventSink, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt index bf6a5ffdf2..dc8c349bbe 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt @@ -50,6 +50,7 @@ import io.element.android.libraries.designsystem.text.stringWithLink import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader 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 @@ -95,6 +96,8 @@ fun SecurityAndPrivacyView( saved = state.savedSettings.roomAccess, isKnockEnabled = state.isKnockEnabled, onSelectOption = { state.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(it)) }, + onManageSpacesClick = { state.eventSink(SecurityAndPrivacyEvent.ManageAuthorizedSpaces) }, + onSpaceMemberAccessClick = { state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) } ) } if (state.showRoomVisibilitySections) { @@ -212,6 +215,8 @@ private fun RoomAccessSection( saved: SecurityAndPrivacyRoomAccess, isKnockEnabled: Boolean, onSelectOption: (SecurityAndPrivacyRoomAccess) -> Unit, + onSpaceMemberAccessClick: () -> Unit, + onManageSpacesClick: () -> Unit, modifier: Modifier = Modifier, ) { SecurityAndPrivacySection( @@ -226,17 +231,15 @@ private fun RoomAccessSection( onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, ) // Show space member option, but disabled as we don't support this option for now. - if (saved == SecurityAndPrivacyRoomAccess.SpaceMember) { ListItem( headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description)) }, - trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.SpaceMember, enabled = false), + trailingContent = ListItemContent.RadioButton(selected = edited is SecurityAndPrivacyRoomAccess.SpaceMember), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Space())), - enabled = false, + onClick = onSpaceMemberAccessClick, ) - } // Show Ask to join option in two cases: // - the Knock FF is enabled // - AskToJoin is the current saved value @@ -257,6 +260,19 @@ private fun RoomAccessSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) }, ) + if (edited is SecurityAndPrivacyRoomAccess.SpaceMember) { + val footerText = stringWithLink( + textRes = R.string.screen_security_and_privacy_room_access_footer, + url = stringResource(R.string.screen_security_and_privacy_room_access_footer_manage_spaces_action), + onLinkClick = { onManageSpacesClick()}, + ) + Text( + text = footerText, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.padding(bottom = 12.dp, start = 56.dp, end = 24.dp) + ) + } } }