feature(security&privacy): start branching logic of ManageAuthorizedSpaces

This commit is contained in:
ganfra
2025-12-30 15:56:32 +01:00
parent b59e36aabd
commit 556fdadd7f
8 changed files with 161 additions and 23 deletions

View File

@@ -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<EditRoomAddressNode>(buildContext, plugins = listOf(navigator))
}
NavTarget.ManageAuthorizedSpaces -> {
createNode<ManageAuthorizedSpacesNode>(buildContext, plugins = listOf(navigator))
}
}
}

View File

@@ -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()
}
}

View File

@@ -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(

View File

@@ -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

View File

@@ -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<SpaceRoom>,
savedAccess: SecurityAndPrivacyRoomAccess,
editedAccess: MutableState<SecurityAndPrivacyRoomAccess>,
) {
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<SpaceRoom>,
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<AsyncData<Boolean>>) = 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<AllowRule.RoomMembership>()
.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()
)
}
}

View File

@@ -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<Unit>,
val isSpace: Boolean,
private val permissions: SecurityAndPrivacyPermissions,
private val selectableJoinedSpaces: ImmutableSet<SpaceRoom>,
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<RoomId>) : SecurityAndPrivacyRoomAccess
fun canConfigureRoomVisibility(): Boolean {
return when (this) {
InviteOnly, SpaceMember -> false
InviteOnly, is SpaceMember -> false
AskToJoin, Anyone -> true
}
}
fun spaceIds(): ImmutableList<RoomId> {
return when (this) {
is SpaceMember -> spaceIds
else -> persistentListOf()
}
}
}
sealed class SecurityAndPrivacyFailures : Exception() {

View File

@@ -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<SecurityAndPrivacyState> {
override val values: Sequence<SecurityAndPrivacyState>
@@ -61,7 +65,7 @@ private fun commonSecurityAndPrivacyStates(isSpace: Boolean): Sequence<SecurityA
),
aSecurityAndPrivacyState(
savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(persistentListOf())
),
isSpace = isSpace,
isKnockEnabled = false,
@@ -117,6 +121,7 @@ fun aSecurityAndPrivacyState(
),
isKnockEnabled: Boolean = true,
isSpace: Boolean = false,
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState(
editedSettings = editedSettings,
@@ -127,5 +132,6 @@ fun aSecurityAndPrivacyState(
isKnockEnabled = isKnockEnabled,
permissions = permissions,
isSpace = isSpace,
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
eventSink = eventSink,
)

View File

@@ -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)
)
}
}
}