feature(security&privacy): support KnockRestricted join rule

This commit is contained in:
ganfra
2026-01-07 17:05:09 +01:00
parent 75ab791629
commit 92acf1edea
9 changed files with 128 additions and 20 deletions

View File

@@ -19,6 +19,7 @@ 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.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.activeElement
import com.bumble.appyx.navmodel.backstack.operation.pop
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
@@ -66,7 +67,7 @@ class SecurityAndPrivacyFlowNode(
data object EditRoomAddress : NavTarget
@Parcelize
data object ManageAuthorizedSpaces: NavTarget
data class ManageAuthorizedSpaces(val forKnockRestricted: Boolean = false) : NavTarget
}
private val callback: SecurityAndPrivacyEntryPoint.Callback = callback()
@@ -94,9 +95,10 @@ class SecurityAndPrivacyFlowNode(
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)
securityAndPrivacyNode.onAuthorizedSpacesSelected(selectedSpaces, forKnock = forKnock)
}
}
}
@@ -110,7 +112,7 @@ class SecurityAndPrivacyFlowNode(
NavTarget.EditRoomAddress -> {
createNode<EditRoomAddressNode>(buildContext, plugins = listOf(navigator))
}
NavTarget.ManageAuthorizedSpaces -> {
is NavTarget.ManageAuthorizedSpaces -> {
createNode<ManageAuthorizedSpacesNode>(buildContext, plugins = listOf(navigator))
}
}

View File

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

View File

@@ -17,6 +17,8 @@ sealed interface 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

@@ -50,10 +50,13 @@ class SecurityAndPrivacyNode(
return stateFlow.value.getAuthorizedSpacesSelection()
}
fun onAuthorizedSpacesSelected(selectedSpaces: ImmutableList<RoomId>) {
stateFlow.value.eventSink(
SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.SpaceMember(selectedSpaces))
)
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

View File

@@ -200,6 +200,10 @@ class SecurityAndPrivacyPresenter(
spaceIds = editedSettings.roomAccess.spaceIds(),
editedAccess = editedRoomAccess,
)
SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess -> handleAskToJoinWithSpaceMembersAccessSelection(
spaceSelectionMode = spaceSelectionMode,
editedAccess = editedRoomAccess,
)
}
}
@@ -254,7 +258,7 @@ class SecurityAndPrivacyPresenter(
}
when (spaceSelectionMode) {
is SpaceSelectionMode.None -> Unit
is SpaceSelectionMode.Multiple -> navigator.openManageAuthorizedSpaces()
is SpaceSelectionMode.Multiple -> navigator.openManageAuthorizedSpaces(forKnockRestricted = false)
is SpaceSelectionMode.Single -> {
val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(
spaceIds = persistentListOf(spaceSelectionMode.spaceId)
@@ -264,6 +268,25 @@ class SecurityAndPrivacyPresenter(
}
}
private fun handleAskToJoinWithSpaceMembersAccessSelection(
spaceSelectionMode: SpaceSelectionMode,
editedAccess: MutableState<SecurityAndPrivacyRoomAccess>,
) {
if (editedAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember) {
return
}
when (spaceSelectionMode) {
is SpaceSelectionMode.None -> Unit
is SpaceSelectionMode.Multiple -> navigator.openManageAuthorizedSpaces(forKnockRestricted = true)
is SpaceSelectionMode.Single -> {
val newRoomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(
spaceIds = persistentListOf(spaceSelectionMode.spaceId)
)
editedAccess.value = newRoomAccess
}
}
}
private fun getSpaceSelectionMode(
selectableJoinedSpaces: Set<SpaceRoom>,
savedAccess: SecurityAndPrivacyRoomAccess,
@@ -328,6 +351,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
}
@@ -365,7 +389,13 @@ class SecurityAndPrivacyPresenter(
private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess {
return when (this) {
JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone
JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin
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>()
@@ -388,6 +418,9 @@ private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? {
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

@@ -46,14 +46,25 @@ data class SecurityAndPrivacyState(
// - 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 showManageSpaceAction = spaceSelectionMode is SpaceSelectionMode.Multiple && editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember
val showManageSpaceFooter = spaceSelectionMode is SpaceSelectionMode.Multiple &&
(editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember ||
editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember)
val isAskToJoinSelectable = isKnockEnabled
// Show Ask to join option in two cases:
// - the Knock FF is enabled
// - AskToJoin is the current saved value
val showAskToJoinOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.AskToJoin || isAskToJoinSelectable
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
@@ -94,6 +105,22 @@ data class SecurityAndPrivacyState(
}
}
@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)
}
}
fun getAuthorizedSpacesSelection(): AuthorizedSpacesSelection {
return AuthorizedSpacesSelection(
joinedSpaces = selectableJoinedSpaces.toImmutableList(),
@@ -102,6 +129,7 @@ data class SecurityAndPrivacyState(
}.toImmutableList(),
initialSelectedIds = when (editedSettings.roomAccess) {
is SecurityAndPrivacyRoomAccess.SpaceMember -> editedSettings.roomAccess.spaceIds
is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember -> editedSettings.roomAccess.spaceIds
else -> savedSettings.roomAccess.spaceIds()
}
)
@@ -145,17 +173,19 @@ sealed interface 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, is SpaceMember -> false
AskToJoin, Anyone -> true
AskToJoin, Anyone, is AskToJoinWithSpaceMember -> true
}
}
fun spaceIds(): ImmutableList<RoomId> {
return when (this) {
is SpaceMember -> spaceIds
is AskToJoinWithSpaceMember -> spaceIds
else -> persistentListOf()
}
}

View File

@@ -69,6 +69,19 @@ private fun commonSecurityAndPrivacyStates(isSpace: Boolean): Sequence<SecurityA
isSpace = isSpace,
isKnockEnabled = false,
),
aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(persistentListOf()),
),
isSpace = isSpace,
),
aSecurityAndPrivacyState(
savedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(persistentListOf())
),
isSpace = isSpace,
isKnockEnabled = false,
),
aSecurityAndPrivacyState(
editedSettings = aSecurityAndPrivacySettings(
roomAccess = SecurityAndPrivacyRoomAccess.Anyone,

View File

@@ -220,6 +220,10 @@ private fun RoomAccessSection(
state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess)
}
fun onAskToJoinWithSpaceMembersClick() {
state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess)
}
fun onManageSpacesClick() {
state.eventSink(SecurityAndPrivacyEvent.ManageAuthorizedSpaces)
}
@@ -235,7 +239,7 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) },
)
if (state.showSpaceMemberOption)
if (state.showSpaceMemberOption) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) },
supportingContent = {
@@ -246,6 +250,7 @@ private fun RoomAccessSection(
onClick = ::onSpaceMemberAccessClick,
enabled = state.isSpaceMemberSelectable,
)
}
if (state.showAskToJoinOption) {
ListItem(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) },
@@ -256,6 +261,16 @@ private fun RoomAccessSection(
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(
headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_title)) },
supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_invite_only_option_description)) },
@@ -263,7 +278,7 @@ private fun RoomAccessSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) },
)
if (state.showManageSpaceAction) {
if (state.showManageSpaceFooter) {
val footerText = stringWithLink(
textRes = R.string.screen_security_and_privacy_room_access_footer,
url = stringResource(R.string.screen_security_and_privacy_room_access_footer_manage_spaces_action),

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: (Boolean) -> 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(forKnockRestricted: Boolean) {
openManageAuthorizedSpacesLambda(forKnockRestricted)
}
override fun closeManageAuthorizedSpaces() {
closeManageAuthorizedSpacesLambda()
}
}