Refactor space selection to use SpaceSelectionStateHolder

Move authorized space selection state to a shared StateHolder scoped
to RoomScope. This simplifies communication between SecurityAndPrivacy
and ManageAuthorizedSpaces nodes by replacing the complex coroutine-based
parent-child coordination with a reactive state flow pattern.
This commit is contained in:
ganfra 2026-01-09 12:00:54 +01:00
parent c66f8c8c34
commit 993cf838a0
17 changed files with 283 additions and 278 deletions

View file

@ -13,14 +13,12 @@ import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.activeElement
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
@ -36,12 +34,10 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.powerlevels.use import io.element.android.libraries.matrix.api.room.powerlevels.use
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@ -66,7 +62,7 @@ class SecurityAndPrivacyFlowNode(
data object EditRoomAddress : NavTarget data object EditRoomAddress : NavTarget
@Parcelize @Parcelize
data class ManageAuthorizedSpaces(val forKnockRestricted: Boolean = false) : NavTarget data object ManageAuthorizedSpaces : NavTarget
} }
private val callback: SecurityAndPrivacyEntryPoint.Callback = callback() private val callback: SecurityAndPrivacyEntryPoint.Callback = callback()
@ -90,21 +86,6 @@ class SecurityAndPrivacyFlowNode(
callback.onDone() callback.onDone()
} }
} }
whenChildrenAttached {
commonLifecycle: Lifecycle,
securityAndPrivacyNode: SecurityAndPrivacyNode,
manageAuthorizedSpacesNode: ManageAuthorizedSpacesNode
->
commonLifecycle.coroutineScope.launch {
val authorizedSpacesData = securityAndPrivacyNode.getAuthorizedSpacesData()
val selectedSpaces = manageAuthorizedSpacesNode.waitForCompletion(authorizedSpacesData)
val forKnock = (backstack.activeElement as? NavTarget.ManageAuthorizedSpaces)?.forKnockRestricted ?: false
withContext(NonCancellable) {
navigator.closeManageAuthorizedSpaces()
securityAndPrivacyNode.onAuthorizedSpacesSelected(selectedSpaces, forKnock = forKnock)
}
}
}
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@ -115,7 +96,7 @@ class SecurityAndPrivacyFlowNode(
NavTarget.EditRoomAddress -> { NavTarget.EditRoomAddress -> {
createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator)) createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator))
} }
is NavTarget.ManageAuthorizedSpaces -> { NavTarget.ManageAuthorizedSpaces -> {
createNode<ManageAuthorizedSpacesNode>(buildContext, plugins = listOf(navigator)) createNode<ManageAuthorizedSpacesNode>(buildContext, plugins = listOf(navigator))
} }
} }

View file

@ -18,7 +18,7 @@ interface SecurityAndPrivacyNavigator : Plugin {
fun onDone() fun onDone()
fun openEditRoomAddress() fun openEditRoomAddress()
fun closeEditRoomAddress() fun closeEditRoomAddress()
fun openManageAuthorizedSpaces(forKnockRestricted: Boolean) fun openManageAuthorizedSpaces()
fun closeManageAuthorizedSpaces() fun closeManageAuthorizedSpaces()
} }
@ -38,8 +38,8 @@ class BackstackSecurityAndPrivacyNavigator(
backStack.pop() backStack.pop()
} }
override fun openManageAuthorizedSpaces(forKnockRestricted: Boolean) { override fun openManageAuthorizedSpaces() {
backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces(forKnockRestricted)) backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces)
} }
override fun closeManageAuthorizedSpaces() { override fun closeManageAuthorizedSpaces() {

View file

@ -11,7 +11,7 @@ package io.element.android.features.securityandprivacy.impl.manageauthorizedspac
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
sealed interface ManageAuthorizedSpacesEvent { sealed interface ManageAuthorizedSpacesEvent {
data class SetData(val data: AuthorizedSpacesSelection) : ManageAuthorizedSpacesEvent data object Cancel : ManageAuthorizedSpacesEvent
data object Done : ManageAuthorizedSpacesEvent data object Done : ManageAuthorizedSpacesEvent
data class ToggleSpace(val roomId: RoomId) : ManageAuthorizedSpacesEvent data class ToggleSpace(val roomId: RoomId) : ManageAuthorizedSpacesEvent
} }

View file

@ -19,12 +19,8 @@ import com.bumble.appyx.core.plugin.plugins
import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.first
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@ -33,20 +29,13 @@ class ManageAuthorizedSpacesNode(
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
presenter: ManageAuthorizedSpacesPresenter, presenter: ManageAuthorizedSpacesPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val navigator = plugins<SecurityAndPrivacyNavigator>().first()
private val stateFlow = launchMolecule { presenter.present() } private val stateFlow = launchMolecule { presenter.present() }
suspend fun waitForCompletion(data: AuthorizedSpacesSelection): ImmutableList<RoomId> {
stateFlow.value.eventSink(ManageAuthorizedSpacesEvent.SetData(data))
return stateFlow.first { it.isSelectionComplete }.selectedIds
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state by stateFlow.collectAsState() val state by stateFlow.collectAsState()
ManageAuthorizedSpacesView( ManageAuthorizedSpacesView(
state = state, state = state,
onBackClick = { navigator.closeManageAuthorizedSpaces() },
modifier = modifier modifier = modifier
) )
} }

View file

@ -9,46 +9,43 @@
package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@Inject @Inject
class ManageAuthorizedSpacesPresenter : Presenter<ManageAuthorizedSpacesState> { class ManageAuthorizedSpacesPresenter(
private val spaceSelectionStateHolder: SpaceSelectionStateHolder,
) : Presenter<ManageAuthorizedSpacesState> {
@Composable @Composable
override fun present(): ManageAuthorizedSpacesState { override fun present(): ManageAuthorizedSpacesState {
var selectedIds: ImmutableList<RoomId> by remember { mutableStateOf(persistentListOf()) } val spaceSelectionState by spaceSelectionStateHolder.state.collectAsState()
var spacesSelection by remember { mutableStateOf(AuthorizedSpacesSelection()) }
var isSelectionComplete by remember { mutableStateOf(false) }
fun handleEvent(event: ManageAuthorizedSpacesEvent) { fun handleEvent(event: ManageAuthorizedSpacesEvent) {
when (event) { when (event) {
ManageAuthorizedSpacesEvent.Done -> isSelectionComplete = true
is ManageAuthorizedSpacesEvent.ToggleSpace -> { is ManageAuthorizedSpacesEvent.ToggleSpace -> {
selectedIds = if (selectedIds.contains(event.roomId)) { val currentSelectedIds = spaceSelectionState.selectedSpaceIds
selectedIds.minus(event.roomId).toImmutableList() val newSelectedIds = if (currentSelectedIds.contains(event.roomId)) {
currentSelectedIds.minus(event.roomId).toImmutableList()
} else { } else {
selectedIds.plus(event.roomId).toImmutableList() currentSelectedIds.plus(event.roomId).toImmutableList()
} }
spaceSelectionStateHolder.updateSelectedSpaceIds(newSelectedIds)
} }
is ManageAuthorizedSpacesEvent.SetData -> { ManageAuthorizedSpacesEvent.Done -> {
spacesSelection = event.data spaceSelectionStateHolder.setCompletion(SpaceSelectionState.Completion.Completed)
selectedIds = event.data.initialSelectedIds }
ManageAuthorizedSpacesEvent.Cancel -> {
spaceSelectionStateHolder.setCompletion(SpaceSelectionState.Completion.Cancelled)
} }
} }
} }
return ManageAuthorizedSpacesState( return ManageAuthorizedSpacesState(
selection = spacesSelection, selectableSpaces = spaceSelectionState.selectableSpaces,
selectedIds = selectedIds, unknownSpaceIds = spaceSelectionState.unknownSpaceIds,
isSelectionComplete = isSelectionComplete, selectedIds = spaceSelectionState.selectedSpaceIds,
eventSink = ::handleEvent, eventSink = ::handleEvent,
) )
} }

View file

@ -11,19 +11,13 @@ package io.element.android.features.securityandprivacy.impl.manageauthorizedspac
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.ImmutableSet
data class ManageAuthorizedSpacesState( data class ManageAuthorizedSpacesState(
val selection: AuthorizedSpacesSelection, val selectableSpaces: ImmutableSet<SpaceRoom>,
val unknownSpaceIds: ImmutableList<RoomId>,
val selectedIds: ImmutableList<RoomId>, val selectedIds: ImmutableList<RoomId>,
val isSelectionComplete: Boolean,
val eventSink: (ManageAuthorizedSpacesEvent) -> Unit val eventSink: (ManageAuthorizedSpacesEvent) -> Unit
) { ) {
val isDoneButtonEnabled = selectedIds.isNotEmpty() val isDoneButtonEnabled = selectedIds.isNotEmpty()
} }
data class AuthorizedSpacesSelection(
val joinedSpaces: ImmutableList<SpaceRoom> = persistentListOf(),
val unknownSpaceIds: ImmutableList<RoomId> = persistentListOf(),
val initialSelectedIds: ImmutableList<RoomId> = persistentListOf()
)

View file

@ -14,21 +14,17 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoom
import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
open class ManageAuthorizedSpacesStateProvider : PreviewParameterProvider<ManageAuthorizedSpacesState> { open class ManageAuthorizedSpacesStateProvider : PreviewParameterProvider<ManageAuthorizedSpacesState> {
override val values: Sequence<ManageAuthorizedSpacesState> override val values: Sequence<ManageAuthorizedSpacesState>
get() = sequenceOf( get() = sequenceOf(
aManageAuthorizedSpacesState(), aManageAuthorizedSpacesState(),
aManageAuthorizedSpacesState( aManageAuthorizedSpacesState(
authorizedSpacesSelection = anAuthorizedSpaceSelection( unknownSpaceIds = listOf(aRoomId(99))
unknownSpaceIds = listOf(aRoomId(99))
)
), ),
aManageAuthorizedSpacesState( aManageAuthorizedSpacesState(
selectedIds = listOf(aRoomId(1), aRoomId(3)), selectedIds = listOf(aRoomId(1), aRoomId(3)),
authorizedSpacesSelection = anAuthorizedSpaceSelection(
initialSelectedIds = listOf(aRoomId(1)),
),
), ),
) )
} }
@ -49,23 +45,14 @@ private fun aSpaceRoomList(count: Int): List<SpaceRoom> {
} }
} }
fun anAuthorizedSpaceSelection( fun aManageAuthorizedSpacesState(
joinedSpaces: List<SpaceRoom> = aSpaceRoomList(5), selectableSpaces: List<SpaceRoom> = aSpaceRoomList(5),
unknownSpaceIds: List<RoomId> = emptyList(), unknownSpaceIds: List<RoomId> = emptyList(),
initialSelectedIds: List<RoomId> = emptyList(),
) = AuthorizedSpacesSelection(
joinedSpaces = joinedSpaces.toImmutableList(),
unknownSpaceIds = unknownSpaceIds.toImmutableList(),
initialSelectedIds = initialSelectedIds.toImmutableList(),
)
private fun aManageAuthorizedSpacesState(
authorizedSpacesSelection: AuthorizedSpacesSelection = anAuthorizedSpaceSelection(),
selectedIds: List<RoomId> = emptyList(), selectedIds: List<RoomId> = emptyList(),
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {}, eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {},
) = ManageAuthorizedSpacesState( ) = ManageAuthorizedSpacesState(
selection = authorizedSpacesSelection, selectableSpaces = selectableSpaces.toImmutableSet(),
unknownSpaceIds = unknownSpaceIds.toImmutableList(),
selectedIds = selectedIds.toImmutableList(), selectedIds = selectedIds.toImmutableList(),
isSelectionComplete = false,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -8,6 +8,7 @@
package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces package io.element.android.features.securityandprivacy.impl.manageauthorizedspaces
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
@ -42,17 +43,24 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun ManageAuthorizedSpacesView( fun ManageAuthorizedSpacesView(
state: ManageAuthorizedSpacesState, state: ManageAuthorizedSpacesState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
fun onCancel() {
state.eventSink(ManageAuthorizedSpacesEvent.Cancel)
}
fun onDone() {
state.eventSink(ManageAuthorizedSpacesEvent.Done)
}
BackHandler(onBack = ::onCancel)
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
ManageAuthorizedSpacesTopBar( ManageAuthorizedSpacesTopBar(
onBackClick = onBackClick, onBackClick = ::onCancel,
onDoneClick = { onDoneClick = ::onDone,
state.eventSink(ManageAuthorizedSpacesEvent.Done)
},
isDoneButtonEnabled = state.isDoneButtonEnabled isDoneButtonEnabled = state.isDoneButtonEnabled
) )
} }
@ -67,7 +75,7 @@ fun ManageAuthorizedSpacesView(
hasDivider = false, hasDivider = false,
) )
} }
items(items = state.selection.joinedSpaces) { space -> items(items = state.selectableSpaces.toList()) { space ->
CheckableSpaceListItem( CheckableSpaceListItem(
headlineText = space.displayName, headlineText = space.displayName,
supportingText = space.canonicalAlias?.value, supportingText = space.canonicalAlias?.value,
@ -80,14 +88,14 @@ fun ManageAuthorizedSpacesView(
} }
) )
} }
if (state.selection.unknownSpaceIds.isNotEmpty()) { if (state.unknownSpaceIds.isNotEmpty()) {
item { item {
ListSectionHeader( ListSectionHeader(
title = stringResource(R.string.screen_manage_authorized_spaces_unknown_spaces_section_title), title = stringResource(R.string.screen_manage_authorized_spaces_unknown_spaces_section_title),
hasDivider = true, hasDivider = true,
) )
} }
items(items = state.selection.unknownSpaceIds) { items(items = state.unknownSpaceIds) {
CheckableSpaceListItem( CheckableSpaceListItem(
headlineText = stringResource(R.string.screen_manage_authorized_spaces_unknown_space), headlineText = stringResource(R.string.screen_manage_authorized_spaces_unknown_space),
supportingText = it.value, supportingText = it.value,
@ -185,8 +193,5 @@ private fun ManageAuthorizedSpacesTopBar(
internal fun ManageAuthorizedSpacesViewPreview( internal fun ManageAuthorizedSpacesViewPreview(
@PreviewParameter(ManageAuthorizedSpacesStateProvider::class) state: ManageAuthorizedSpacesState @PreviewParameter(ManageAuthorizedSpacesStateProvider::class) state: ManageAuthorizedSpacesState
) = ElementPreview { ) = ElementPreview {
ManageAuthorizedSpacesView( ManageAuthorizedSpacesView(state = state)
state = state,
onBackClick = {},
)
} }

View file

@ -0,0 +1,64 @@
/*
* 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 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

@ -23,12 +23,9 @@ import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.AuthorizedSpacesSelection
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.architecture.appyx.launchMolecule
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList
@ContributesNode(RoomScope::class) @ContributesNode(RoomScope::class)
@AssistedInject @AssistedInject
@ -46,19 +43,6 @@ class SecurityAndPrivacyNode(
activity.openUrlInChromeCustomTab(null, darkTheme, url) activity.openUrlInChromeCustomTab(null, darkTheme, url)
} }
fun getAuthorizedSpacesData(): AuthorizedSpacesSelection {
return stateFlow.value.getAuthorizedSpacesSelection()
}
fun onAuthorizedSpacesSelected(selectedSpaces: ImmutableList<RoomId>, forKnock: Boolean) {
val roomAccess = if (forKnock) {
SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(selectedSpaces)
} else {
SecurityAndPrivacyRoomAccess.SpaceMember(selectedSpaces)
}
stateFlow.value.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(roomAccess))
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current) val activity = requireNotNull(LocalActivity.current)

View file

@ -26,6 +26,8 @@ import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPerm
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionState
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionStateHolder
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -51,18 +53,22 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@AssistedInject @AssistedInject
class SecurityAndPrivacyPresenter( class SecurityAndPrivacyPresenter(
@Assisted private val navigator: SecurityAndPrivacyNavigator, @Assisted private val navigator: SecurityAndPrivacyNavigator,
private val spaceSelectionStateHolder: SpaceSelectionStateHolder,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val room: JoinedRoom, private val room: JoinedRoom,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
) : Presenter<SecurityAndPrivacyState> { ) : Presenter<SecurityAndPrivacyState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
fun create(navigator: SecurityAndPrivacyNavigator): SecurityAndPrivacyPresenter fun create(
navigator: SecurityAndPrivacyNavigator,
): SecurityAndPrivacyPresenter
} }
@Composable @Composable
@ -136,6 +142,18 @@ class SecurityAndPrivacyPresenter(
} }
} }
LaunchedEffect(selectableJoinedSpaces, savedSettings.roomAccess) {
val unknownSpaceIds = savedSettings.roomAccess.spaceIds().filter { spaceId ->
selectableJoinedSpaces.none { it.roomId == spaceId }
}.toImmutableList()
spaceSelectionStateHolder.update { state ->
state.copy(
selectableSpaces = selectableJoinedSpaces,
unknownSpaceIds = unknownSpaceIds,
)
}
}
var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) }
val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms -> val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms ->
perms.securityAndPrivacyPermissions() perms.securityAndPrivacyPermissions()
@ -191,19 +209,27 @@ class SecurityAndPrivacyPresenter(
SecurityAndPrivacyEvent.DismissExitConfirmation -> { SecurityAndPrivacyEvent.DismissExitConfirmation -> {
saveAction.value = AsyncAction.Uninitialized saveAction.value = AsyncAction.Uninitialized
} }
SecurityAndPrivacyEvent.ManageAuthorizedSpaces -> { SecurityAndPrivacyEvent.ManageAuthorizedSpaces -> coroutineScope.launch {
navigator.openManageAuthorizedSpaces( handleMultipleSelection(
savedAccess = savedSettings.roomAccess,
editedRoomAccess = editedRoomAccess,
forKnockRestricted = editedRoomAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember forKnockRestricted = editedRoomAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember
) )
} }
SecurityAndPrivacyEvent.SelectSpaceMemberAccess -> handleSpaceMemberAccessSelection( SecurityAndPrivacyEvent.SelectSpaceMemberAccess -> coroutineScope.launch {
spaceSelectionMode = spaceSelectionMode, handleSpaceMemberAccessSelection(
editedAccess = editedRoomAccess, spaceSelectionMode = spaceSelectionMode,
) savedAccess = savedSettings.roomAccess,
SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess -> handleAskToJoinWithSpaceMembersAccessSelection( editedAccess = editedRoomAccess,
spaceSelectionMode = spaceSelectionMode, )
editedAccess = editedRoomAccess, }
) SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess -> coroutineScope.launch {
handleAskToJoinWithSpaceMembersAccessSelection(
spaceSelectionMode = spaceSelectionMode,
savedAccess = savedSettings.roomAccess,
editedAccess = editedRoomAccess,
)
}
} }
} }
@ -248,8 +274,9 @@ class SecurityAndPrivacyPresenter(
return state return state
} }
private fun handleSpaceMemberAccessSelection( private suspend fun handleSpaceMemberAccessSelection(
spaceSelectionMode: SpaceSelectionMode, spaceSelectionMode: SpaceSelectionMode,
savedAccess: SecurityAndPrivacyRoomAccess,
editedAccess: MutableState<SecurityAndPrivacyRoomAccess>, editedAccess: MutableState<SecurityAndPrivacyRoomAccess>,
) { ) {
if (editedAccess.value is SecurityAndPrivacyRoomAccess.SpaceMember) { if (editedAccess.value is SecurityAndPrivacyRoomAccess.SpaceMember) {
@ -257,7 +284,11 @@ class SecurityAndPrivacyPresenter(
} }
when (spaceSelectionMode) { when (spaceSelectionMode) {
is SpaceSelectionMode.None -> Unit is SpaceSelectionMode.None -> Unit
is SpaceSelectionMode.Multiple -> navigator.openManageAuthorizedSpaces(forKnockRestricted = false) is SpaceSelectionMode.Multiple -> handleMultipleSelection(
savedAccess = savedAccess,
editedRoomAccess = editedAccess,
forKnockRestricted = false,
)
is SpaceSelectionMode.Single -> { is SpaceSelectionMode.Single -> {
val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember( val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(
spaceIds = persistentListOf(spaceSelectionMode.spaceId) spaceIds = persistentListOf(spaceSelectionMode.spaceId)
@ -267,8 +298,9 @@ class SecurityAndPrivacyPresenter(
} }
} }
private fun handleAskToJoinWithSpaceMembersAccessSelection( private suspend fun handleAskToJoinWithSpaceMembersAccessSelection(
spaceSelectionMode: SpaceSelectionMode, spaceSelectionMode: SpaceSelectionMode,
savedAccess: SecurityAndPrivacyRoomAccess,
editedAccess: MutableState<SecurityAndPrivacyRoomAccess>, editedAccess: MutableState<SecurityAndPrivacyRoomAccess>,
) { ) {
if (editedAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember) { if (editedAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember) {
@ -276,7 +308,11 @@ class SecurityAndPrivacyPresenter(
} }
when (spaceSelectionMode) { when (spaceSelectionMode) {
is SpaceSelectionMode.None -> Unit is SpaceSelectionMode.None -> Unit
is SpaceSelectionMode.Multiple -> navigator.openManageAuthorizedSpaces(forKnockRestricted = true) is SpaceSelectionMode.Multiple -> handleMultipleSelection(
savedAccess = savedAccess,
editedRoomAccess = editedAccess,
forKnockRestricted = true,
)
is SpaceSelectionMode.Single -> { is SpaceSelectionMode.Single -> {
val newRoomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember( val newRoomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(
spaceIds = persistentListOf(spaceSelectionMode.spaceId) spaceIds = persistentListOf(spaceSelectionMode.spaceId)
@ -286,6 +322,38 @@ class SecurityAndPrivacyPresenter(
} }
} }
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( private fun getSpaceSelectionMode(
selectableJoinedSpaces: Set<SpaceRoom>, selectableJoinedSpaces: Set<SpaceRoom>,
savedAccess: SecurityAndPrivacyRoomAccess, savedAccess: SecurityAndPrivacyRoomAccess,

View file

@ -12,7 +12,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.R import io.element.android.features.securityandprivacy.impl.R
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.AuthorizedSpacesSelection
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -121,20 +120,6 @@ data class SecurityAndPrivacyState(
stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description) stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)
} }
} }
fun getAuthorizedSpacesSelection(): AuthorizedSpacesSelection {
return AuthorizedSpacesSelection(
joinedSpaces = selectableJoinedSpaces.toImmutableList(),
unknownSpaceIds = savedSettings.roomAccess.spaceIds().filter { spaceId ->
selectableJoinedSpaces.none { it.roomId == spaceId }
}.toImmutableList(),
initialSelectedIds = when (editedSettings.roomAccess) {
is SecurityAndPrivacyRoomAccess.SpaceMember -> editedSettings.roomAccess.spaceIds
is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember -> editedSettings.roomAccess.spaceIds
else -> savedSettings.roomAccess.spaceIds()
}
)
}
} }
data class SecurityAndPrivacySettings( data class SecurityAndPrivacySettings(

View file

@ -14,7 +14,7 @@ class FakeSecurityAndPrivacyNavigator(
private val onDoneLambda: () -> Unit = { lambdaError() }, private val onDoneLambda: () -> Unit = { lambdaError() },
private val openEditRoomAddressLambda: () -> Unit = { lambdaError() }, private val openEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() }, private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() },
private val openManageAuthorizedSpacesLambda: (Boolean) -> Unit = { lambdaError() }, private val openManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() },
private val closeManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() }, private val closeManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() },
) : SecurityAndPrivacyNavigator { ) : SecurityAndPrivacyNavigator {
override fun onDone() { override fun onDone() {
@ -29,8 +29,8 @@ class FakeSecurityAndPrivacyNavigator(
closeEditRoomAddressLambda() closeEditRoomAddressLambda()
} }
override fun openManageAuthorizedSpaces(forKnockRestricted: Boolean) { override fun openManageAuthorizedSpaces() {
openManageAuthorizedSpacesLambda(forKnockRestricted) openManageAuthorizedSpacesLambda()
} }
override fun closeManageAuthorizedSpaces() { override fun closeManageAuthorizedSpaces() {

View file

@ -49,27 +49,16 @@ class SecurityAndPrivacyFlowNodeTest {
} }
@Test @Test
fun `openManageAuthorizedSpaces navigates with forKnockRestricted false`() = runTest { fun `openManageAuthorizedSpaces navigates to ManageAuthorizedSpaces`() = runTest {
val flowNode = createFlowNode() val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces(forKnockRestricted = false) flowNode.navigator.openManageAuthorizedSpaces()
assertThat(flowNode.currentNavTarget()).isEqualTo( assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces)
SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces(forKnockRestricted = false)
)
}
@Test
fun `openManageAuthorizedSpaces navigates with forKnockRestricted true`() = runTest {
val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces(forKnockRestricted = true)
assertThat(flowNode.currentNavTarget()).isEqualTo(
SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces(forKnockRestricted = true)
)
} }
@Test @Test
fun `closeManageAuthorizedSpaces pops backstack`() = runTest { fun `closeManageAuthorizedSpaces pops backstack`() = runTest {
val flowNode = createFlowNode() val flowNode = createFlowNode()
flowNode.navigator.openManageAuthorizedSpaces(forKnockRestricted = false) flowNode.navigator.openManageAuthorizedSpaces()
assertThat(flowNode.currentNavTarget()) assertThat(flowNode.currentNavTarget())
.isInstanceOf(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces::class.java) .isInstanceOf(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces::class.java)
flowNode.navigator.closeManageAuthorizedSpaces() flowNode.navigator.closeManageAuthorizedSpaces()

View file

@ -12,38 +12,33 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class ManageAuthorizedSpacesPresenterTest { class ManageAuthorizedSpacesPresenterTest {
@Test @Test
fun `present - initial state has empty selection`() = runTest { fun `present - initial state reflects shared state`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter() val sharedStateHolder = SpaceSelectionStateHolder()
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test { presenter.test {
with(awaitItem()) { with(awaitItem()) {
assertThat(selectedIds).isEmpty() assertThat(selectedIds).isEmpty()
assertThat(isSelectionComplete).isFalse()
assertThat(isDoneButtonEnabled).isFalse() assertThat(isDoneButtonEnabled).isFalse()
} }
} }
} }
@Test @Test
fun `present - SetData event updates selection and initial selectedIds`() = runTest { fun `present - state reflects shared state with pre-selected spaces`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter() val sharedStateHolder = SpaceSelectionStateHolder()
val roomId = A_ROOM_ID
sharedStateHolder.update {
it.copy(selectedSpaceIds = persistentListOf(roomId))
}
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test { presenter.test {
val initialState = awaitItem()
val roomId = A_ROOM_ID
val data = AuthorizedSpacesSelection(
joinedSpaces = persistentListOf(),
unknownSpaceIds = persistentListOf(),
initialSelectedIds = persistentListOf(roomId)
)
initialState.eventSink(ManageAuthorizedSpacesEvent.SetData(data))
// SetData updates two state variables, which may emit intermediate states
skipItems(1)
with(awaitItem()) { with(awaitItem()) {
assertThat(selection).isEqualTo(data)
assertThat(selectedIds).containsExactly(roomId) assertThat(selectedIds).containsExactly(roomId)
assertThat(isDoneButtonEnabled).isTrue() assertThat(isDoneButtonEnabled).isTrue()
} }
@ -51,8 +46,9 @@ class ManageAuthorizedSpacesPresenterTest {
} }
@Test @Test
fun `present - ToggleSpace event adds space to selectedIds`() = runTest { fun `present - ToggleSpace event adds space to selectedIds in shared state`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter() val sharedStateHolder = SpaceSelectionStateHolder()
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
val roomId = A_ROOM_ID val roomId = A_ROOM_ID
@ -61,34 +57,69 @@ class ManageAuthorizedSpacesPresenterTest {
assertThat(selectedIds).containsExactly(roomId) assertThat(selectedIds).containsExactly(roomId)
assertThat(isDoneButtonEnabled).isTrue() assertThat(isDoneButtonEnabled).isTrue()
} }
// Verify the shared state is also updated
assertThat(sharedStateHolder.state.value.selectedSpaceIds).containsExactly(roomId)
} }
} }
@Test @Test
fun `present - ToggleSpace event removes space when already selected`() = runTest { fun `present - ToggleSpace event removes space when already selected`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter() val sharedStateHolder = SpaceSelectionStateHolder()
sharedStateHolder.updateSelectedSpaceIds(persistentListOf(A_ROOM_ID))
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
val roomId = A_ROOM_ID assertThat(initialState.selectedIds).containsExactly(A_ROOM_ID)
initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(roomId)) initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(A_ROOM_ID))
val stateWithSelection = awaitItem()
assertThat(stateWithSelection.selectedIds).containsExactly(roomId)
stateWithSelection.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(roomId))
with(awaitItem()) { with(awaitItem()) {
assertThat(selectedIds).isEmpty() assertThat(selectedIds).isEmpty()
assertThat(isDoneButtonEnabled).isFalse() assertThat(isDoneButtonEnabled).isFalse()
} }
// Verify the shared state is also updated
assertThat(sharedStateHolder.state.value.selectedSpaceIds).isEmpty()
} }
} }
@Test @Test
fun `present - Done event sets isSelectionComplete to true`() = runTest { fun `present - Done event sets completion to Completed`() = runTest {
val presenter = ManageAuthorizedSpacesPresenter() val sharedStateHolder = SpaceSelectionStateHolder()
val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder)
presenter.test { presenter.test {
val initialState = awaitItem() val initialState = awaitItem()
initialState.eventSink(ManageAuthorizedSpacesEvent.Done) 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()) { with(awaitItem()) {
assertThat(isSelectionComplete).isTrue() assertThat(selectableSpaces).isEmpty()
assertThat(unknownSpaceIds).containsExactly(A_ROOM_ID)
} }
} }
} }

View file

@ -15,15 +15,15 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.RoomId 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.matrix.test.A_ROOM_ID
import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBack
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TestRule import org.junit.rules.TestRule
@ -34,11 +34,12 @@ class ManageAuthorizedSpacesViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>() @get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun `clicking back invokes callback`() { fun `clicking back emits Cancel event`() {
ensureCalledOnce { callback -> val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>()
rule.setManageAuthorizedSpacesView(onBackClick = callback) val state = aManageAuthorizedSpacesState(eventSink = recorder)
rule.pressBack() rule.setManageAuthorizedSpacesView(state)
} rule.pressBack()
recorder.assertSingle(ManageAuthorizedSpacesEvent.Cancel)
} }
@Test @Test
@ -47,9 +48,7 @@ class ManageAuthorizedSpacesViewTest {
val space = aSpaceRoom(roomId = roomId, displayName = "Test Space") val space = aSpaceRoom(roomId = roomId, displayName = "Test Space")
val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>() val recorder = EventsRecorder<ManageAuthorizedSpacesEvent>()
val state = aManageAuthorizedSpacesState( val state = aManageAuthorizedSpacesState(
selection = anAuthorizedSpaceSelection( selectableSpaces = listOf(space),
joinedSpaces = listOf(space)
),
eventSink = recorder eventSink = recorder
) )
rule.setManageAuthorizedSpacesView(state) rule.setManageAuthorizedSpacesView(state)
@ -86,24 +85,20 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setManag
state: ManageAuthorizedSpacesState = aManageAuthorizedSpacesState( state: ManageAuthorizedSpacesState = aManageAuthorizedSpacesState(
eventSink = EventsRecorder(expectEvents = false) eventSink = EventsRecorder(expectEvents = false)
), ),
onBackClick: () -> Unit = EnsureNeverCalled(),
) { ) {
setContent { setContent {
ManageAuthorizedSpacesView( ManageAuthorizedSpacesView(state = state)
state = state,
onBackClick = onBackClick,
)
} }
} }
private fun aManageAuthorizedSpacesState( private fun aManageAuthorizedSpacesState(
selection: AuthorizedSpacesSelection = AuthorizedSpacesSelection(), selectableSpaces: List<SpaceRoom> = emptyList(),
unknownSpaceIds: List<RoomId> = emptyList(),
selectedIds: List<RoomId> = emptyList(), selectedIds: List<RoomId> = emptyList(),
isSelectionComplete: Boolean = false,
eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {}, eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {},
) = ManageAuthorizedSpacesState( ) = ManageAuthorizedSpacesState(
selection = selection, selectableSpaces = selectableSpaces.toImmutableSet(),
unknownSpaceIds = unknownSpaceIds.toImmutableList(),
selectedIds = selectedIds.toImmutableList(), selectedIds = selectedIds.toImmutableList(),
isSelectionComplete = isSelectionComplete,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -10,6 +10,7 @@ package io.element.android.features.securityandprivacy.impl.root
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.securityandprivacy.impl.FakeSecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.FakeSecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionStateHolder
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -436,7 +437,7 @@ class SecurityAndPrivacyPresenterTest {
@Test @Test
fun `present - SelectSpaceMemberAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest { fun `present - SelectSpaceMemberAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest {
val openManageAuthorizedSpacesLambda = lambdaRecorder<Boolean, Unit> { } val openManageAuthorizedSpacesLambda = lambdaRecorder<Unit> { }
val navigator = val navigator =
FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda) FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda)
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
@ -471,7 +472,7 @@ class SecurityAndPrivacyPresenterTest {
val state = awaitItem() val state = awaitItem()
assertThat(state.isSpaceMemberSelectable).isTrue() assertThat(state.isSpaceMemberSelectable).isTrue()
state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
assert(openManageAuthorizedSpacesLambda).isCalledOnce().with(value(false)) assert(openManageAuthorizedSpacesLambda).isCalledOnce()
} }
} }
@ -605,7 +606,7 @@ class SecurityAndPrivacyPresenterTest {
@Test @Test
fun `present - SelectAskToJoinWithSpaceMembersAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest { fun `present - SelectAskToJoinWithSpaceMembersAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest {
val openManageAuthorizedSpacesLambda = lambdaRecorder<Boolean, Unit> { } val openManageAuthorizedSpacesLambda = lambdaRecorder<Unit> { }
val navigator = val navigator =
FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda) FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda)
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
@ -642,7 +643,7 @@ class SecurityAndPrivacyPresenterTest {
val state = awaitItem() val state = awaitItem()
assertThat(state.isAskToJoinWithSpaceMembersSelectable).isTrue() assertThat(state.isAskToJoinWithSpaceMembersSelectable).isTrue()
state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess) state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess)
assert(openManageAuthorizedSpacesLambda).isCalledOnce().with(value(true)) assert(openManageAuthorizedSpacesLambda).isCalledOnce()
} }
} }
@ -950,73 +951,6 @@ class SecurityAndPrivacyPresenterTest {
} }
} }
@Test
fun `present - getAuthorizedSpacesSelection returns correct data for SpaceMember`() = runTest {
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Restricted(
rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID))
)
)
)
)
val spaceRoom = aSpaceRoom(roomId = A_ROOM_ID)
val client = FakeMatrixClient(
userIdServerNameLambda = { "matrix.org" },
spaceService = FakeSpaceService(
joinedParentsResult = { _ -> Result.success(listOf(spaceRoom)) },
getSpaceRoomResult = { null }
)
)
val presenter = createSecurityAndPrivacyPresenter(
room = room,
matrixClient = client,
featureFlagService = FakeFeatureFlagService(
initialState = mapOf(FeatureFlags.SpaceSettings.key to true)
)
)
presenter.test {
skipItems(1)
with(awaitItem()) {
val selection = getAuthorizedSpacesSelection()
assertThat(selection.joinedSpaces).containsExactly(spaceRoom)
assertThat(selection.initialSelectedIds).containsExactly(A_ROOM_ID)
assertThat(selection.unknownSpaceIds).isEmpty()
}
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - getAuthorizedSpacesSelection identifies unknown space IDs`() = runTest {
val unknownSpaceId = RoomId("!unknown:matrix.org")
val room = FakeJoinedRoom(
baseRoom = FakeBaseRoom(
roomPermissions = roomPermissions(),
initialRoomInfo = aRoomInfo(
historyVisibility = RoomHistoryVisibility.Shared,
joinRule = JoinRule.Restricted(
rules = persistentListOf(AllowRule.RoomMembership(unknownSpaceId))
)
)
)
)
// No spaces available (the space in the join rule is unknown)
val presenter = createSecurityAndPrivacyPresenter(room = room)
presenter.test {
skipItems(1)
with(awaitItem()) {
val selection = getAuthorizedSpacesSelection()
assertThat(selection.joinedSpaces).isEmpty()
assertThat(selection.unknownSpaceIds).containsExactly(unknownSpaceId)
}
cancelAndIgnoreRemainingEvents()
}
}
@Test @Test
fun `present - SelectAskToJoinWithSpaceMembersAccess with single space auto-selects`() = runTest { fun `present - SelectAskToJoinWithSpaceMembersAccess with single space auto-selects`() = runTest {
val room = FakeJoinedRoom( val room = FakeJoinedRoom(
@ -1169,12 +1103,14 @@ class SecurityAndPrivacyPresenterTest {
getSpaceRoomResult = { null } getSpaceRoomResult = { null }
), ),
), ),
spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(),
): SecurityAndPrivacyPresenter { ): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter( return SecurityAndPrivacyPresenter(
room = room, room = room,
matrixClient = matrixClient, matrixClient = matrixClient,
navigator = navigator, navigator = navigator,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
spaceSelectionStateHolder = spaceSelectionStateHolder,
) )
} }
} }