Merge pull request #5979 from element-hq/feature/fga/space_members_access

Change Room’s Access to/from Space members
This commit is contained in:
ganfra 2026-01-12 10:48:44 +01:00 committed by GitHub
commit 9a40a6404e
85 changed files with 2535 additions and 571 deletions

View file

@ -9,6 +9,7 @@
package io.element.android.features.securityandprivacy.impl package io.element.android.features.securityandprivacy.impl
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@ -24,6 +25,7 @@ import io.element.android.annotations.ContributesNode
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.editroomaddress.EditRoomAddressNode 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.features.securityandprivacy.impl.root.SecurityAndPrivacyNode
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
@ -58,10 +60,15 @@ class SecurityAndPrivacyFlowNode(
@Parcelize @Parcelize
data object EditRoomAddress : NavTarget data object EditRoomAddress : NavTarget
@Parcelize
data object ManageAuthorizedSpaces : NavTarget
} }
private val callback: SecurityAndPrivacyEntryPoint.Callback = callback() private val callback: SecurityAndPrivacyEntryPoint.Callback = callback()
private val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack)
@VisibleForTesting
val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack)
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
@ -89,6 +96,9 @@ class SecurityAndPrivacyFlowNode(
NavTarget.EditRoomAddress -> { NavTarget.EditRoomAddress -> {
createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator)) 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 onDone()
fun openEditRoomAddress() fun openEditRoomAddress()
fun closeEditRoomAddress() fun closeEditRoomAddress()
fun openManageAuthorizedSpaces()
fun closeManageAuthorizedSpaces()
} }
class BackstackSecurityAndPrivacyNavigator( class BackstackSecurityAndPrivacyNavigator(
@ -35,4 +37,12 @@ class BackstackSecurityAndPrivacyNavigator(
override fun closeEditRoomAddress() { override fun closeEditRoomAddress() {
backStack.pop() backStack.pop()
} }
override fun openManageAuthorizedSpaces() {
backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces)
}
override fun closeManageAuthorizedSpaces() {
backStack.pop()
}
} }

View file

@ -0,0 +1,16 @@
/*
* Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface ManageAuthorizedSpacesEvent {
data object Cancel : ManageAuthorizedSpacesEvent
data object Done : ManageAuthorizedSpacesEvent
data class ToggleSpace(val roomId: RoomId) : ManageAuthorizedSpacesEvent
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope
@ContributesNode(RoomScope::class)
@AssistedInject
class ManageAuthorizedSpacesNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenter: ManageAuthorizedSpacesPresenter,
) : Node(buildContext, plugins = plugins) {
private val stateFlow = launchMolecule { presenter.present() }
@Composable
override fun View(modifier: Modifier) {
val state by stateFlow.collectAsState()
ManageAuthorizedSpacesView(
state = state,
modifier = modifier
)
}
}

View file

@ -0,0 +1,51 @@
/*
* Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter
import kotlinx.collections.immutable.toImmutableList
@Inject
class ManageAuthorizedSpacesPresenter(
private val spaceSelectionStateHolder: SpaceSelectionStateHolder,
) : Presenter<ManageAuthorizedSpacesState> {
@Composable
override fun present(): ManageAuthorizedSpacesState {
val spaceSelectionState by spaceSelectionStateHolder.state.collectAsState()
fun handleEvent(event: ManageAuthorizedSpacesEvent) {
when (event) {
is ManageAuthorizedSpacesEvent.ToggleSpace -> {
val currentSelectedIds = spaceSelectionState.selectedSpaceIds
val newSelectedIds = if (currentSelectedIds.contains(event.roomId)) {
currentSelectedIds.minus(event.roomId).toImmutableList()
} else {
currentSelectedIds.plus(event.roomId).toImmutableList()
}
spaceSelectionStateHolder.updateSelectedSpaceIds(newSelectedIds)
}
ManageAuthorizedSpacesEvent.Done -> {
spaceSelectionStateHolder.setCompletion(SpaceSelectionState.Completion.Completed)
}
ManageAuthorizedSpacesEvent.Cancel -> {
spaceSelectionStateHolder.setCompletion(SpaceSelectionState.Completion.Cancelled)
}
}
}
return ManageAuthorizedSpacesState(
selectableSpaces = spaceSelectionState.selectableSpaces,
unknownSpaceIds = spaceSelectionState.unknownSpaceIds,
selectedIds = spaceSelectionState.selectedSpaceIds,
eventSink = ::handleEvent,
)
}
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces
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
data class ManageAuthorizedSpacesState(
val selectableSpaces: ImmutableSet<SpaceRoom>,
val unknownSpaceIds: ImmutableList<RoomId>,
val selectedIds: ImmutableList<RoomId>,
val eventSink: (ManageAuthorizedSpacesEvent) -> Unit
) {
val isDoneButtonEnabled = selectedIds.isNotEmpty()
}

View file

@ -0,0 +1,57 @@
/*
* Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
open class ManageAuthorizedSpacesStateProvider : PreviewParameterProvider<ManageAuthorizedSpacesState> {
override val values: Sequence<ManageAuthorizedSpacesState>
get() = sequenceOf(
aManageAuthorizedSpacesState(),
aManageAuthorizedSpacesState(
unknownSpaceIds = listOf(aRoomId(99))
),
aManageAuthorizedSpacesState(
selectedIds = listOf(aRoomId(1), aRoomId(3)),
),
)
}
private fun aRoomId(index: Int) = RoomId("!roomId$index:matrix.org")
private fun aSpaceRoomList(count: Int): List<SpaceRoom> {
return (1..count).map { index ->
aSpaceRoom(
roomId = aRoomId(index),
displayName = "Space $index",
canonicalAlias = if (index % 2 == 0) {
RoomAlias("#space$index:matrix.org")
} else {
null
}
)
}
}
fun aManageAuthorizedSpacesState(
selectableSpaces: List<SpaceRoom> = aSpaceRoomList(5),
unknownSpaceIds: List<RoomId> = emptyList(),
selectedIds: List<RoomId> = emptyList(),
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {},
) = ManageAuthorizedSpacesState(
selectableSpaces = selectableSpaces.toImmutableSet(),
unknownSpaceIds = unknownSpaceIds.toImmutableList(),
selectedIds = selectedIds.toImmutableList(),
eventSink = eventSink,
)

View file

@ -0,0 +1,197 @@
/*
* Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.securityandprivacy.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.avatar.Avatar
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.list.ListItemContent
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
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
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
// Figma design: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=6361-86274&m=dev
@Composable
fun ManageAuthorizedSpacesView(
state: ManageAuthorizedSpacesState,
modifier: Modifier = Modifier,
) {
fun onCancel() {
state.eventSink(ManageAuthorizedSpacesEvent.Cancel)
}
fun onDone() {
state.eventSink(ManageAuthorizedSpacesEvent.Done)
}
BackHandler(onBack = ::onCancel)
Scaffold(
modifier = modifier,
topBar = {
ManageAuthorizedSpacesTopBar(
onBackClick = ::onCancel,
onDoneClick = ::onDone,
isDoneButtonEnabled = state.isDoneButtonEnabled
)
}
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding)
) {
headerItem()
item {
ListSectionHeader(
title = stringResource(R.string.screen_manage_authorized_spaces_your_spaces_section_title),
hasDivider = false,
)
}
items(items = state.selectableSpaces.toList()) { space ->
CheckableSpaceListItem(
headlineText = space.displayName,
supportingText = space.canonicalAlias?.value,
avatarData = space.getAvatarData(AvatarSize.SpaceMember),
checked = state.selectedIds.contains(space.roomId),
onCheckedChange = { _ ->
state.eventSink(
ManageAuthorizedSpacesEvent.ToggleSpace(space.roomId)
)
}
)
}
if (state.unknownSpaceIds.isNotEmpty()) {
item {
ListSectionHeader(
title = stringResource(R.string.screen_manage_authorized_spaces_unknown_spaces_section_title),
hasDivider = true,
)
}
items(items = state.unknownSpaceIds) {
CheckableSpaceListItem(
headlineText = stringResource(R.string.screen_manage_authorized_spaces_unknown_space),
supportingText = it.value,
avatarData = null,
checked = state.selectedIds.contains(it),
onCheckedChange = { _ ->
state.eventSink(
ManageAuthorizedSpacesEvent.ToggleSpace(it)
)
}
)
}
}
}
}
}
private fun LazyListScope.headerItem() {
item(key = "header", contentType = "header") {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(
vertical = 16.dp,
horizontal = 24.dp
),
title = stringResource(R.string.screen_manage_authorized_spaces_header),
subTitle = null,
iconStyle = BigIcon.Style.Default(
vectorIcon = CompoundIcons.SpaceSolid(),
)
)
}
}
@Composable
private fun CheckableSpaceListItem(
headlineText: String,
supportingText: String?,
avatarData: AvatarData?,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
ListItem(
headlineContent = {
Text(text = headlineText)
},
supportingContent = supportingText?.let {
@Composable {
Text(text = supportingText)
}
},
leadingContent = avatarData?.let {
ListItemContent.Custom {
Avatar(
avatarData = avatarData,
avatarType = AvatarType.Space(),
)
}
},
trailingContent = ListItemContent.Checkbox(
checked = checked,
enabled = enabled,
),
enabled = enabled,
onClick = { onCheckedChange(!checked) },
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ManageAuthorizedSpacesTopBar(
isDoneButtonEnabled: Boolean,
onBackClick: () -> Unit,
onDoneClick: () -> Unit,
modifier: Modifier = Modifier,
) {
TopAppBar(
modifier = modifier,
titleStr = stringResource(R.string.screen_manage_authorized_spaces_title),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
TextButton(
enabled = isDoneButtonEnabled,
text = stringResource(CommonStrings.action_done),
onClick = onDoneClick,
)
}
)
}
@PreviewsDayNight
@Composable
internal fun ManageAuthorizedSpacesViewPreview(
@PreviewParameter(ManageAuthorizedSpacesStateProvider::class) state: ManageAuthorizedSpacesState
) = ElementPreview {
ManageAuthorizedSpacesView(state = state)
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.di.RoomScope
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.persistentSetOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class SpaceSelectionState(
val selectableSpaces: ImmutableSet<SpaceRoom>,
val unknownSpaceIds: ImmutableList<RoomId>,
val selectedSpaceIds: ImmutableList<RoomId>,
val completion: Completion,
) {
enum class Completion {
Initial,
Completed,
Cancelled,
}
companion object {
val INITIAL = SpaceSelectionState(
selectableSpaces = persistentSetOf(),
unknownSpaceIds = persistentListOf(),
selectedSpaceIds = persistentListOf(),
completion = Completion.Initial,
)
}
}
@Inject
@SingleIn(RoomScope::class)
class SpaceSelectionStateHolder {
private val _state = MutableStateFlow(SpaceSelectionState.INITIAL)
val state: StateFlow<SpaceSelectionState> = _state.asStateFlow()
fun update(transform: (SpaceSelectionState) -> SpaceSelectionState) {
_state.update(transform)
}
fun updateSelectedSpaceIds(selectedSpaceIds: ImmutableList<RoomId>) {
update { it.copy(selectedSpaceIds = selectedSpaceIds) }
}
fun setCompletion(completion: SpaceSelectionState.Completion) {
update { it.copy(completion = completion) }
}
}

View file

@ -10,10 +10,17 @@ package io.element.android.features.securityandprivacy.impl.root
sealed interface SecurityAndPrivacyEvent { sealed interface SecurityAndPrivacyEvent {
data object EditRoomAddress : SecurityAndPrivacyEvent data object EditRoomAddress : SecurityAndPrivacyEvent
data object ManageAuthorizedSpaces : SecurityAndPrivacyEvent
data object Save : SecurityAndPrivacyEvent data object Save : SecurityAndPrivacyEvent
data object Exit : SecurityAndPrivacyEvent data object Exit : SecurityAndPrivacyEvent
data object DismissExitConfirmation : SecurityAndPrivacyEvent data object DismissExitConfirmation : SecurityAndPrivacyEvent
data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvent data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvent
// Special case for "Space Members"
data object SelectSpaceMemberAccess : SecurityAndPrivacyEvent
// Special case for "Ask to join with Space Members"
data object SelectAskToJoinWithSpaceMembersAccess : SecurityAndPrivacyEvent
data object ToggleEncryptionState : SecurityAndPrivacyEvent data object ToggleEncryptionState : SecurityAndPrivacyEvent
data object CancelEnableEncryption : SecurityAndPrivacyEvent data object CancelEnableEncryption : SecurityAndPrivacyEvent
data object ConfirmEnableEncryption : 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.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@ -25,6 +26,8 @@ import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPerm
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionState
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionStateHolder
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -37,25 +40,35 @@ 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.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomInfo 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.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.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState 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.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.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@AssistedInject @AssistedInject
class SecurityAndPrivacyPresenter( class SecurityAndPrivacyPresenter(
@Assisted private val navigator: SecurityAndPrivacyNavigator, @Assisted private val navigator: SecurityAndPrivacyNavigator,
private val spaceSelectionStateHolder: SpaceSelectionStateHolder,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val room: JoinedRoom, private val room: JoinedRoom,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
) : Presenter<SecurityAndPrivacyState> { ) : Presenter<SecurityAndPrivacyState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(navigator: SecurityAndPrivacyNavigator): SecurityAndPrivacyPresenter fun create(
navigator: SecurityAndPrivacyNavigator,
): SecurityAndPrivacyPresenter
} }
@Composable @Composable
@ -65,6 +78,10 @@ class SecurityAndPrivacyPresenter(
val isKnockEnabled by remember { val isKnockEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false) }.collectAsState(false)
val isSpaceSettingsEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.SpaceSettings)
}.collectAsState(false)
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) } val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val homeserverName = remember { matrixClient.userIdServerName() } val homeserverName = remember { matrixClient.userIdServerName() }
val roomInfo by room.roomInfoFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState()
@ -86,7 +103,7 @@ class SecurityAndPrivacyPresenter(
} }
} }
var editedRoomAccess by remember(savedSettings.roomAccess) { val editedRoomAccess = remember(savedSettings.roomAccess) {
mutableStateOf(savedSettings.roomAccess) mutableStateOf(savedSettings.roomAccess)
} }
var editedHistoryVisibility by remember(savedSettings.historyVisibility) { var editedHistoryVisibility by remember(savedSettings.historyVisibility) {
@ -99,13 +116,44 @@ class SecurityAndPrivacyPresenter(
mutableStateOf(savedIsVisibleInRoomDirectory.value) mutableStateOf(savedIsVisibleInRoomDirectory.value)
} }
val editedSettings = SecurityAndPrivacySettings( val editedSettings = SecurityAndPrivacySettings(
roomAccess = editedRoomAccess, roomAccess = editedRoomAccess.value,
isEncrypted = editedIsEncrypted, isEncrypted = editedIsEncrypted,
isVisibleInRoomDirectory = editedVisibleInRoomDirectory, isVisibleInRoomDirectory = editedVisibleInRoomDirectory,
historyVisibility = editedHistoryVisibility, historyVisibility = editedHistoryVisibility,
address = savedSettings.address, address = savedSettings.address,
) )
val selectableJoinedSpaces by produceState(initialValue = persistentSetOf(), key1 = savedSettings.roomAccess.spaceIds()) {
val joinedParentSpaces = matrixClient
.spaceService
.joinedParents(room.roomId)
.getOrDefault(emptyList())
val nonParentJoinedSpaces = savedSettings.roomAccess
.spaceIds()
.mapNotNull { spaceId -> matrixClient.spaceService.getSpaceRoom(spaceId) }
value = (joinedParentSpaces + nonParentJoinedSpaces).toImmutableSet()
}
val spaceSelectionMode by remember {
derivedStateOf {
getSpaceSelectionMode(selectableJoinedSpaces, savedSettings.roomAccess)
}
}
LaunchedEffect(selectableJoinedSpaces, savedSettings.roomAccess) {
val unknownSpaceIds = savedSettings.roomAccess.spaceIds().filter { spaceId ->
selectableJoinedSpaces.none { it.roomId == spaceId }
}.toImmutableList()
spaceSelectionStateHolder.update { state ->
state.copy(
selectableSpaces = selectableJoinedSpaces,
unknownSpaceIds = unknownSpaceIds,
)
}
}
var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) }
val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms -> val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms ->
perms.securityAndPrivacyPermissions() perms.securityAndPrivacyPermissions()
@ -122,7 +170,7 @@ class SecurityAndPrivacyPresenter(
) )
} }
is SecurityAndPrivacyEvent.ChangeRoomAccess -> { is SecurityAndPrivacyEvent.ChangeRoomAccess -> {
editedRoomAccess = event.roomAccess editedRoomAccess.value = event.roomAccess
} }
is SecurityAndPrivacyEvent.ToggleEncryptionState -> { is SecurityAndPrivacyEvent.ToggleEncryptionState -> {
if (editedIsEncrypted) { if (editedIsEncrypted) {
@ -161,6 +209,27 @@ class SecurityAndPrivacyPresenter(
SecurityAndPrivacyEvent.DismissExitConfirmation -> { SecurityAndPrivacyEvent.DismissExitConfirmation -> {
saveAction.value = AsyncAction.Uninitialized saveAction.value = AsyncAction.Uninitialized
} }
SecurityAndPrivacyEvent.ManageAuthorizedSpaces -> coroutineScope.launch {
handleMultipleSelection(
savedAccess = savedSettings.roomAccess,
editedRoomAccess = editedRoomAccess,
forKnockRestricted = editedRoomAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember
)
}
SecurityAndPrivacyEvent.SelectSpaceMemberAccess -> coroutineScope.launch {
handleSpaceMemberAccessSelection(
spaceSelectionMode = spaceSelectionMode,
savedAccess = savedSettings.roomAccess,
editedAccess = editedRoomAccess,
)
}
SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess -> coroutineScope.launch {
handleAskToJoinWithSpaceMembersAccessSelection(
spaceSelectionMode = spaceSelectionMode,
savedAccess = savedSettings.roomAccess,
editedAccess = editedRoomAccess,
)
}
} }
} }
@ -179,13 +248,16 @@ class SecurityAndPrivacyPresenter(
saveAction = saveAction.value, saveAction = saveAction.value,
permissions = permissions, permissions = permissions,
isSpace = roomInfo.isSpace, isSpace = roomInfo.isSpace,
isSpaceSettingsEnabled = isSpaceSettingsEnabled,
selectableJoinedSpaces = selectableJoinedSpaces,
spaceSelectionMode = spaceSelectionMode,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
// Revert changes that the user is not allowed to make anymore // Revert changes that the user is not allowed to make anymore
LaunchedEffect(permissions, state.editedSettings.roomAccess) { LaunchedEffect(permissions, state.editedSettings.roomAccess) {
if (!state.showRoomAccessSection) { if (!state.showRoomAccessSection) {
editedRoomAccess = savedSettings.roomAccess editedRoomAccess.value = savedSettings.roomAccess
} }
if (!state.showEncryptionSection) { if (!state.showEncryptionSection) {
editedIsEncrypted = savedSettings.isEncrypted editedIsEncrypted = savedSettings.isEncrypted
@ -202,6 +274,110 @@ class SecurityAndPrivacyPresenter(
return state return state
} }
private suspend fun handleSpaceMemberAccessSelection(
spaceSelectionMode: SpaceSelectionMode,
savedAccess: SecurityAndPrivacyRoomAccess,
editedAccess: MutableState<SecurityAndPrivacyRoomAccess>,
) {
if (editedAccess.value is SecurityAndPrivacyRoomAccess.SpaceMember) {
return
}
when (spaceSelectionMode) {
is SpaceSelectionMode.None -> Unit
is SpaceSelectionMode.Multiple -> handleMultipleSelection(
savedAccess = savedAccess,
editedRoomAccess = editedAccess,
forKnockRestricted = false,
)
is SpaceSelectionMode.Single -> {
val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(
spaceIds = persistentListOf(spaceSelectionMode.spaceId)
)
editedAccess.value = newRoomAccess
}
}
}
private suspend fun handleAskToJoinWithSpaceMembersAccessSelection(
spaceSelectionMode: SpaceSelectionMode,
savedAccess: SecurityAndPrivacyRoomAccess,
editedAccess: MutableState<SecurityAndPrivacyRoomAccess>,
) {
if (editedAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember) {
return
}
when (spaceSelectionMode) {
is SpaceSelectionMode.None -> Unit
is SpaceSelectionMode.Multiple -> handleMultipleSelection(
savedAccess = savedAccess,
editedRoomAccess = editedAccess,
forKnockRestricted = true,
)
is SpaceSelectionMode.Single -> {
val newRoomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(
spaceIds = persistentListOf(spaceSelectionMode.spaceId)
)
editedAccess.value = newRoomAccess
}
}
}
private suspend fun handleMultipleSelection(
savedAccess: SecurityAndPrivacyRoomAccess,
editedRoomAccess: MutableState<SecurityAndPrivacyRoomAccess>,
forKnockRestricted: Boolean
) {
val initialSelection = when (val currentRoomAccess = editedRoomAccess.value) {
is SecurityAndPrivacyRoomAccess.SpaceMember -> currentRoomAccess.spaceIds
is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember -> currentRoomAccess.spaceIds
else -> savedAccess.spaceIds()
}
spaceSelectionStateHolder.update { state ->
state.copy(selectedSpaceIds = initialSelection, completion = SpaceSelectionState.Completion.Initial)
}
navigator.openManageAuthorizedSpaces()
val newState = spaceSelectionStateHolder.state.first { it.completion != SpaceSelectionState.Completion.Initial }
when (newState.completion) {
SpaceSelectionState.Completion.Initial -> Unit
SpaceSelectionState.Completion.Cancelled -> {
navigator.closeManageAuthorizedSpaces()
}
SpaceSelectionState.Completion.Completed -> {
val selectedIds = newState.selectedSpaceIds
editedRoomAccess.value = if (forKnockRestricted) {
SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(spaceIds = selectedIds)
} else {
SecurityAndPrivacyRoomAccess.SpaceMember(spaceIds = selectedIds)
}
navigator.closeManageAuthorizedSpaces()
}
}
}
private fun getSpaceSelectionMode(
selectableJoinedSpaces: Set<SpaceRoom>,
savedAccess: SecurityAndPrivacyRoomAccess,
): SpaceSelectionMode {
val selectableSpacesCount = (selectableJoinedSpaces.map { it.roomId } + savedAccess.spaceIds()).toSet().size
return when {
selectableSpacesCount == 0 -> SpaceSelectionMode.None
selectableSpacesCount > 1 -> SpaceSelectionMode.Multiple
else -> {
val joinedSpace = selectableJoinedSpaces.firstOrNull()
if (joinedSpace != null) {
SpaceSelectionMode.Single(joinedSpace.roomId, joinedSpace)
} else {
val spaceId = savedAccess.spaceIds().firstOrNull()
if (spaceId == null) {
SpaceSelectionMode.None
} else {
SpaceSelectionMode.Single(spaceId, null)
}
}
}
}
}
private fun CoroutineScope.isRoomVisibleInRoomDirectory(isRoomVisible: MutableState<AsyncData<Boolean>>) = launch { private fun CoroutineScope.isRoomVisibleInRoomDirectory(isRoomVisible: MutableState<AsyncData<Boolean>>) = launch {
isRoomVisible.runUpdatingState { isRoomVisible.runUpdatingState {
room.getRoomVisibility().map { it == RoomVisibility.Public } room.getRoomVisibility().map { it == RoomVisibility.Public }
@ -242,6 +418,7 @@ class SecurityAndPrivacyPresenter(
// the room should be automatically made invisible (private) in the room directory. // the room should be automatically made invisible (private) in the room directory.
val editedIsVisibleInRoomDirectory = when (editedSettings.roomAccess) { val editedIsVisibleInRoomDirectory = when (editedSettings.roomAccess) {
SecurityAndPrivacyRoomAccess.AskToJoin, SecurityAndPrivacyRoomAccess.AskToJoin,
is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember,
SecurityAndPrivacyRoomAccess.Anyone -> editedSettings.isVisibleInRoomDirectory.dataOrNull() SecurityAndPrivacyRoomAccess.Anyone -> editedSettings.isVisibleInRoomDirectory.dataOrNull()
else -> false else -> false
} }
@ -279,8 +456,19 @@ class SecurityAndPrivacyPresenter(
private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess { private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess {
return when (this) { return when (this) {
JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone
JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin JoinRule.Knock -> SecurityAndPrivacyRoomAccess.AskToJoin
is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(
spaceIds = this.rules
.filterIsInstance<AllowRule.RoomMembership>()
.map { it.roomId }
.toImmutableList()
)
is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember(
spaceIds = this.rules
.filterIsInstance<AllowRule.RoomMembership>()
.map { it.roomId }
.toImmutableList()
)
JoinRule.Invite -> SecurityAndPrivacyRoomAccess.InviteOnly JoinRule.Invite -> SecurityAndPrivacyRoomAccess.InviteOnly
// All other cases are not supported so we default to InviteOnly // All other cases are not supported so we default to InviteOnly
is JoinRule.Custom, is JoinRule.Custom,
@ -294,8 +482,12 @@ private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? {
SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public
SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock
SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private
// SpaceMember can't be selected in the ui is SecurityAndPrivacyRoomAccess.SpaceMember -> JoinRule.Restricted(
SecurityAndPrivacyRoomAccess.SpaceMember -> null rules = this.spaceIds.map { AllowRule.RoomMembership(it) }.toImmutableList()
)
is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember -> JoinRule.KnockRestricted(
rules = this.spaceIds.map { AllowRule.RoomMembership(it) }.toImmutableList()
)
} }
} }

View file

@ -8,9 +8,17 @@
package io.element.android.features.securityandprivacy.impl.root package io.element.android.features.securityandprivacy.impl.root
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.R
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData 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 import kotlinx.collections.immutable.toImmutableList
data class SecurityAndPrivacyState( data class SecurityAndPrivacyState(
@ -20,12 +28,42 @@ data class SecurityAndPrivacyState(
val editedSettings: SecurityAndPrivacySettings, val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String, val homeserverName: String,
val showEnableEncryptionConfirmation: Boolean, val showEnableEncryptionConfirmation: Boolean,
val isKnockEnabled: Boolean, private val isKnockEnabled: Boolean,
private val isSpaceSettingsEnabled: Boolean,
val saveAction: AsyncAction<Unit>, val saveAction: AsyncAction<Unit>,
val isSpace: Boolean, val isSpace: Boolean,
private val permissions: SecurityAndPrivacyPermissions, private val permissions: SecurityAndPrivacyPermissions,
private val selectableJoinedSpaces: ImmutableSet<SpaceRoom>,
private val spaceSelectionMode: SpaceSelectionMode,
val eventSink: (SecurityAndPrivacyEvent) -> Unit val eventSink: (SecurityAndPrivacyEvent) -> Unit
) { ) {
val isSpaceMemberSelectable = isSpaceSettingsEnabled && spaceSelectionMode != SpaceSelectionMode.None
// Show SpaceMember option in two cases:
// - SpaceMember is the current saved value
// - SpaceMember option is selectable (ie. the FF is enabled and there is at least one space to select)
val showSpaceMemberOption = savedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember || isSpaceMemberSelectable
val showManageSpaceFooter = spaceSelectionMode is SpaceSelectionMode.Multiple &&
(editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember ||
editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember)
val isAskToJoinSelectable = isKnockEnabled
val isAskToJoinWithSpaceMembersSelectable = isAskToJoinSelectable && isSpaceMemberSelectable
// Show Ask to join option only when:
// - AskToJoin is the current saved value (legacy), OR
// - Knock FF enabled BUT (SpaceSettings FF disabled OR no spaces available)
val showAskToJoinOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.AskToJoin ||
isAskToJoinSelectable && !isAskToJoinWithSpaceMembersSelectable
// Show AskToJoinWithSpaceMember option when:
// - It's the current saved value, OR
// - Both FFs enabled AND spaces available
val showAskToJoinWithSpaceMemberOption = savedSettings.roomAccess is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember ||
isAskToJoinWithSpaceMembersSelectable
val canBeSaved = savedSettings != editedSettings val canBeSaved = savedSettings != editedSettings
// Logic is in https://github.com/element-hq/element-meta/issues/3029 // Logic is in https://github.com/element-hq/element-meta/issues/3029
@ -48,6 +86,40 @@ data class SecurityAndPrivacyState(
val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility && !isSpace val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility && !isSpace
val showEncryptionSection = permissions.canChangeEncryption && !isSpace val showEncryptionSection = permissions.canChangeEncryption && !isSpace
@Composable
fun spaceMemberDescription(): String {
return if (isSpaceMemberSelectable) {
when (spaceSelectionMode) {
is SpaceSelectionMode.Single -> {
val spaceName = spaceSelectionMode.spaceRoom?.displayName ?: spaceSelectionMode.spaceId.value
stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_single_parent_description, spaceName)
}
is SpaceSelectionMode.None,
is SpaceSelectionMode.Multiple -> stringResource(
R.string.screen_security_and_privacy_room_access_space_members_option_multiple_parents_description
)
}
} else {
stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description)
}
}
@Composable
fun askToJoinWithSpaceMembersDescription(): String {
return if (isAskToJoinWithSpaceMembersSelectable) {
when (spaceSelectionMode) {
is SpaceSelectionMode.Single -> {
val spaceName = spaceSelectionMode.spaceRoom?.displayName ?: spaceSelectionMode.spaceId.value
stringResource(R.string.screen_security_and_privacy_ask_to_join_single_space_members_option_description, spaceName)
}
is SpaceSelectionMode.None,
is SpaceSelectionMode.Multiple -> stringResource(R.string.screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description)
}
} else {
stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)
}
}
} }
data class SecurityAndPrivacySettings( data class SecurityAndPrivacySettings(
@ -76,16 +148,31 @@ enum class SecurityAndPrivacyHistoryVisibility {
} }
} }
enum class SecurityAndPrivacyRoomAccess { sealed interface SpaceSelectionMode {
InviteOnly, data object None : SpaceSelectionMode
AskToJoin, data class Single(val spaceId: RoomId, val spaceRoom: SpaceRoom?) : SpaceSelectionMode
Anyone, data object Multiple : SpaceSelectionMode
SpaceMember; }
sealed interface SecurityAndPrivacyRoomAccess {
data object InviteOnly : SecurityAndPrivacyRoomAccess
data object AskToJoin : SecurityAndPrivacyRoomAccess
data object Anyone : SecurityAndPrivacyRoomAccess
data class SpaceMember(val spaceIds: ImmutableList<RoomId>) : SecurityAndPrivacyRoomAccess
data class AskToJoinWithSpaceMember(val spaceIds: ImmutableList<RoomId>) : SecurityAndPrivacyRoomAccess
fun canConfigureRoomVisibility(): Boolean { fun canConfigureRoomVisibility(): Boolean {
return when (this) { return when (this) {
InviteOnly, SpaceMember -> false InviteOnly, is SpaceMember -> false
AskToJoin, Anyone -> true AskToJoin, Anyone, is AskToJoinWithSpaceMember -> true
}
}
fun spaceIds(): ImmutableList<RoomId> {
return when (this) {
is SpaceMember -> spaceIds
is AskToJoinWithSpaceMember -> spaceIds
else -> persistentListOf()
} }
} }
} }

View file

@ -12,6 +12,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData 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.toImmutableSet
open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> { open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> {
override val values: Sequence<SecurityAndPrivacyState> override val values: Sequence<SecurityAndPrivacyState>
@ -61,11 +64,27 @@ private fun commonSecurityAndPrivacyStates(isSpace: Boolean): Sequence<SecurityA
), ),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
savedSettings = aSecurityAndPrivacySettings( savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(persistentListOf())
), ),
spaceSelectionMode = SpaceSelectionMode.Multiple,
isSpace = isSpace, isSpace = isSpace,
isKnockEnabled = false, isKnockEnabled = false,
), ),
aSecurityAndPrivacyState(
spaceSelectionMode = SpaceSelectionMode.Multiple,
savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(persistentListOf()),
),
isSpace = isSpace,
),
aSecurityAndPrivacyState(
spaceSelectionMode = SpaceSelectionMode.Multiple,
savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(persistentListOf())
),
isSpace = isSpace,
isKnockEnabled = true,
),
aSecurityAndPrivacyState( aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings( editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone, roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
@ -117,6 +136,9 @@ fun aSecurityAndPrivacyState(
), ),
isKnockEnabled: Boolean = true, isKnockEnabled: Boolean = true,
isSpace: Boolean = false, isSpace: Boolean = false,
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
isSpaceSettingsEnabled: Boolean = true,
eventSink: (SecurityAndPrivacyEvent) -> Unit = {} eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState( ) = SecurityAndPrivacyState(
editedSettings = editedSettings, editedSettings = editedSettings,
@ -127,5 +149,8 @@ fun aSecurityAndPrivacyState(
isKnockEnabled = isKnockEnabled, isKnockEnabled = isKnockEnabled,
permissions = permissions, permissions = permissions,
isSpace = isSpace, isSpace = isSpace,
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
spaceSelectionMode = spaceSelectionMode,
isSpaceSettingsEnabled = isSpaceSettingsEnabled,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -90,11 +90,8 @@ fun SecurityAndPrivacyView(
) { ) {
if (state.showRoomAccessSection) { if (state.showRoomAccessSection) {
RoomAccessSection( RoomAccessSection(
state = state,
modifier = Modifier.padding(top = 24.dp), modifier = Modifier.padding(top = 24.dp),
edited = state.editedSettings.roomAccess,
saved = state.savedSettings.roomAccess,
isKnockEnabled = state.isKnockEnabled,
onSelectOption = { state.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(it)) },
) )
} }
if (state.showRoomVisibilitySections) { if (state.showRoomVisibilitySections) {
@ -208,12 +205,27 @@ private fun SecurityAndPrivacySection(
@Composable @Composable
private fun RoomAccessSection( private fun RoomAccessSection(
edited: SecurityAndPrivacyRoomAccess, state: SecurityAndPrivacyState,
saved: SecurityAndPrivacyRoomAccess,
isKnockEnabled: Boolean,
onSelectOption: (SecurityAndPrivacyRoomAccess) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val edited = state.editedSettings.roomAccess
fun onSelectOption(option: SecurityAndPrivacyRoomAccess) {
state.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(option))
}
fun onSpaceMemberAccessClick() {
state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
}
fun onAskToJoinWithSpaceMembersClick() {
state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess)
}
fun onManageSpacesClick() {
state.eventSink(SecurityAndPrivacyEvent.ManageAuthorizedSpaces)
}
SecurityAndPrivacySection( SecurityAndPrivacySection(
title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), title = stringResource(R.string.screen_security_and_privacy_room_access_section_header),
modifier = modifier, modifier = modifier,
@ -225,29 +237,36 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
) )
// Show space member option, but disabled as we don't support this option for now. if (state.showSpaceMemberOption) {
if (saved == SecurityAndPrivacyRoomAccess.SpaceMember) {
ListItem( ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) },
supportingContent = { supportingContent = {
Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description)) Text(text = state.spaceMemberDescription())
}, },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.SpaceMember, enabled = false), trailingContent = ListItemContent.RadioButton(selected = state.editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember),
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Space())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Space())),
enabled = false, onClick = ::onSpaceMemberAccessClick,
enabled = state.isSpaceMemberSelectable,
) )
} }
// Show Ask to join option in two cases: if (state.showAskToJoinOption) {
// - the Knock FF is enabled
// - AskToJoin is the current saved value
if (saved == SecurityAndPrivacyRoomAccess.AskToJoin || isKnockEnabled) {
ListItem( ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin), trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) }, onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
enabled = isKnockEnabled, enabled = state.isAskToJoinSelectable,
)
}
if (state.showAskToJoinWithSpaceMemberOption) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) },
supportingContent = { Text(text = state.askToJoinWithSpaceMembersDescription()) },
trailingContent = ListItemContent.RadioButton(selected = edited is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember),
onClick = ::onAskToJoinWithSpaceMembersClick,
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())),
enabled = state.isAskToJoinWithSpaceMembersSelectable,
) )
} }
ListItem( ListItem(
@ -257,6 +276,20 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) }, onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) },
) )
if (state.showManageSpaceFooter) {
val footerText = stringWithLink(
textRes = R.string.screen_security_and_privacy_room_access_footer,
url = "",
linkTextRes = 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)
)
}
} }
} }

View file

@ -2,6 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi."</string> <string name="screen_edit_room_address_room_address_section_footer">"Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi."</string>
<string name="screen_edit_room_address_title">"Muuda aadressi"</string> <string name="screen_edit_room_address_title">"Muuda aadressi"</string>
<string name="screen_manage_authorized_spaces_header">"Kogukonnad, milles on võimalik jututoaga liituda ilma kutseta."</string>
<string name="screen_manage_authorized_spaces_title">"Halda kogukondi"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Tundmatu kogukond)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Muud kogukonnad, mille liige sa ei ole"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Sinu kogukonnad"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Lisa aadress"</string> <string name="screen_security_and_privacy_add_room_address_action">"Lisa aadress"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Liituda saavad kõik volitatud kogukondade liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks."</string> <string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Liituda saavad kõik volitatud kogukondade liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Kõik võivad paluda jututoaga liitumist."</string> <string name="screen_security_and_privacy_ask_to_join_option_description">"Kõik võivad paluda jututoaga liitumist."</string>

View file

@ -2,6 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Vous aurez besoin dune adresse pour le rendre visible dans lannuaire public."</string> <string name="screen_edit_room_address_room_address_section_footer">"Vous aurez besoin dune adresse pour le rendre visible dans lannuaire public."</string>
<string name="screen_edit_room_address_title">"Modifier ladresse"</string> <string name="screen_edit_room_address_title">"Modifier ladresse"</string>
<string name="screen_manage_authorized_spaces_header">"Espaces où les membres peuvent rejoindre le salon sans invitation."</string>
<string name="screen_manage_authorized_spaces_title">"Gérer les espaces"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Espace inconnu)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Autres espaces dont vous nêtes pas membre"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vos espaces"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Ajouter une adresse"</string> <string name="screen_security_and_privacy_add_room_address_action">"Ajouter une adresse"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Toute personne se trouvant dans un espace autorisé peut participer, mais toutes les autres doivent demander laccès."</string> <string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Toute personne se trouvant dans un espace autorisé peut participer, mais toutes les autres doivent demander laccès."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Tout le monde doit demander un accès."</string> <string name="screen_security_and_privacy_ask_to_join_option_description">"Tout le monde doit demander un accès."</string>

View file

@ -2,6 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju."</string> <string name="screen_edit_room_address_room_address_section_footer">"Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju."</string>
<string name="screen_edit_room_address_title">"Uredi adresu"</string> <string name="screen_edit_room_address_title">"Uredi adresu"</string>
<string name="screen_manage_authorized_spaces_header">"Prostori u kojima se članovi mogu pridružiti sobi bez pozivnice."</string>
<string name="screen_manage_authorized_spaces_title">"Upravljaj prostorima"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(nepoznati prostor)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Drugi prostori čiji niste član"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vaši prostori"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Dodaj adresu"</string> <string name="screen_security_and_privacy_add_room_address_action">"Dodaj adresu"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti, ali svi ostali moraju zatražiti pristup."</string> <string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti, ali svi ostali moraju zatražiti pristup."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Svi moraju zatražiti pristup."</string> <string name="screen_security_and_privacy_ask_to_join_option_description">"Svi moraju zatražiti pristup."</string>

View file

@ -2,6 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Você precisará de um endereço para torná-la visível no diretório."</string> <string name="screen_edit_room_address_room_address_section_footer">"Você precisará de um endereço para torná-la visível no diretório."</string>
<string name="screen_edit_room_address_title">"Editar endereço"</string> <string name="screen_edit_room_address_title">"Editar endereço"</string>
<string name="screen_manage_authorized_spaces_header">"Os espaços dos quais os membros podem entrar na sala sem um convite."</string>
<string name="screen_manage_authorized_spaces_title">"Gerenciar espaços"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Espaço desconhecido)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Outros espaços dos quais você não é um membro"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Seus espaços"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Adicionar endereço"</string> <string name="screen_security_and_privacy_add_room_address_action">"Adicionar endereço"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Qualquer um nos espaços autorizados podem entrar, mas todos os outros devem pedir acesso."</string> <string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Qualquer um nos espaços autorizados podem entrar, mas todos os outros devem pedir acesso."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Qualquer um pode pedir acesso, mas um administrador terá que aceitar o pedido."</string> <string name="screen_security_and_privacy_ask_to_join_option_description">"Qualquer um pode pedir acesso, mas um administrador terá que aceitar o pedido."</string>

View file

@ -2,6 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public."</string> <string name="screen_edit_room_address_room_address_section_footer">"Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public."</string>
<string name="screen_edit_room_address_title">"Editați adresa"</string> <string name="screen_edit_room_address_title">"Editați adresa"</string>
<string name="screen_manage_authorized_spaces_header">"Spațile din care membrii se pot alătura camerei fără invitație."</string>
<string name="screen_manage_authorized_spaces_title">"Gestionați spațiile"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Spațiu necunoscut)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Alte spații din care nu faceți parte"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Spațiile dumneavoastră"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Adăugați o adresă"</string> <string name="screen_security_and_privacy_add_room_address_action">"Adăugați o adresă"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Oricine se află în spațiile autorizate se poate alătura, dar toți ceilalți trebuie să solicite accesul."</string> <string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Oricine se află în spațiile autorizate se poate alătura, dar toți ceilalți trebuie să solicite accesul."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Toată lumea trebuie să solicite acces."</string> <string name="screen_security_and_privacy_ask_to_join_option_description">"Toată lumea trebuie să solicite acces."</string>

View file

@ -2,6 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári."</string> <string name="screen_edit_room_address_room_address_section_footer">"Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári."</string>
<string name="screen_edit_room_address_title">"Upraviť adresu"</string> <string name="screen_edit_room_address_title">"Upraviť adresu"</string>
<string name="screen_manage_authorized_spaces_header">"Priestory, kde sa členovia môžu pripojiť k miestnosti bez pozvania."</string>
<string name="screen_manage_authorized_spaces_title">"Spravovať priestory"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Neznámy priestor)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Iné priestory, ktorých nie ste členom"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vaše priestory"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Pridať adresu"</string> <string name="screen_security_and_privacy_add_room_address_action">"Pridať adresu"</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Všetci musia požiadať o prístup."</string> <string name="screen_security_and_privacy_ask_to_join_option_description">"Všetci musia požiadať o prístup."</string>
<string name="screen_security_and_privacy_ask_to_join_option_title">"Požiadať o pripojenie"</string> <string name="screen_security_and_privacy_ask_to_join_option_title">"Požiadať o pripojenie"</string>

View file

@ -2,6 +2,11 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_edit_room_address_room_address_section_footer">"Youll need an address in order to make it visible in the public directory."</string> <string name="screen_edit_room_address_room_address_section_footer">"Youll need an address in order to make it visible in the public directory."</string>
<string name="screen_edit_room_address_title">"Edit address"</string> <string name="screen_edit_room_address_title">"Edit address"</string>
<string name="screen_manage_authorized_spaces_header">"Spaces where members can join the room without an invitation."</string>
<string name="screen_manage_authorized_spaces_title">"Manage spaces"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Unknown space)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Other spaces youre not a member of"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Your spaces"</string>
<string name="screen_security_and_privacy_add_room_address_action">"Add address"</string> <string name="screen_security_and_privacy_add_room_address_action">"Add address"</string>
<string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Anyone in authorised spaces can join, but everyone else must request access."</string> <string name="screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description">"Anyone in authorised spaces can join, but everyone else must request access."</string>
<string name="screen_security_and_privacy_ask_to_join_option_description">"Everyone must request access."</string> <string name="screen_security_and_privacy_ask_to_join_option_description">"Everyone must request access."</string>

View file

@ -14,6 +14,8 @@ class FakeSecurityAndPrivacyNavigator(
private val onDoneLambda: () -> Unit = { lambdaError() }, private val onDoneLambda: () -> Unit = { lambdaError() },
private val openEditRoomAddressLambda: () -> Unit = { lambdaError() }, private val openEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() }, private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val openManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() },
private val closeManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() },
) : SecurityAndPrivacyNavigator { ) : SecurityAndPrivacyNavigator {
override fun onDone() { override fun onDone() {
onDoneLambda() onDoneLambda()
@ -26,4 +28,12 @@ class FakeSecurityAndPrivacyNavigator(
override fun closeEditRoomAddress() { override fun closeEditRoomAddress() {
closeEditRoomAddressLambda() closeEditRoomAddressLambda()
} }
override fun openManageAuthorizedSpaces() {
openManageAuthorizedSpacesLambda()
}
override fun closeManageAuthorizedSpaces() {
closeManageAuthorizedSpacesLambda()
}
} }

View file

@ -0,0 +1,107 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.securityandprivacy.impl
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bumble.appyx.core.modality.AncestryInfo
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint
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.test.room.FakeBaseRoom
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SecurityAndPrivacyFlowNodeTest {
@Test
fun `initial backstack contains SecurityAndPrivacy`() = runTest {
val flowNode = createFlowNode()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy)
}
@Test
fun `openEditRoomAddress navigates to EditRoomAddress`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openEditRoomAddress()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress)
}
@Test
fun `closeEditRoomAddress pops backstack`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openEditRoomAddress()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress)
flowNode.navigator.closeEditRoomAddress()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy)
}
@Test
fun `openManageAuthorizedSpaces navigates to ManageAuthorizedSpaces`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces)
}
@Test
fun `closeManageAuthorizedSpaces pops backstack`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces()
assertThat(flowNode.currentNavTarget())
.isInstanceOf(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces::class.java)
flowNode.navigator.closeManageAuthorizedSpaces()
assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy)
}
@Test
fun `onDone invokes callback`() = runTest {
var onDoneCalled = false
val callback = object : SecurityAndPrivacyEntryPoint.Callback {
override fun onDone() {
onDoneCalled = true
}
}
val flowNode = createFlowNode(callback = callback)
flowNode.navigator.onDone()
assertThat(onDoneCalled).isTrue()
}
private fun createFlowNode(
callback: SecurityAndPrivacyEntryPoint.Callback = object : SecurityAndPrivacyEntryPoint.Callback {
override fun onDone() {}
},
): SecurityAndPrivacyFlowNode {
val buildContext = BuildContext(
ancestryInfo = AncestryInfo.Root,
savedStateMap = null,
customisations = NodeCustomisationDirectoryImpl()
)
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
initialRoomInfo = aRoomInfo(
joinRule = JoinRule.Invite,
historyVisibility = RoomHistoryVisibility.Shared
)
)
)
return SecurityAndPrivacyFlowNode(
buildContext = buildContext,
plugins = listOf(callback),
room = room,
)
}
private fun SecurityAndPrivacyFlowNode.currentNavTarget() = backstack.activeElement
}

View file

@ -1,421 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.securityandprivacy.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyEvent
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyHistoryVisibility
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyPresenter
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyRoomAccess
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
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.api.room.StateEventType
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.room.powerlevels.RoomPermissions
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.test.A_ROOM_ALIAS
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.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions
import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.test
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SecurityAndPrivacyPresenterTest {
@Test
fun `present - initial states`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
assertThat(canBeSaved).isFalse()
assertThat(showEnableEncryptionConfirmation).isFalse()
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(showRoomAccessSection).isFalse()
assertThat(showRoomVisibilitySections).isFalse()
assertThat(showHistoryVisibilitySection).isFalse()
assertThat(showEncryptionSection).isFalse()
assertThat(isKnockEnabled).isFalse()
}
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
assertThat(canBeSaved).isFalse()
assertThat(showEnableEncryptionConfirmation).isFalse()
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
assertThat(showRoomAccessSection).isTrue()
assertThat(showRoomVisibilitySections).isFalse()
assertThat(showHistoryVisibilitySection).isTrue()
assertThat(showEncryptionSection).isTrue()
assertThat(isKnockEnabled).isFalse()
}
}
}
@Test
fun `present - room info change updates saved and edited settings`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
canonicalAlias = A_ROOM_ALIAS,
)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings).isEqualTo(savedSettings)
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone)
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable)
assertThat(editedSettings.address).isEqualTo(A_ROOM_ALIAS.value)
assertThat(canBeSaved).isFalse()
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - change room access`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
assertThat(showRoomVisibilitySections).isFalse()
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone)
assertThat(showRoomVisibilitySections).isTrue()
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly))
}
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
assertThat(showRoomVisibilitySections).isFalse()
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - change history visibility`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Shared)
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Invited))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Invited)
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Shared))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Shared)
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - enable encryption`() = runTest {
val presenter = createSecurityAndPrivacyPresenter()
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isFalse()
eventSink(SecurityAndPrivacyEvent.ToggleEncryptionState)
}
with(awaitItem()) {
assertThat(showEnableEncryptionConfirmation).isTrue()
eventSink(SecurityAndPrivacyEvent.CancelEnableEncryption)
}
with(awaitItem()) {
assertThat(showEnableEncryptionConfirmation).isFalse()
eventSink(SecurityAndPrivacyEvent.ToggleEncryptionState)
}
with(awaitItem()) {
assertThat(showEnableEncryptionConfirmation).isTrue()
eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isTrue()
assertThat(showEnableEncryptionConfirmation).isFalse()
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvent.ToggleEncryptionState)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isFalse()
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - room visibility loading and change`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared)
)
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Loading<Boolean>())
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false))
eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true))
assertThat(canBeSaved).isTrue()
eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false))
assertThat(canBeSaved).isFalse()
}
}
}
@Test
fun `present - edit room address`() = runTest {
val openEditRoomAddressLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda = openEditRoomAddressLambda)
val presenter = createSecurityAndPrivacyPresenter(navigator = navigator)
presenter.test {
skipItems(1)
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvent.EditRoomAddress)
}
assert(openEditRoomAddressLambda).isCalledOnce()
}
}
@Test
fun `present - save success`() = runTest {
val enableEncryptionLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val updateJoinRuleLambda = lambdaRecorder<JoinRule, Result<Unit>> { Result.success(Unit) }
val updateRoomVisibilityLambda = lambdaRecorder<RoomVisibility, Result<Unit>> { Result.success(Unit) }
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared)
),
enableEncryptionResult = enableEncryptionLambda,
updateJoinRuleResult = updateJoinRuleLambda,
updateRoomVisibilityResult = updateRoomVisibilityLambda,
updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda,
)
val onDoneLambda = lambdaRecorder<Unit> { }
val navigator = FakeSecurityAndPrivacyNavigator(
onDoneLambda = onDoneLambda,
)
val presenter = createSecurityAndPrivacyPresenter(
room = room,
navigator = navigator,
)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.WorldReadable))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable)
eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isTrue()
eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true))
eventSink(SecurityAndPrivacyEvent.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
room.givenRoomInfo(
aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
isEncrypted = true,
)
)
// Saved settings are updated 2 times to match the edited settings
skipItems(2)
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit))
assertThat(savedSettings).isEqualTo(editedSettings)
assertThat(canBeSaved).isFalse()
}
assert(enableEncryptionLambda).isCalledOnce()
assert(updateJoinRuleLambda).isCalledOnce()
assert(updateRoomVisibilityLambda).isCalledOnce()
assert(updateRoomHistoryVisibilityLambda).isCalledOnce()
onDoneLambda.assertions().isCalledOnce()
}
}
@Test
fun `present - save failure`() = runTest {
val enableEncryptionLambda = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val updateJoinRuleLambda = lambdaRecorder<JoinRule, Result<Unit>> { Result.success(Unit) }
val updateRoomVisibilityLambda = lambdaRecorder<RoomVisibility, Result<Unit>> {
Result.failure(Exception("Failed to update room visibility"))
}
val updateRoomHistoryVisibilityLambda = lambdaRecorder<RoomHistoryVisibility, Result<Unit>> { Result.success(Unit) }
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
),
enableEncryptionResult = enableEncryptionLambda,
updateJoinRuleResult = updateJoinRuleLambda,
updateRoomVisibilityResult = updateRoomVisibilityLambda,
updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda,
)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly)
eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone))
}
with(awaitItem()) {
eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.WorldReadable))
}
with(awaitItem()) {
assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable)
eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption)
}
skipItems(1)
with(awaitItem()) {
assertThat(editedSettings.isEncrypted).isTrue()
eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility)
}
with(awaitItem()) {
assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true))
eventSink(SecurityAndPrivacyEvent.Save)
}
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Loading)
}
room.givenRoomInfo(
aRoomInfo(
joinRule = JoinRule.Public,
historyVisibility = RoomHistoryVisibility.WorldReadable,
)
)
// Saved settings are updated 2 times to match the edited settings
skipItems(2)
val state = awaitItem()
with(state) {
assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java)
assertThat(savedSettings.isVisibleInRoomDirectory).isNotEqualTo(editedSettings.isVisibleInRoomDirectory)
assertThat(canBeSaved).isTrue()
}
assert(enableEncryptionLambda).isCalledOnce()
assert(updateJoinRuleLambda).isCalledOnce()
assert(updateRoomVisibilityLambda).isCalledOnce()
assert(updateRoomHistoryVisibilityLambda).isCalledOnce()
// Clear error
state.eventSink(SecurityAndPrivacyEvent.DismissSaveError)
with(awaitItem()) {
assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized)
}
}
}
@Test
fun `present - isKnockEnabled is true if the Knock feature flag is enabled`() = runTest {
val presenter = createSecurityAndPrivacyPresenter(
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(
FeatureFlags.Knock.key to true,
)
)
)
presenter.test {
assertThat(awaitItem().isKnockEnabled).isFalse()
assertThat(awaitItem().isKnockEnabled).isTrue()
}
}
private fun roomPermissions(
canChangeRoomAccess: Boolean = true,
canChangeHistoryVisibility: Boolean = true,
canChangeEncryption: Boolean = true,
canChangeRoomVisibility: Boolean = true,
): RoomPermissions {
return FakeRoomPermissions(
canSendState = { eventType ->
when (eventType) {
StateEventType.RoomJoinRules -> canChangeRoomAccess
StateEventType.RoomHistoryVisibility -> canChangeHistoryVisibility
StateEventType.RoomEncryption -> canChangeEncryption
StateEventType.RoomCanonicalAlias -> canChangeRoomVisibility
else -> lambdaError()
}
}
)
}
private fun createSecurityAndPrivacyPresenter(
serverName: String = "matrix.org",
room: FakeJoinedRoom = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
getRoomVisibilityResult = { Result.success(RoomVisibility.Private) },
initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private)
),
),
navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter(
room = room,
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { serverName },
),
navigator = navigator,
featureFlagService = featureFlagService,
)
}
}

View file

@ -0,0 +1,126 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.securityandprivacy.impl.manageauthorizedspaces
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ManageAuthorizedSpacesPresenterTest {
@Test
fun `present - initial state reflects shared state`() = runTest {
val sharedStateHolder = SpaceSelectionStateHolder()
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test {
with(awaitItem()) {
assertThat(selectedIds).isEmpty()
assertThat(isDoneButtonEnabled).isFalse()
}
}
}
@Test
fun `present - state reflects shared state with pre-selected spaces`() = runTest {
val sharedStateHolder = SpaceSelectionStateHolder()
val roomId = A_ROOM_ID
sharedStateHolder.update {
it.copy(selectedSpaceIds = persistentListOf(roomId))
}
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test {
with(awaitItem()) {
assertThat(selectedIds).containsExactly(roomId)
assertThat(isDoneButtonEnabled).isTrue()
}
}
}
@Test
fun `present - ToggleSpace event adds space to selectedIds in shared state`() = runTest {
val sharedStateHolder = SpaceSelectionStateHolder()
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test {
val initialState = awaitItem()
val roomId = A_ROOM_ID
initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(roomId))
with(awaitItem()) {
assertThat(selectedIds).containsExactly(roomId)
assertThat(isDoneButtonEnabled).isTrue()
}
// Verify the shared state is also updated
assertThat(sharedStateHolder.state.value.selectedSpaceIds).containsExactly(roomId)
}
}
@Test
fun `present - ToggleSpace event removes space when already selected`() = runTest {
val sharedStateHolder = SpaceSelectionStateHolder()
sharedStateHolder.updateSelectedSpaceIds(persistentListOf(A_ROOM_ID))
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.selectedIds).containsExactly(A_ROOM_ID)
initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(A_ROOM_ID))
with(awaitItem()) {
assertThat(selectedIds).isEmpty()
assertThat(isDoneButtonEnabled).isFalse()
}
// Verify the shared state is also updated
assertThat(sharedStateHolder.state.value.selectedSpaceIds).isEmpty()
}
}
@Test
fun `present - Done event sets completion to Completed`() = runTest {
val sharedStateHolder = SpaceSelectionStateHolder()
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ManageAuthorizedSpacesEvent.Done)
cancelAndIgnoreRemainingEvents()
assertThat(sharedStateHolder.state.value.completion)
.isEqualTo(SpaceSelectionState.Completion.Completed)
}
}
@Test
fun `present - Cancel event sets completion to Cancelled`() = runTest {
val sharedStateHolder = SpaceSelectionStateHolder()
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(ManageAuthorizedSpacesEvent.Cancel)
cancelAndIgnoreRemainingEvents()
assertThat(sharedStateHolder.state.value.completion)
.isEqualTo(SpaceSelectionState.Completion.Cancelled)
}
}
@Test
fun `present - displays spaces from shared state`() = runTest {
val sharedStateHolder = SpaceSelectionStateHolder()
sharedStateHolder.update {
it.copy(
selectableSpaces = persistentSetOf(),
unknownSpaceIds = persistentListOf(A_ROOM_ID),
)
}
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test {
with(awaitItem()) {
assertThat(selectableSpaces).isEmpty()
assertThat(unknownSpaceIds).containsExactly(A_ROOM_ID)
}
}
}
}

View file

@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.securityandprivacy.impl.manageauthorizedspaces
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ManageAuthorizedSpacesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking back emits Cancel event`() {
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>()
val state = aManageAuthorizedSpacesState(eventSink = recorder)
rule.setManageAuthorizedSpacesView(state)
rule.pressBack()
recorder.assertSingle(ManageAuthorizedSpacesEvent.Cancel)
}
@Test
fun `clicking space checkbox emits ToggleSpace event`() {
val roomId = A_ROOM_ID
val space = aSpaceRoom(roomId = roomId, displayName = "Test Space")
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>()
val state = aManageAuthorizedSpacesState(
selectableSpaces = listOf(space),
eventSink = recorder
)
rule.setManageAuthorizedSpacesView(state)
rule.onNodeWithText("Test Space").performClick()
recorder.assertSingle(ManageAuthorizedSpacesEvent.ToggleSpace(roomId))
}
@Test
fun `clicking done button emits Done event`() {
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>()
val state = aManageAuthorizedSpacesState(
selectedIds = listOf(A_ROOM_ID),
eventSink = recorder
)
rule.setManageAuthorizedSpacesView(state)
rule.clickOn(CommonStrings.action_done)
recorder.assertSingle(ManageAuthorizedSpacesEvent.Done)
}
@Test
fun `done button is disabled when no spaces selected`() {
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>(expectEvents = false)
val state = aManageAuthorizedSpacesState(
selectedIds = emptyList(),
eventSink = recorder
)
rule.setManageAuthorizedSpacesView(state)
rule.clickOn(CommonStrings.action_done)
recorder.assertEmpty()
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setManageAuthorizedSpacesView(
state: ManageAuthorizedSpacesState = aManageAuthorizedSpacesState(
eventSink = EventsRecorder(expectEvents = false)
),
) {
setContent {
ManageAuthorizedSpacesView(state = state)
}
}
private fun aManageAuthorizedSpacesState(
selectableSpaces: List<SpaceRoom> = emptyList(),
unknownSpaceIds: List<RoomId> = emptyList(),
selectedIds: List<RoomId> = emptyList(),
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {},
) = ManageAuthorizedSpacesState(
selectableSpaces = selectableSpaces.toImmutableSet(),
unknownSpaceIds = unknownSpaceIds.toImmutableList(),
selectedIds = selectedIds.toImmutableList(),
eventSink = eventSink,
)

View file

@ -1,12 +1,11 @@
/* /*
* Copyright (c) 2025 Element Creations Ltd. * Copyright (c) 2026 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
package io.element.android.features.securityandprivacy.impl package io.element.android.features.securityandprivacy.impl.root
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.AndroidComposeTestRule
@ -14,20 +13,16 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyEvent import io.element.android.features.securityandprivacy.impl.R
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyHistoryVisibility
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyRoomAccess
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyState
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyView
import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacySettings
import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacyState
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.persistentListOf
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -179,6 +174,50 @@ class SecurityAndPrivacyViewTest {
rule.clickOn(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title) rule.clickOn(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title)
recorder.assertSingle(SecurityAndPrivacyEvent.ConfirmEnableEncryption) recorder.assertSingle(SecurityAndPrivacyEvent.ConfirmEnableEncryption)
} }
@Test
@Config(qualifiers = "h1024dp")
fun `click on space member access emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvent>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
spaceSelectionMode = SpaceSelectionMode.Single(A_ROOM_ID, null),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_room_access_space_members_option_title)
recorder.assertSingle(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
}
@Test
@Config(qualifiers = "h1024dp")
fun `click on ask to join with space members emits the expected event`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvent>()
val state = aSecurityAndPrivacyState(
eventSink = recorder,
spaceSelectionMode = SpaceSelectionMode.Single(A_ROOM_ID, null),
)
rule.setSecurityAndPrivacyView(state)
rule.clickOn(R.string.screen_security_and_privacy_ask_to_join_option_title)
recorder.assertSingle(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess)
}
@Test
@Config(qualifiers = "h1024dp")
fun `manage spaces footer is shown when space member access is selected`() {
val recorder = EventsRecorder<SecurityAndPrivacyEvent>(expectEvents = false)
val state = aSecurityAndPrivacyState(
eventSink = recorder,
spaceSelectionMode = SpaceSelectionMode.Multiple,
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(persistentListOf(A_ROOM_ID)),
),
)
rule.setSecurityAndPrivacyView(state)
// The footer text uses AnnotatedString with a link. Verify the footer text is displayed.
val actionFooterText = rule.activity.getString(R.string.screen_security_and_privacy_room_access_footer_manage_spaces_action)
val footerText = rule.activity.getString(R.string.screen_security_and_privacy_room_access_footer, actionFooterText)
rule.onNodeWithText(footerText).assertExists()
}
} }
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSecurityAndPrivacyView( private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setSecurityAndPrivacyView(

View file

@ -15,6 +15,10 @@ interface SpaceService {
val spaceRoomsFlow: SharedFlow<List<SpaceRoom>> val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
suspend fun joinedSpaces(): Result<List<SpaceRoom>> suspend fun joinedSpaces(): Result<List<SpaceRoom>>
suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>>
suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom?
fun spaceRoomList(id: RoomId): SpaceRoomList fun spaceRoomList(id: RoomId): SpaceRoomList
fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle

View file

@ -51,13 +51,28 @@ class RustSpaceService(
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) { override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions { runCatchingExceptions {
innerSpaceService.topLevelJoinedSpaces() innerSpaceService
.map { .topLevelJoinedSpaces()
it.let(spaceRoomMapper::map) .map(spaceRoomMapper::map)
}
} }
} }
override suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService
.joinedParentsOfChild(spaceId.value)
.map(spaceRoomMapper::map)
}
}
override suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.getSpaceRoom(spaceId.value)?.let { spaceRoom ->
spaceRoomMapper.map(spaceRoom)
}
}.getOrNull()
}
override fun spaceRoomList(id: RoomId): SpaceRoomList { override fun spaceRoomList(id: RoomId): SpaceRoomList {
val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this") val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this")
return RustSpaceRoomList( return RustSpaceRoomList(

View file

@ -23,6 +23,8 @@ class FakeSpaceService(
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() }, private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() }, private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
private val joinedParentsResult: (RoomId) -> Result<List<SpaceRoom>> = { lambdaError() },
private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() },
) : SpaceService { ) : SpaceService {
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>() private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>> override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
@ -36,6 +38,14 @@ class FakeSpaceService(
return joinedSpacesResult() return joinedSpacesResult()
} }
override suspend fun joinedParents(spaceId: RoomId): Result<List<SpaceRoom>> {
return joinedParentsResult(spaceId)
}
override suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? {
return getSpaceRoomResult(spaceId)
}
override fun spaceRoomList(id: RoomId): SpaceRoomList { override fun spaceRoomList(id: RoomId): SpaceRoomList {
return spaceRoomListResult(id) return spaceRoomListResult(id)
} }

View file

@ -435,11 +435,6 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="screen_create_poll_options_section_title">"Valikud"</string> <string name="screen_create_poll_options_section_title">"Valikud"</string>
<string name="screen_create_poll_remove_accessibility_label">"Kustuta: %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Kustuta: %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Seadistused"</string> <string name="screen_create_poll_settings_section_title">"Seadistused"</string>
<string name="screen_manage_authorized_spaces_header">"Kogukonnad, milles on võimalik jututoaga liituda ilma kutseta."</string>
<string name="screen_manage_authorized_spaces_title">"Halda kogukondi"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Tundmatu kogukond)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Muud kogukonnad, mille liige sa ei ole"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Sinu kogukonnad"</string>
<string name="screen_media_picker_error_failed_selection">"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."</string> <string name="screen_media_picker_error_failed_selection">"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_pinned_timeline_empty_state_description">"Siia lisamiseks vajuta sõnumil ja vali „%1$s“."</string> <string name="screen_pinned_timeline_empty_state_description">"Siia lisamiseks vajuta sõnumil ja vali „%1$s“."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Et olulisi sõnumeid oleks lihtsam leida, tõsta nad esile"</string> <string name="screen_pinned_timeline_empty_state_headline">"Et olulisi sõnumeid oleks lihtsam leida, tõsta nad esile"</string>

View file

@ -435,11 +435,6 @@ Raison : %1$s."</string>
<string name="screen_create_poll_options_section_title">"Options"</string> <string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Supprimer %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Supprimer %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Paramètres"</string> <string name="screen_create_poll_settings_section_title">"Paramètres"</string>
<string name="screen_manage_authorized_spaces_header">"Espaces où les membres peuvent rejoindre le salon sans invitation."</string>
<string name="screen_manage_authorized_spaces_title">"Gérer les espaces"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Espace inconnu)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Autres espaces dont vous nêtes pas membre"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vos espaces"</string>
<string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string> <string name="screen_media_picker_error_failed_selection">"Échec de la sélection du média, veuillez réessayer."</string>
<string name="screen_pinned_timeline_empty_state_description">"Cliquez (clic long) sur un message et choisissez « %1$s » pour quil apparaisse ici."</string> <string name="screen_pinned_timeline_empty_state_description">"Cliquez (clic long) sur un message et choisissez « %1$s » pour quil apparaisse ici."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Épinglez les messages importants pour leur donner plus de visibilité"</string> <string name="screen_pinned_timeline_empty_state_headline">"Épinglez les messages importants pour leur donner plus de visibilité"</string>

View file

@ -443,11 +443,6 @@ Jeste li sigurni da želite nastaviti?"</string>
<string name="screen_create_poll_options_section_title">"Mogućnosti"</string> <string name="screen_create_poll_options_section_title">"Mogućnosti"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ukloni %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Ukloni %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Postavke"</string> <string name="screen_create_poll_settings_section_title">"Postavke"</string>
<string name="screen_manage_authorized_spaces_header">"Prostori u kojima se članovi mogu pridružiti sobi bez pozivnice."</string>
<string name="screen_manage_authorized_spaces_title">"Upravljaj prostorima"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(nepoznati prostor)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Drugi prostori čiji niste član"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vaši prostori"</string>
<string name="screen_media_picker_error_failed_selection">"Odabir medija nije uspio, pokušajte ponovno."</string> <string name="screen_media_picker_error_failed_selection">"Odabir medija nije uspio, pokušajte ponovno."</string>
<string name="screen_pinned_timeline_empty_state_description">"Pritisnite poruku i odaberite “%1$s” kako biste uključili ovdje."</string> <string name="screen_pinned_timeline_empty_state_description">"Pritisnite poruku i odaberite “%1$s” kako biste uključili ovdje."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Prikvačite važne poruke kako bi ih se lakše moglo pronaći"</string> <string name="screen_pinned_timeline_empty_state_headline">"Prikvačite važne poruke kako bi ih se lakše moglo pronaći"</string>

View file

@ -434,11 +434,6 @@ Você tem certeza de que deseja continuar?"</string>
<string name="screen_create_poll_options_section_title">"Opções"</string> <string name="screen_create_poll_options_section_title">"Opções"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remover %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Remover %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Configurações"</string> <string name="screen_create_poll_settings_section_title">"Configurações"</string>
<string name="screen_manage_authorized_spaces_header">"Os espaços dos quais os membros podem entrar na sala sem um convite."</string>
<string name="screen_manage_authorized_spaces_title">"Gerenciar espaços"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Espaço desconhecido)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Outros espaços dos quais você não é um membro"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Seus espaços"</string>
<string name="screen_media_picker_error_failed_selection">"Falha ao selecionar a mídia, tente novamente."</string> <string name="screen_media_picker_error_failed_selection">"Falha ao selecionar a mídia, tente novamente."</string>
<string name="screen_pinned_timeline_empty_state_description">"Pressione em uma mensagem e escolha \"%1$s\" para incluir aqui."</string> <string name="screen_pinned_timeline_empty_state_description">"Pressione em uma mensagem e escolha \"%1$s\" para incluir aqui."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Fixe mensagens importantes para que elas possam ser facilmente descobertas"</string> <string name="screen_pinned_timeline_empty_state_headline">"Fixe mensagens importantes para que elas possam ser facilmente descobertas"</string>

View file

@ -442,11 +442,6 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="screen_create_poll_options_section_title">"Opțiuni"</string> <string name="screen_create_poll_options_section_title">"Opțiuni"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ștergeți %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Ștergeți %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Setări"</string> <string name="screen_create_poll_settings_section_title">"Setări"</string>
<string name="screen_manage_authorized_spaces_header">"Spațile din care membrii se pot alătura camerei fără invitație."</string>
<string name="screen_manage_authorized_spaces_title">"Gestionați spațiile"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Spațiu necunoscut)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Alte spații din care nu faceți parte"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Spațiile dumneavoastră"</string>
<string name="screen_media_picker_error_failed_selection">"Selectarea fișierelor media a eșuat, încercați din nou."</string> <string name="screen_media_picker_error_failed_selection">"Selectarea fișierelor media a eșuat, încercați din nou."</string>
<string name="screen_pinned_timeline_empty_state_description">"Apăsați pe un mesaj și alegeți \"%1$s\" pentru a-l include aici."</string> <string name="screen_pinned_timeline_empty_state_description">"Apăsați pe un mesaj și alegeți \"%1$s\" pentru a-l include aici."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Fixați mesajele importante, astfel încât să poată fi descoperite cu ușurință"</string> <string name="screen_pinned_timeline_empty_state_headline">"Fixați mesajele importante, astfel încât să poată fi descoperite cu ușurință"</string>

View file

@ -439,11 +439,6 @@ Naozaj chcete pokračovať?"</string>
<string name="screen_create_poll_options_section_title">"Možnosti"</string> <string name="screen_create_poll_options_section_title">"Možnosti"</string>
<string name="screen_create_poll_remove_accessibility_label">"Odstrániť %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Odstrániť %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Nastavenia"</string> <string name="screen_create_poll_settings_section_title">"Nastavenia"</string>
<string name="screen_manage_authorized_spaces_header">"Priestory, kde sa členovia môžu pripojiť k miestnosti bez pozvania."</string>
<string name="screen_manage_authorized_spaces_title">"Spravovať priestory"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Neznámy priestor)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Iné priestory, ktorých nie ste členom"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Vaše priestory"</string>
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string> <string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
<string name="screen_pinned_timeline_empty_state_description">"Stlačte správu a vyberte možnosť „%1$s“, ktorú chcete zahrnúť sem."</string> <string name="screen_pinned_timeline_empty_state_description">"Stlačte správu a vyberte možnosť „%1$s“, ktorú chcete zahrnúť sem."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Pripnite dôležité správy, aby sa dali ľahko nájsť"</string> <string name="screen_pinned_timeline_empty_state_headline">"Pripnite dôležité správy, aby sa dali ľahko nájsť"</string>

View file

@ -435,11 +435,6 @@ Are you sure you want to continue?"</string>
<string name="screen_create_poll_options_section_title">"Options"</string> <string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string> <string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Settings"</string> <string name="screen_create_poll_settings_section_title">"Settings"</string>
<string name="screen_manage_authorized_spaces_header">"Spaces where members can join the room without an invitation."</string>
<string name="screen_manage_authorized_spaces_title">"Manage spaces"</string>
<string name="screen_manage_authorized_spaces_unknown_space">"(Unknown space)"</string>
<string name="screen_manage_authorized_spaces_unknown_spaces_section_title">"Other spaces youre not a member of"</string>
<string name="screen_manage_authorized_spaces_your_spaces_section_title">"Your spaces"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string> <string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_pinned_timeline_empty_state_description">"Press on a message and choose “%1$s” to include here."</string> <string name="screen_pinned_timeline_empty_state_description">"Press on a message and choose “%1$s” to include here."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Pin important messages so that they can be easily discovered"</string> <string name="screen_pinned_timeline_empty_state_headline">"Pin important messages so that they can be easily discovered"</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e09986f8b500061cfc9108af757b581334d8babb3c5c17433dd53dd3a8f52f3
size 48709

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e09986f8b500061cfc9108af757b581334d8babb3c5c17433dd53dd3a8f52f3
size 48709

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f953609cb044e439198f7072a4468c54269b646e3c39686440334903eb12797d
size 49279

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c481049fcd33ca40aac538bf82ca56ef27cce2448c148cc45ba95493b668ef03
size 47890

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c481049fcd33ca40aac538bf82ca56ef27cce2448c148cc45ba95493b668ef03
size 47890

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:15ed6659fbf148a23b4090ba8896bad91d82279df71552c2798755b190a0cdca
size 48369

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bef6e6c4cdd362577cf5dc91914a84c72465014a71a08f48c1f4a526be44c85c oid sha256:67bd76421dcb3e8a6c2c0a6aeda024dacb392b2f6d8bea278d6ffae7de5b4e8f
size 39535 size 20023

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:39222fd299a2dec3cc60863878906eba5c9d86100a3e4bad781633a1a0372cc5 oid sha256:48f50c8053f0f15228899e8bce1656a90cfaf936e6bc8ca725c676aff5578575
size 39874 size 39874

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5c9bf91f57bb68ae3341acecd62d418d1654a8d4cf271eeda9e87bc5e4998891 oid sha256:bef6e6c4cdd362577cf5dc91914a84c72465014a71a08f48c1f4a526be44c85c
size 20225 size 39535

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:495fff852c9549125b3d3a681a880147f680d4604eee3cb13a8fc3dc47e7c729 oid sha256:39222fd299a2dec3cc60863878906eba5c9d86100a3e4bad781633a1a0372cc5
size 40476 size 39874

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:39222fd299a2dec3cc60863878906eba5c9d86100a3e4bad781633a1a0372cc5 oid sha256:da260572a274c3fedcc81b055e1c52b8cf7467692287aa176b0dd9171be76937
size 39874 size 25139

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cdd805d857d590437bde9be2a7df252ff42571d3b08be8085aae81dc75e896c3 oid sha256:da888123f37a0dcc7d80849216044b82a442052c900d636d006020ad42469a71
size 40235 size 51849

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:507eec1e5125056ca4980ae67f904dd30bcb88ef6d66d5968aafbb3c1449e4b8 oid sha256:da888123f37a0dcc7d80849216044b82a442052c900d636d006020ad42469a71
size 33779 size 51849

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d300a9854096634ea0e6722db487607a75f6e0c46d7341f044a190a212d95307 oid sha256:495fff852c9549125b3d3a681a880147f680d4604eee3cb13a8fc3dc47e7c729
size 32373 size 40476

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b42e3df9fec259210b63431e295e8c7d1a7640149c9616b572b90c0b40392a60 oid sha256:39222fd299a2dec3cc60863878906eba5c9d86100a3e4bad781633a1a0372cc5
size 34637 size 39874

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3bb107a37b14dcbc6aa530a8d47f58c809df7719a0c8a416a268ffd2df165681 oid sha256:cdd805d857d590437bde9be2a7df252ff42571d3b08be8085aae81dc75e896c3
size 41602 size 40235

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:507eec1e5125056ca4980ae67f904dd30bcb88ef6d66d5968aafbb3c1449e4b8
size 33779

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d300a9854096634ea0e6722db487607a75f6e0c46d7341f044a190a212d95307
size 32373

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b42e3df9fec259210b63431e295e8c7d1a7640149c9616b572b90c0b40392a60
size 34637

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3bb107a37b14dcbc6aa530a8d47f58c809df7719a0c8a416a268ffd2df165681
size 41602

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b67ebf4536e36571b0867e71533b2fe1953ca2b88f15fe3531f3ddb9043f69ec oid sha256:64134bc23ef38b73a087ec5679784516bfd66f23dd675a3771e1aa8cf6159836
size 39418 size 44729

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e5fb16ef839b9f93caddbc984c8be260846e7fd7d68ae3b31dcc97c7644b41b4 oid sha256:56d1614ce5e0da0bc29f8184f045339e263087676e9d7908a94007f410924b5d
size 56670 size 59154

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9402ad99b181b99083abff78d078e9fbb010144e07f80ff995db7884a61fa853 oid sha256:56d1614ce5e0da0bc29f8184f045339e263087676e9d7908a94007f410924b5d
size 56060 size 59154

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cadacec9344f3ca2c029006c620cd8c309bb7f4b38cbb7b47c7c336ca265a141 oid sha256:e5fb16ef839b9f93caddbc984c8be260846e7fd7d68ae3b31dcc97c7644b41b4
size 56424 size 56670

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:67bd76421dcb3e8a6c2c0a6aeda024dacb392b2f6d8bea278d6ffae7de5b4e8f oid sha256:9402ad99b181b99083abff78d078e9fbb010144e07f80ff995db7884a61fa853
size 20023 size 56060

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:48f50c8053f0f15228899e8bce1656a90cfaf936e6bc8ca725c676aff5578575 oid sha256:cadacec9344f3ca2c029006c620cd8c309bb7f4b38cbb7b47c7c336ca265a141
size 39874 size 56424

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:342cc313605f43325a0720fe9c9fa969ed7edd41258ce0768dc624fc4e74d66d oid sha256:8303e863849a00c16f00850854e8d4d7ceaf3fb097c7b20fe349127d8ed3b082
size 40636 size 20651

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:adfd4213ac36d904a8a31b1c3bf5463ba72ad4760d084a4c412370d6d51cf250 oid sha256:1626bffb649c204b0dcb1c92e7344d1b7f119b61ecac3c49840385f3a9a39515
size 40996 size 40998

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4ef8e92812f6b65fc09ea4e77d44f9c32a74daebe8e2ef2a9e499588e46a43db oid sha256:342cc313605f43325a0720fe9c9fa969ed7edd41258ce0768dc624fc4e74d66d
size 20823 size 40636

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5547eb8b7f7ca596edf65bfd7703d1cb91fbf2e254374b9ea41d5c8754030e34 oid sha256:adfd4213ac36d904a8a31b1c3bf5463ba72ad4760d084a4c412370d6d51cf250
size 41628 size 40996

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:adfd4213ac36d904a8a31b1c3bf5463ba72ad4760d084a4c412370d6d51cf250 oid sha256:20a302e03d4f10c912c249f7c26f8ecc06472dd2a2ad4abe91607bb7527de732
size 40996 size 25577

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:40c6334e01cc858f343f9046cfbb5079b79369002f5194965b6c2e75dd37b134 oid sha256:9939e7a38e0e280d42a996576a3cfbeb773625cf43ddf8afd04aad6ab35fd955
size 41437 size 53555

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:548950d915f6fbfdf1a17b81619d3d4cbefe93b79091d259bcec41ae6bbbc652 oid sha256:9939e7a38e0e280d42a996576a3cfbeb773625cf43ddf8afd04aad6ab35fd955
size 35422 size 53555

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6b47bcab08cded6e857fe213ab2c90602e557a36feebb45b2b8d4d18115ac68b oid sha256:5547eb8b7f7ca596edf65bfd7703d1cb91fbf2e254374b9ea41d5c8754030e34
size 34582 size 41628

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5cd5de47e285702bf3d2ee325fc83a1168a0367d9c6b54018565daa0ed503a77 oid sha256:adfd4213ac36d904a8a31b1c3bf5463ba72ad4760d084a4c412370d6d51cf250
size 36876 size 40996

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:59b6d7d5e484b34e1ce7814432144df34b1cd3323d31c90db439cbb5d9f8dc83 oid sha256:40c6334e01cc858f343f9046cfbb5079b79369002f5194965b6c2e75dd37b134
size 43588 size 41437

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:548950d915f6fbfdf1a17b81619d3d4cbefe93b79091d259bcec41ae6bbbc652
size 35422

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6b47bcab08cded6e857fe213ab2c90602e557a36feebb45b2b8d4d18115ac68b
size 34582

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5cd5de47e285702bf3d2ee325fc83a1168a0367d9c6b54018565daa0ed503a77
size 36876

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:59b6d7d5e484b34e1ce7814432144df34b1cd3323d31c90db439cbb5d9f8dc83
size 43588

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a3bebec99f2b870e7a38333590f5cb776b30f4f9b976d99601a7fb3bfd05a71f oid sha256:093bb9672ecd80432c7e6102bd5ed2d1184cd384c91e479d99d13c0ba773bf78
size 40913 size 46448

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:853218e7ddc4d18b260f6ab047beb82778284851e230babc14113c6cb329d29a oid sha256:ee80f7a765a453c3704af6d1f38f90637528bcf1e5966dc1bd016f4d34f21fa4
size 58504 size 61069

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e7caee4430244e294b75e3b6a5830088dd25397b1c272666eb2bec47ecd1f382 oid sha256:ee80f7a765a453c3704af6d1f38f90637528bcf1e5966dc1bd016f4d34f21fa4
size 57895 size 61069

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bbb4224047c88d838de227e61332775e5efe0ce1380a7446c701f0db5b2d2dcc oid sha256:853218e7ddc4d18b260f6ab047beb82778284851e230babc14113c6cb329d29a
size 58323 size 58504

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8303e863849a00c16f00850854e8d4d7ceaf3fb097c7b20fe349127d8ed3b082 oid sha256:e7caee4430244e294b75e3b6a5830088dd25397b1c272666eb2bec47ecd1f382
size 20651 size 57895

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1626bffb649c204b0dcb1c92e7344d1b7f119b61ecac3c49840385f3a9a39515 oid sha256:bbb4224047c88d838de227e61332775e5efe0ce1380a7446c701f0db5b2d2dcc
size 40998 size 58323

View file

@ -403,7 +403,8 @@
"name" : ":features:securityandprivacy:impl", "name" : ":features:securityandprivacy:impl",
"includeRegex" : [ "includeRegex" : [
"screen\\.edit_room_address\\..*", "screen\\.edit_room_address\\..*",
"screen\\.security_and_privacy\\..*" "screen\\.security_and_privacy\\..*",
"screen\\.manage_authorized_spaces\\..*"
] ]
} }
] ]