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
85 changed files with 2535 additions and 571 deletions

View File

@@ -9,6 +9,7 @@
package io.element.android.features.securityandprivacy.impl
import android.os.Parcelable
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.securityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.editroomaddress.EditRoomAddressNode
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.ManageAuthorizedSpacesNode
import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyNode
import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
@@ -58,10 +60,15 @@ class SecurityAndPrivacyFlowNode(
@Parcelize
data object EditRoomAddress : NavTarget
@Parcelize
data object ManageAuthorizedSpaces : NavTarget
}
private val callback: SecurityAndPrivacyEntryPoint.Callback = callback()
private val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack)
@VisibleForTesting
val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack)
override fun onBuilt() {
super.onBuilt()
@@ -89,6 +96,9 @@ class SecurityAndPrivacyFlowNode(
NavTarget.EditRoomAddress -> {
createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator))
}
NavTarget.ManageAuthorizedSpaces -> {
createNode<ManageAuthorizedSpacesNode>(buildContext, plugins = listOf(navigator))
}
}
}

View File

@@ -18,6 +18,8 @@ interface SecurityAndPrivacyNavigator : Plugin {
fun onDone()
fun openEditRoomAddress()
fun closeEditRoomAddress()
fun openManageAuthorizedSpaces()
fun closeManageAuthorizedSpaces()
}
class BackstackSecurityAndPrivacyNavigator(
@@ -35,4 +37,12 @@ class BackstackSecurityAndPrivacyNavigator(
override fun closeEditRoomAddress() {
backStack.pop()
}
override fun openManageAuthorizedSpaces() {
backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces)
}
override fun closeManageAuthorizedSpaces() {
backStack.pop()
}
}

View File

@@ -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 {
data object EditRoomAddress : SecurityAndPrivacyEvent
data object ManageAuthorizedSpaces : SecurityAndPrivacyEvent
data object Save : SecurityAndPrivacyEvent
data object Exit : SecurityAndPrivacyEvent
data object DismissExitConfirmation : SecurityAndPrivacyEvent
data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvent
// Special case for "Space Members"
data object SelectSpaceMemberAccess : SecurityAndPrivacyEvent
// Special case for "Ask to join with Space Members"
data object SelectAskToJoinWithSpaceMembersAccess : SecurityAndPrivacyEvent
data object ToggleEncryptionState : SecurityAndPrivacyEvent
data object CancelEnableEncryption : SecurityAndPrivacyEvent
data object ConfirmEnableEncryption : SecurityAndPrivacyEvent

View File

@@ -15,6 +15,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -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.impl.SecurityAndPrivacyNavigator
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.AsyncData
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.RoomInfo
import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility
import io.element.android.libraries.matrix.api.room.join.AllowRule
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@AssistedInject
class SecurityAndPrivacyPresenter(
@Assisted private val navigator: SecurityAndPrivacyNavigator,
private val spaceSelectionStateHolder: SpaceSelectionStateHolder,
private val matrixClient: MatrixClient,
private val room: JoinedRoom,
private val featureFlagService: FeatureFlagService,
) : Presenter<SecurityAndPrivacyState> {
@AssistedFactory
interface Factory {
fun create(navigator: SecurityAndPrivacyNavigator): SecurityAndPrivacyPresenter
fun create(
navigator: SecurityAndPrivacyNavigator,
): SecurityAndPrivacyPresenter
}
@Composable
@@ -65,6 +78,10 @@ class SecurityAndPrivacyPresenter(
val isKnockEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(false)
val isSpaceSettingsEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.SpaceSettings)
}.collectAsState(false)
val saveAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val homeserverName = remember { matrixClient.userIdServerName() }
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)
}
var editedHistoryVisibility by remember(savedSettings.historyVisibility) {
@@ -99,13 +116,44 @@ class SecurityAndPrivacyPresenter(
mutableStateOf(savedIsVisibleInRoomDirectory.value)
}
val editedSettings = SecurityAndPrivacySettings(
roomAccess = editedRoomAccess,
roomAccess = editedRoomAccess.value,
isEncrypted = editedIsEncrypted,
isVisibleInRoomDirectory = editedVisibleInRoomDirectory,
historyVisibility = editedHistoryVisibility,
address = savedSettings.address,
)
val selectableJoinedSpaces by produceState(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) }
val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms ->
perms.securityAndPrivacyPermissions()
@@ -122,7 +170,7 @@ class SecurityAndPrivacyPresenter(
)
}
is SecurityAndPrivacyEvent.ChangeRoomAccess -> {
editedRoomAccess = event.roomAccess
editedRoomAccess.value = event.roomAccess
}
is SecurityAndPrivacyEvent.ToggleEncryptionState -> {
if (editedIsEncrypted) {
@@ -161,6 +209,27 @@ class SecurityAndPrivacyPresenter(
SecurityAndPrivacyEvent.DismissExitConfirmation -> {
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,
permissions = permissions,
isSpace = roomInfo.isSpace,
isSpaceSettingsEnabled = isSpaceSettingsEnabled,
selectableJoinedSpaces = selectableJoinedSpaces,
spaceSelectionMode = spaceSelectionMode,
eventSink = ::handleEvent,
)
// Revert changes that the user is not allowed to make anymore
LaunchedEffect(permissions, state.editedSettings.roomAccess) {
if (!state.showRoomAccessSection) {
editedRoomAccess = savedSettings.roomAccess
editedRoomAccess.value = savedSettings.roomAccess
}
if (!state.showEncryptionSection) {
editedIsEncrypted = savedSettings.isEncrypted
@@ -202,6 +274,110 @@ class SecurityAndPrivacyPresenter(
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 {
isRoomVisible.runUpdatingState {
room.getRoomVisibility().map { it == RoomVisibility.Public }
@@ -242,6 +418,7 @@ class SecurityAndPrivacyPresenter(
// the room should be automatically made invisible (private) in the room directory.
val editedIsVisibleInRoomDirectory = when (editedSettings.roomAccess) {
SecurityAndPrivacyRoomAccess.AskToJoin,
is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember,
SecurityAndPrivacyRoomAccess.Anyone -> editedSettings.isVisibleInRoomDirectory.dataOrNull()
else -> false
}
@@ -279,8 +456,19 @@ class SecurityAndPrivacyPresenter(
private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess {
return when (this) {
JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone
JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin
is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember
JoinRule.Knock -> SecurityAndPrivacyRoomAccess.AskToJoin
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
// All other cases are not supported so we default to InviteOnly
is JoinRule.Custom,
@@ -294,8 +482,12 @@ private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? {
SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public
SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock
SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private
// SpaceMember can't be selected in the ui
SecurityAndPrivacyRoomAccess.SpaceMember -> null
is SecurityAndPrivacyRoomAccess.SpaceMember -> JoinRule.Restricted(
rules = this.spaceIds.map { AllowRule.RoomMembership(it) }.toImmutableList()
)
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
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.impl.R
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
data class SecurityAndPrivacyState(
@@ -20,12 +28,42 @@ data class SecurityAndPrivacyState(
val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String,
val showEnableEncryptionConfirmation: Boolean,
val isKnockEnabled: Boolean,
private val isKnockEnabled: Boolean,
private val isSpaceSettingsEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
val isSpace: Boolean,
private val permissions: SecurityAndPrivacyPermissions,
private val selectableJoinedSpaces: ImmutableSet<SpaceRoom>,
private val spaceSelectionMode: SpaceSelectionMode,
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
// 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 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(
@@ -76,16 +148,31 @@ enum class SecurityAndPrivacyHistoryVisibility {
}
}
enum class SecurityAndPrivacyRoomAccess {
InviteOnly,
AskToJoin,
Anyone,
SpaceMember;
sealed interface SpaceSelectionMode {
data object None : SpaceSelectionMode
data class Single(val spaceId: RoomId, val spaceRoom: SpaceRoom?) : SpaceSelectionMode
data object Multiple : SpaceSelectionMode
}
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 {
return when (this) {
InviteOnly, SpaceMember -> false
AskToJoin, Anyone -> true
InviteOnly, is SpaceMember -> false
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.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableSet
open class SecurityAndPrivacyStateProvider : PreviewParameterProvider<SecurityAndPrivacyState> {
override val values: Sequence<SecurityAndPrivacyState>
@@ -61,11 +64,27 @@ private fun commonSecurityAndPrivacyStates(isSpace: Boolean): Sequence<SecurityA
),
aSecurityAndPrivacyState(
savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember
roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(persistentListOf())
),
spaceSelectionMode = SpaceSelectionMode.Multiple,
isSpace = isSpace,
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(
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,
@@ -117,6 +136,9 @@ fun aSecurityAndPrivacyState(
),
isKnockEnabled: Boolean = true,
isSpace: Boolean = false,
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
isSpaceSettingsEnabled: Boolean = true,
eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState(
editedSettings = editedSettings,
@@ -127,5 +149,8 @@ fun aSecurityAndPrivacyState(
isKnockEnabled = isKnockEnabled,
permissions = permissions,
isSpace = isSpace,
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
spaceSelectionMode = spaceSelectionMode,
isSpaceSettingsEnabled = isSpaceSettingsEnabled,
eventSink = eventSink,
)

View File

@@ -90,11 +90,8 @@ fun SecurityAndPrivacyView(
) {
if (state.showRoomAccessSection) {
RoomAccessSection(
state = state,
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) {
@@ -208,12 +205,27 @@ private fun SecurityAndPrivacySection(
@Composable
private fun RoomAccessSection(
edited: SecurityAndPrivacyRoomAccess,
saved: SecurityAndPrivacyRoomAccess,
isKnockEnabled: Boolean,
onSelectOption: (SecurityAndPrivacyRoomAccess) -> Unit,
state: SecurityAndPrivacyState,
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(
title = stringResource(R.string.screen_security_and_privacy_room_access_section_header),
modifier = modifier,
@@ -225,29 +237,36 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
)
// Show space member option, but disabled as we don't support this option for now.
if (saved == SecurityAndPrivacyRoomAccess.SpaceMember) {
if (state.showSpaceMemberOption) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) },
supportingContent = {
Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description))
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())),
enabled = false,
onClick = ::onSpaceMemberAccessClick,
enabled = state.isSpaceMemberSelectable,
)
}
// Show Ask to join option in two cases:
// - the Knock FF is enabled
// - AskToJoin is the current saved value
if (saved == SecurityAndPrivacyRoomAccess.AskToJoin || isKnockEnabled) {
if (state.showAskToJoinOption) {
ListItem(
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)) },
trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) },
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(
@@ -257,6 +276,20 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
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">
<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_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_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>

View File

@@ -2,6 +2,11 @@
<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_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_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>

View File

@@ -2,6 +2,11 @@
<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_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_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>

View File

@@ -2,6 +2,11 @@
<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_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_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>

View File

@@ -2,6 +2,11 @@
<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_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_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>

View File

@@ -2,6 +2,11 @@
<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_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_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>

View File

@@ -2,6 +2,11 @@
<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_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_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>

View File

@@ -14,6 +14,8 @@ class FakeSecurityAndPrivacyNavigator(
private val onDoneLambda: () -> Unit = { lambdaError() },
private val openEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val openManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() },
private val closeManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() },
) : SecurityAndPrivacyNavigator {
override fun onDone() {
onDoneLambda()
@@ -26,4 +28,12 @@ class FakeSecurityAndPrivacyNavigator(
override fun closeEditRoomAddress() {
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 2025 New Vector Ltd.
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.securityandprivacy.impl
package io.element.android.features.securityandprivacy.impl.root
import androidx.activity.ComponentActivity
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.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
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.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.features.securityandprivacy.impl.R
import io.element.android.libraries.architecture.AsyncAction
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.tests.testutils.EnsureNeverCalledWithParam
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.persistentListOf
import org.junit.Rule
import org.junit.Test
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)
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(

View File

@@ -15,6 +15,10 @@ interface SpaceService {
val spaceRoomsFlow: SharedFlow<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 getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle

View File

@@ -51,13 +51,28 @@ class RustSpaceService(
override suspend fun joinedSpaces(): Result<List<SpaceRoom>> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerSpaceService.topLevelJoinedSpaces()
.map {
it.let(spaceRoomMapper::map)
}
innerSpaceService
.topLevelJoinedSpaces()
.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 {
val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this")
return RustSpaceRoomList(

View File

@@ -23,6 +23,8 @@ class FakeSpaceService(
private val joinedSpacesResult: () -> Result<List<SpaceRoom>> = { lambdaError() },
private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() },
private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() },
private val joinedParentsResult: (RoomId) -> Result<List<SpaceRoom>> = { lambdaError() },
private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() },
) : SpaceService {
private val _spaceRoomsFlow = MutableSharedFlow<List<SpaceRoom>>()
override val spaceRoomsFlow: SharedFlow<List<SpaceRoom>>
@@ -36,6 +38,14 @@ class FakeSpaceService(
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 {
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_remove_accessibility_label">"Kustuta: %1$s"</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_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>

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_remove_accessibility_label">"Supprimer %1$s"</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_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>

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_remove_accessibility_label">"Ukloni %1$s"</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_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>

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_remove_accessibility_label">"Remover %1$s"</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_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>

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_remove_accessibility_label">"Ștergeți %1$s"</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_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>

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_remove_accessibility_label">"Odstrániť %1$s"</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_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>

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_remove_accessibility_label">"Remove %1$s"</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_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>

View File

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