Made the hooks of room creation and security & privacy screen more generic.

This commit is contained in:
mxandreas
2026-10-06 09:53:26 +03:00
parent 84fdbbe573
commit f2310cc530
14 changed files with 82 additions and 114 deletions
@@ -0,0 +1,14 @@
/*
* 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.createroom.impl.configureroom
/** Lets a build-specific implementation adjust what the configure room screen offers. */
interface ConfigureRoomExtension {
/** Whether the public join rule may be offered. */
suspend fun isPublicAccessAllowed(): Boolean
}
@@ -76,6 +76,7 @@ class ConfigureRoomPresenter(
private val roomAliasHelper: RoomAliasHelper,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val sessionEnterpriseService: SessionEnterpriseService,
private val configureRoomExtension: ConfigureRoomExtension,
) : Presenter<ConfigureRoomState> {
@AssistedFactory
interface Factory {
@@ -93,8 +94,8 @@ class ConfigureRoomPresenter(
val isKnockFeatureEnabled by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock)
}.collectAsState(initial = false)
val arePublicRoomsDisabled by produceState(false) {
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
val isPublicAccessAllowed by produceState(true) {
value = configureRoomExtension.isPublicAccessAllowed()
}
val roomAddressValidity = remember {
mutableStateOf<RoomAddressValidity>(RoomAddressValidity.Unknown)
@@ -149,13 +150,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.
// 4. If public access is allowed.
val parentSpace = createRoomConfig.parentSpace
val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, arePublicRoomsDisabled) {
val availableJoinRules = remember(parentSpace, isSpace, isKnockFeatureEnabled, isPublicAccessAllowed) {
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.takeIf { !arePublicRoomsDisabled },
JoinRuleItem.PublicVisibility.Public.takeIf { isPublicAccessAllowed },
JoinRuleItem.PublicVisibility.AskToJoin.takeIf { !isSpace && isKnockFeatureEnabled },
JoinRuleItem.PrivateVisibility.Private,
).toImmutableList()
@@ -0,0 +1,16 @@
/*
* 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.createroom.impl.configureroom
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class NoopConfigureRoomExtension : ConfigureRoomExtension {
override suspend fun isPublicAccessAllowed(): Boolean = true
}
@@ -18,6 +18,7 @@ import io.element.android.features.createroom.impl.configureroom.ConfigureRoomSt
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfig
import io.element.android.features.createroom.impl.configureroom.CreateRoomConfigStore
import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
import io.element.android.features.createroom.impl.configureroom.NoopConfigureRoomExtension
import io.element.android.features.createroom.impl.configureroom.RoomAddress
import io.element.android.features.createroom.impl.configureroom.RoomVisibilityState
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
@@ -57,7 +58,6 @@ 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
@@ -523,10 +523,7 @@ 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 },
arePublicRoomsDisabledResult = { false },
)
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
val createRoomLambda = lambdaRecorder<CreateRoomParameters, Result<RoomId>> { Result.success(A_ROOM_ID) }
val matrixClient = createMatrixClient().apply {
createRoomResult = createRoomLambda
@@ -548,38 +545,6 @@ 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<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
@@ -615,10 +580,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { false },
),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }),
) = ConfigureRoomPresenter(
isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
@@ -634,5 +596,6 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
sessionEnterpriseService = sessionEnterpriseService,
configureRoomExtension = NoopConfigureRoomExtension(),
)
}
@@ -29,10 +29,4 @@ 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
}
@@ -18,5 +18,4 @@ 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
}
@@ -16,7 +16,6 @@ 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() {
}
@@ -32,8 +31,4 @@ class FakeSessionEnterpriseService(
override suspend fun isEncryptionDisabledByHomeserver(): Boolean {
return isEncryptionDisabledResult()
}
override suspend fun arePublicRoomsDisabledByHomeserver(): Boolean {
return arePublicRoomsDisabledResult()
}
}
@@ -0,0 +1,16 @@
/*
* 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.root
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.RoomScope
@ContributesBinding(RoomScope::class)
class NoopSecurityAndPrivacyExtension : SecurityAndPrivacyExtension {
override suspend fun isPublicAccessAllowed(): Boolean = true
}
@@ -0,0 +1,14 @@
/*
* 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.root
/** Lets a build-specific implementation adjust what the security and privacy screen offers. */
interface SecurityAndPrivacyExtension {
/** Whether the room access may be changed to anyone. */
suspend fun isPublicAccessAllowed(): Boolean
}
@@ -65,6 +65,7 @@ class SecurityAndPrivacyPresenter(
private val room: JoinedRoom,
private val featureFlagService: FeatureFlagService,
private val sessionEnterpriseService: SessionEnterpriseService,
private val securityAndPrivacyExtension: SecurityAndPrivacyExtension,
) : Presenter<SecurityAndPrivacyState> {
@AssistedFactory
interface Factory {
@@ -89,8 +90,8 @@ class SecurityAndPrivacyPresenter(
value = sessionEnterpriseService.isEncryptionDisabledByHomeserver()
}
val arePublicRoomsDisabledByHomeserver by produceState(false) {
value = sessionEnterpriseService.arePublicRoomsDisabledByHomeserver()
val isPublicAccessAllowed by produceState(true) {
value = securityAndPrivacyExtension.isPublicAccessAllowed()
}
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
@@ -258,7 +259,7 @@ class SecurityAndPrivacyPresenter(
selectableJoinedSpaces = selectableJoinedSpaces,
spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
isPublicAccessAllowed = isPublicAccessAllowed,
eventSink = ::handleEvent,
)
@@ -28,7 +28,7 @@ data class SecurityAndPrivacyState(
val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String,
val isEncryptionDisabledByHomeserver: Boolean,
private val arePublicRoomsDisabledByHomeserver: Boolean,
private val isPublicAccessAllowed: Boolean,
val showEnableEncryptionConfirmation: Boolean,
private val isKnockEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
@@ -42,8 +42,8 @@ data class SecurityAndPrivacyState(
// Show Anyone option when:
// - Anyone is the current saved value, OR
// - The homeserver allows public rooms
val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || !arePublicRoomsDisabledByHomeserver
// - Public access is allowed
val showAnyoneOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone || isPublicAccessAllowed
// Show SpaceMember option in two cases:
// - SpaceMember is the current saved value
@@ -37,10 +37,6 @@ open class SecurityAndPrivacyStatePreviewParam : PreviewParameterProvider<Securi
showEncryptionConfirmation = true,
isSpace = false,
),
aSecurityAndPrivacyState(
arePublicRoomsDisabledByHomeserver = true,
isSpace = false,
),
)
}
@@ -143,7 +139,7 @@ fun aSecurityAndPrivacyState(
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
isEncryptionDisabledByHomeserver: Boolean = false,
arePublicRoomsDisabledByHomeserver: Boolean = false,
isPublicAccessAllowed: Boolean = true,
eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState(
editedSettings = editedSettings,
@@ -157,6 +153,6 @@ fun aSecurityAndPrivacyState(
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
arePublicRoomsDisabledByHomeserver = arePublicRoomsDisabledByHomeserver,
isPublicAccessAllowed = isPublicAccessAllowed,
eventSink = eventSink,
)
@@ -34,8 +34,6 @@ 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
@@ -1050,10 +1048,7 @@ class SecurityAndPrivacyPresenterTest {
@Test
fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest {
val sessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { true },
arePublicRoomsDisabledResult = { false },
)
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
val presenter = createSecurityAndPrivacyPresenter(
sessionEnterpriseService = sessionEnterpriseService,
)
@@ -1065,42 +1060,6 @@ 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,
@@ -1141,7 +1100,6 @@ class SecurityAndPrivacyPresenterTest {
spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
arePublicRoomsDisabledResult = { false },
)
): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter(
@@ -1151,6 +1109,7 @@ class SecurityAndPrivacyPresenterTest {
featureFlagService = featureFlagService,
spaceSelectionStateHolder = spaceSelectionStateHolder,
sessionEnterpriseService = sessionEnterpriseService,
securityAndPrivacyExtension = NoopSecurityAndPrivacyExtension(),
)
}
}