From 84fdbbe573923d9a6881bf91f4b5a244efb27873 Mon Sep 17 00:00:00 2001 From: mxandreas Date: Mon, 5 Oct 2026 09:30:01 +0300 Subject: [PATCH] Add a hook to disable public rooms/spaces on UI. --- enterprise | 2 +- .../configureroom/ConfigureRoomPresenter.kt | 9 +++- .../impl/ConfigureRoomPresenterTest.kt | 43 +++++++++++++++++- .../api/SessionEnterpriseService.kt | 6 +++ .../impl/DefaultSessionEnterpriseService.kt | 1 + .../test/FakeSessionEnterpriseService.kt | 5 +++ .../impl/root/SecurityAndPrivacyPresenter.kt | 5 +++ .../impl/root/SecurityAndPrivacyState.kt | 6 +++ .../SecurityAndPrivacyStatePreviewParam.kt | 6 +++ .../impl/root/SecurityAndPrivacyView.kt | 16 ++++--- .../root/SecurityAndPrivacyPresenterTest.kt | 44 ++++++++++++++++++- 11 files changed, 130 insertions(+), 13 deletions(-) diff --git a/enterprise b/enterprise index 073abe3b44..d25d658acb 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit 073abe3b44598a072a87a9c92d15c6e5bc4a88af +Subproject commit d25d658acbee55e25d20a2f22f411040adfb078c diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt index 849647a9d3..86cd67546e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomPresenter.kt @@ -16,6 +16,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 @@ -92,6 +93,9 @@ class ConfigureRoomPresenter( val isKnockFeatureEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(initial = false) + val arePublicRoomsDisabled by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + } val roomAddressValidity = remember { mutableStateOf(RoomAddressValidity.Unknown) } @@ -145,12 +149,13 @@ class ConfigureRoomPresenter( // 1. If we are creating a space. // 2. If it has a parent space. // 3. If knocking is enabled. + // 4. If the homeserver forbids public rooms. val parentSpace = createRoomConfig.parentSpace - val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled) { + val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) { when { isSpace && parentSpace != null -> TODO("Adding a space to a parent space is not supported yet! How did you get here?") parentSpace == null || parentSpace.joinRule == JoinRule.Public -> listOfNotNull( - JoinRuleItem.PublicVisibility.Public, + JoinRuleItem.PublicVisibility.Public.takeIf { !arePublicRoomsDisabled }, JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled }, JoinRuleItem.PrivateVisibility.Private, ).toImmutableList() diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt index 09afd38f90..ebd3cdcdc2 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/ConfigureRoomPresenterTest.kt @@ -57,6 +57,7 @@ import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -522,7 +523,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { @Test fun `present - creating a private room when encryption is disabled by the HS creates it unencrypted`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val createRoomLambda = lambdaRecorder> { Result.success(A_ROOM_ID) } val matrixClient = createMatrixClient().apply { createRoomResult = createRoomLambda @@ -544,6 +548,38 @@ class ConfigureRoomPresenterTest : RobolectricTest() { } } + @Test + fun `present - when public rooms are disabled by the HS, the public join rule is not available`() = runTest { + val presenter = createConfigureRoomPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val expectedJoinRules = listOf(JoinRuleItem.PublicVisibility.AskToJoin, JoinRuleItem.PrivateVisibility.Private) + val state = consumeItemsUntilPredicate { it.availableJoinRules == expectedJoinRules }.last() + assertThat(state.availableJoinRules).containsExactlyElementsIn(expectedJoinRules).inOrder() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - when public rooms are disabled by the HS, only the private join rule is available for a space`() = runTest { + val presenter = createConfigureRoomPresenter( + isSpace = true, + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val state = consumeItemsUntilPredicate { it.availableJoinRules == listOf(JoinRuleItem.PrivateVisibility.Private) }.last() + assertThat(state.config.visibilityState).isEqualTo(RoomVisibilityState.Private(JoinRuleItem.PrivateVisibility.Private)) + cancelAndIgnoreRemainingEvents() + } + } + private suspend fun TurbineTestContext.initialState(): ConfigureRoomState { skipItems(1) return awaitItem() @@ -579,7 +615,10 @@ class ConfigureRoomPresenterTest : RobolectricTest() { permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(), isKnockFeatureEnabled: Boolean = true, mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), - sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }), + sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, + ), ) = ConfigureRoomPresenter( isSpace = isSpace, initialParentSpaceId = initialParenSpaceId, diff --git a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt index 94f455f2ff..aff9a3ae5c 100644 --- a/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt +++ b/features/enterprise/api/src/main/kotlin/io/element/android/features/enterprise/api/SessionEnterpriseService.kt @@ -29,4 +29,10 @@ interface SessionEnterpriseService { /** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */ suspend fun isEncryptionDisabledByHomeserver(): Boolean + + /** + * Whether the homeserver forbids public rooms and spaces, in which case the app must not offer to create one, + * nor to make an existing room or space public. + */ + suspend fun arePublicRoomsDisabledByHomeserver(): Boolean } diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt index 3ac10d8a08..8a528257a6 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultSessionEnterpriseService.kt @@ -18,4 +18,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService { override suspend fun tweakMasUrl(url: String): String = url override suspend fun isElementCallAvailable(): Boolean = true override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean = false } diff --git a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt index e198fca867..a461b87c95 100644 --- a/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt +++ b/features/enterprise/test/src/main/kotlin/io/element/android/features/enterprise/test/FakeSessionEnterpriseService.kt @@ -16,6 +16,7 @@ class FakeSessionEnterpriseService( private val isElementCallAvailableResult: () -> Boolean = { lambdaError() }, private val tweakMasUrlResult: (String) -> String = { lambdaError() }, private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() }, + private val arePublicRoomsDisabledResult: () -> Boolean = { lambdaError() }, ) : SessionEnterpriseService { override suspend fun init() { } @@ -31,4 +32,8 @@ class FakeSessionEnterpriseService( override suspend fun isEncryptionDisabledByHomeserver(): Boolean { return isEncryptionDisabledResult() } + + override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean { + return arePublicRoomsDisabledResult() + } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt index 32517cd18d..498e71dd0a 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenter.kt @@ -89,6 +89,10 @@ class SecurityAndPrivacyPresenter( value = sessionEnterpriseService.isEncryptionDisabledByHomeserver() } + val arePublicRoomsDisabledByHomeserver by produceState(false) { + value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver() + } + val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } LaunchedEffect(Unit) { isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory) @@ -254,6 +258,7 @@ class SecurityAndPrivacyPresenter( selectableJoinedSpaces = selectableJoinedSpaces, spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = ::handleEvent, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt index ddba5d0caa..5b61953674 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyState.kt @@ -28,6 +28,7 @@ data class SecurityAndPrivacyState( val editedSettings: SecurityAndPrivacySettings, val homeserverName: String, val isEncryptionDisabledByHomeserver: Boolean, + private val arePublicRoomsDisabledByHomeserver: Boolean, val showEnableEncryptionConfirmation: Boolean, private val isKnockEnabled: Boolean, val saveAction: AsyncAction, @@ -39,6 +40,11 @@ data class SecurityAndPrivacyState( ) { val isSpaceMemberSelectable = spaceSelectionMode != SpaceSelectionMode.None + // Show Anyone option when: + // - Anyone is the current saved value, OR + // - The homeserver allows public rooms + val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver + // 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) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt index 102adb4015..45383bf814 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStatePreviewParam.kt @@ -37,6 +37,10 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider = emptySet(), spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None, isEncryptionDisabledByHomeserver: Boolean = false, + arePublicRoomsDisabledByHomeserver: Boolean = false, eventSink: (SecurityAndPrivacyEvent) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, @@ -152,5 +157,6 @@ fun aSecurityAndPrivacyState( selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), spaceSelectionMode = spaceSelectionMode, isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver, + arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver, eventSink = eventSink, ) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt index eeaa9bbc13..5b224f682c 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyView.kt @@ -232,13 +232,15 @@ private fun RoomAccessSection( title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), modifier = modifier, ) { - ListItem( - content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, - supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) }, - trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone), - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), - onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, - ) + if (state.showAnyoneOption) { + ListItem( + content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_title)) }, + supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_anyone_option_description)) }, + trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.Anyone), + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), + onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, + ) + } if (state.showSpaceMemberOption) { ListItem( content = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt index 8cdba871be..5a0cf743e2 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -34,6 +34,8 @@ import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.tests.testutils.consumeItemsUntilPredicate +import io.element.android.tests.testutils.consumeItemsUntilTimeout import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -1048,7 +1050,10 @@ class SecurityAndPrivacyPresenterTest { @Test fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest { - val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true }) + val sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { true }, + arePublicRoomsDisabledResult = { false }, + ) val presenter = createSecurityAndPrivacyPresenter( sessionEnterpriseService = sessionEnterpriseService, ) @@ -1060,6 +1065,42 @@ class SecurityAndPrivacyPresenterTest { } } + @Test + fun `present - showAnyoneOption is false if the HS disabled public rooms`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + consumeItemsUntilPredicate { !it.showAnyoneOption } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showAnyoneOption is true if the HS disabled public rooms but the room is already public`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Public) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Public) + ), + ), + sessionEnterpriseService = FakeSessionEnterpriseService( + isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { true }, + ), + ) + presenter.test { + val states = consumeItemsUntilTimeout() + assertThat(states).isNotEmpty() + assertThat(states.all { it.showAnyoneOption }).isTrue() + } + } + private fun roomPermissions( canChangeRoomAccess: Boolean = true, canChangeHistoryVisibility: Boolean = true, @@ -1100,6 +1141,7 @@ class SecurityAndPrivacyPresenterTest { spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(), sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService( isEncryptionDisabledResult = { false }, + arePublicRoomsDisabledResult = { false }, ) ): SecurityAndPrivacyPresenter { return SecurityAndPrivacyPresenter(