Allow homeserver to disable encryption in Element Pro (#7198)

* Allow HS to disable encryption in Element Pro

Add `force_disable_e2ee` support in the Element well known retriever and cache to allow a homeserver to disable encrypted room creation and enabling encryption in an existing room in Element Pro clients

* Bump enterprise repo back to `main` branch
This commit is contained in:
Jorge Martin Espinosa
2026-07-15 13:29:16 +02:00
committed by GitHub
parent 03f3e8b3e0
commit 62cc7e0505
19 changed files with 97 additions and 9 deletions
@@ -43,6 +43,7 @@ dependencies {
implementation(libs.coil.compose)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.invitepeople.api)
implementation(projects.features.enterprise.api)
api(projects.features.createroom.api)
testCommonDependencies(libs, true)
@@ -53,4 +54,5 @@ dependencies {
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.features.startchat.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.features.enterprise.test)
}
@@ -24,6 +24,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.CreatedRoom
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -73,6 +74,7 @@ class ConfigureRoomPresenter(
private val featureFlagService: FeatureFlagService,
private val roomAliasHelper: RoomAliasHelper,
private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider,
private val sessionEnterpriseService: SessionEnterpriseService,
) : Presenter<ConfigureRoomState> {
@AssistedFactory
interface Factory {
@@ -256,7 +258,7 @@ class ConfigureRoomPresenter(
CreateRoomParameters(
name = config.roomName,
topic = config.topic,
isEncrypted = true,
isEncrypted = !sessionEnterpriseService.isEncryptionDisabledByHomeserver(),
isDirect = false,
visibility = RoomVisibility.Private,
historyVisibilityOverride = RoomHistoryVisibility.Invited,
@@ -20,11 +20,13 @@ import io.element.android.features.createroom.impl.configureroom.CreateRoomConfi
import io.element.android.features.createroom.impl.configureroom.JoinRuleItem
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
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.alias.RoomAliasHelper
@@ -56,6 +58,7 @@ 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.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.matching
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.mockk.mockk
@@ -517,6 +520,30 @@ 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 createRoomLambda = lambdaRecorder<CreateRoomParameters, Result<RoomId>> { Result.success(A_ROOM_ID) }
val matrixClient = createMatrixClient().apply {
createRoomResult = createRoomLambda
}
val presenter = createConfigureRoomPresenter(
matrixClient = matrixClient,
sessionEnterpriseService = sessionEnterpriseService,
)
presenter.test {
val initialState = initialState()
initialState.eventSink(ConfigureRoomEvents.JoinRuleChanged(JoinRuleItem.PrivateVisibility.Private))
initialState.eventSink(ConfigureRoomEvents.CreateRoom)
assertThat(awaitItem().createRoomAction.isLoading()).isTrue()
assertThat(awaitItem().createRoomAction.isSuccess()).isTrue()
createRoomLambda.assertions().isCalledOnce().with(matching<CreateRoomParameters> { !it.isEncrypted })
}
}
private suspend fun TurbineTestContext<ConfigureRoomState>.initialState(): ConfigureRoomState {
skipItems(1)
return awaitItem()
@@ -552,6 +579,7 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
isKnockFeatureEnabled: Boolean = true,
mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { false }),
) = ConfigureRoomPresenter(
isSpace = isSpace,
initialParentSpaceId = initialParenSpaceId,
@@ -566,5 +594,6 @@ class ConfigureRoomPresenterTest : RobolectricTest() {
mapOf(FeatureFlags.Knock.key to isKnockFeatureEnabled)
),
mediaOptimizationConfigProvider = mediaOptimizationConfigProvider,
sessionEnterpriseService = sessionEnterpriseService,
)
}
@@ -12,5 +12,7 @@ interface SessionEnterpriseService {
suspend fun isElementCallAvailable(): Boolean
suspend fun tweakMasUrl(url: String): String
suspend fun isEncryptionDisabledByHomeserver(): Boolean
suspend fun init()
}
@@ -17,4 +17,5 @@ class DefaultSessionEnterpriseService : SessionEnterpriseService {
override suspend fun init() = Unit
override suspend fun tweakMasUrl(url: String): String = url
override suspend fun isElementCallAvailable(): Boolean = true
override suspend fun isEncryptionDisabledByHomeserver(): Boolean = false
}
@@ -15,6 +15,7 @@ import io.element.android.tests.testutils.simulateLongTask
class FakeSessionEnterpriseService(
private val isElementCallAvailableResult: () -> Boolean = { lambdaError() },
private val tweakMasUrlResult: (String) -> String = { lambdaError() },
private val isEncryptionDisabledResult: () -> Boolean = { lambdaError() },
) : SessionEnterpriseService {
override suspend fun init() {
}
@@ -26,4 +27,8 @@ class FakeSessionEnterpriseService(
override suspend fun isElementCallAvailable(): Boolean = simulateLongTask {
isElementCallAvailableResult()
}
override suspend fun isEncryptionDisabledByHomeserver(): Boolean {
return isEncryptionDisabledResult()
}
}
@@ -41,10 +41,12 @@ dependencies {
implementation(projects.libraries.uiStrings)
implementation(projects.services.analytics.api)
implementation(projects.libraries.featureflag.api)
implementation(projects.features.enterprise.api)
testCommonDependencies(libs, true)
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.testtags)
testImplementation(projects.features.enterprise.test)
}
@@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
@@ -63,6 +64,7 @@ class SecurityAndPrivacyPresenter(
private val matrixClient: MatrixClient,
private val room: JoinedRoom,
private val featureFlagService: FeatureFlagService,
private val sessionEnterpriseService: SessionEnterpriseService,
) : Presenter<SecurityAndPrivacyState> {
@AssistedFactory
interface Factory {
@@ -83,6 +85,10 @@ class SecurityAndPrivacyPresenter(
val homeserverName = remember { matrixClient.userIdServerName() }
val roomInfo by room.roomInfoFlow.collectAsState()
val isEncryptionDisabledByHomeserver by produceState(false) {
value = sessionEnterpriseService.isEncryptionDisabledByHomeserver()
}
val savedIsVisibleInRoomDirectory = remember { mutableStateOf<AsyncData<Boolean>>(AsyncData.Uninitialized) }
LaunchedEffect(Unit) {
isRoomVisibleInRoomDirectory(savedIsVisibleInRoomDirectory)
@@ -247,6 +253,7 @@ class SecurityAndPrivacyPresenter(
isSpace = roomInfo.isSpace,
selectableJoinedSpaces = selectableJoinedSpaces,
spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
eventSink = ::handleEvent,
)
@@ -27,6 +27,7 @@ data class SecurityAndPrivacyState(
// the settings the user wants to apply.
val editedSettings: SecurityAndPrivacySettings,
val homeserverName: String,
val isEncryptionDisabledByHomeserver: Boolean,
val showEnableEncryptionConfirmation: Boolean,
private val isKnockEnabled: Boolean,
val saveAction: AsyncAction<Unit>,
@@ -84,7 +85,7 @@ data class SecurityAndPrivacyState(
editedSettings.roomAccess.canConfigureRoomVisibility()
val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility && !isSpace
val showEncryptionSection = permissions.canChangeEncryption && !isSpace
val showEncryptionSection = !isEncryptionDisabledByHomeserver && permissions.canChangeEncryption && !isSpace
@Composable
fun spaceMemberDescription(): String {
@@ -138,6 +138,7 @@ fun aSecurityAndPrivacyState(
isSpace: Boolean = false,
selectableJoinedSpaces: Set<SpaceRoom> = emptySet(),
spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None,
isEncryptionDisabledByHomeserver: Boolean = false,
eventSink: (SecurityAndPrivacyEvent) -> Unit = {}
) = SecurityAndPrivacyState(
editedSettings = editedSettings,
@@ -150,5 +151,6 @@ fun aSecurityAndPrivacyState(
isSpace = isSpace,
selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(),
spaceSelectionMode = spaceSelectionMode,
isEncryptionDisabledByHomeserver = isEncryptionDisabledByHomeserver,
eventSink = eventSink,
)
@@ -8,6 +8,7 @@
package io.element.android.features.securityandprivacy.impl.root
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
import io.element.android.features.securityandprivacy.impl.FakeSecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator
import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionStateHolder
@@ -1046,6 +1047,20 @@ class SecurityAndPrivacyPresenterTest {
}
}
@Test
fun `present - showEncryptionSection is false if the HS disabled encryption`() = runTest {
val sessionEnterpriseService = FakeSessionEnterpriseService(isEncryptionDisabledResult = { true })
val presenter = createSecurityAndPrivacyPresenter(
sessionEnterpriseService = sessionEnterpriseService,
)
presenter.test {
skipItems(1)
// The HS has disabled encryption, so the encryption section should not be shown
assertThat(awaitItem().showEncryptionSection).isFalse()
}
}
private fun roomPermissions(
canChangeRoomAccess: Boolean = true,
canChangeHistoryVisibility: Boolean = true,
@@ -1084,6 +1099,9 @@ class SecurityAndPrivacyPresenterTest {
),
),
spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(),
sessionEnterpriseService: FakeSessionEnterpriseService = FakeSessionEnterpriseService(
isEncryptionDisabledResult = { false },
)
): SecurityAndPrivacyPresenter {
return SecurityAndPrivacyPresenter(
room = room,
@@ -1091,6 +1109,7 @@ class SecurityAndPrivacyPresenterTest {
navigator = navigator,
featureFlagService = featureFlagService,
spaceSelectionStateHolder = spaceSelectionStateHolder,
sessionEnterpriseService = sessionEnterpriseService,
)
}
}
@@ -126,7 +126,7 @@ class FakeMatrixClient(
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
override val contentScanner: ContentScanner? = null
override val contentScanner: ContentScanner? = null,
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -144,7 +144,7 @@ class FakeMatrixClient(
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile
private var createRoomResult: Result<RoomId> = Result.success(A_ROOM_ID)
var createRoomResult: (CreateRoomParameters) -> Result<RoomId> = { Result.success(A_ROOM_ID) }
private var createDmResult: Result<RoomId> = Result.success(A_ROOM_ID)
private var findDmResult: Result<RoomId?> = Result.success(A_ROOM_ID)
private val getRoomResults = mutableMapOf<RoomId, BaseRoom>()
@@ -193,7 +193,7 @@ class FakeMatrixClient(
}
override suspend fun createRoom(createRoomParams: CreateRoomParameters): Result<RoomId> = simulateLongTask {
return createRoomResult
return createRoomResult(createRoomParams)
}
override suspend fun createDM(userId: UserId): Result<RoomId> = simulateLongTask {
@@ -297,7 +297,7 @@ class FakeMatrixClient(
// Mocks
fun givenCreateRoomResult(result: Result<RoomId>) {
createRoomResult = result
createRoomResult = { result }
}
fun givenCreateDmResult(result: Result<RoomId>) {
@@ -17,4 +17,5 @@ data class ElementWellKnown(
val identityProviderAppScheme: String?,
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
val contentScannerUrl: String?,
val forceDisableE2EE: Boolean?,
)
@@ -46,6 +46,8 @@ data class InternalElementWellKnown(
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
@SerialName("content_scanner_url")
val contentScannerUrl: String? = null,
@SerialName("force_disable_e2ee")
val forceDisableE2EE: Boolean? = null,
)
@Serializable
@@ -31,6 +31,7 @@ fun InternalElementWellKnown.map() = ElementWellKnown(
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
contentScannerUrl = contentScannerUrl,
forceDisableE2EE = forceDisableE2EE,
)
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
@@ -50,6 +50,7 @@ class DefaultSessionWellknownRetrieverTest {
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
forceDisableE2EE = null,
)
)
)
@@ -77,6 +78,7 @@ class DefaultSessionWellknownRetrieverTest {
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
@@ -108,6 +110,7 @@ class DefaultSessionWellknownRetrieverTest {
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
forceDisableE2EE = null,
)
)
)
@@ -255,6 +258,7 @@ class DefaultSessionWellknownRetrieverTest {
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
@@ -303,6 +307,7 @@ class DefaultSessionWellknownRetrieverTest {
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
@@ -340,7 +345,8 @@ class DefaultSessionWellknownRetrieverTest {
"brand_color": "#FF0000",
"notification_sound": "a_notification_sound.flac",
"idp_app_scheme": "an_app_scheme",
"content_scanner_url": "https://content-scanner.example.com"
"content_scanner_url": "https://content-scanner.example.com",
"force_disable_e2ee": false,
}"""
}
}
@@ -50,6 +50,7 @@ class DefaultWellknownRetrieverTest {
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
forceDisableE2EE = null,
)
)
)
@@ -72,6 +73,7 @@ class DefaultWellknownRetrieverTest {
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
@@ -101,6 +103,7 @@ class DefaultWellknownRetrieverTest {
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
forceDisableE2EE = null,
)
)
)
@@ -266,7 +269,8 @@ class DefaultWellknownRetrieverTest {
"brand_color": "#FF0000",
"notification_sound": "a_notification_sound.flac",
"idp_app_scheme": "an_app_scheme",
"content_scanner_url": "https://content-scanner.example.com"
"content_scanner_url": "https://content-scanner.example.com",
"force_disable_e2ee": false
}"""
}
}
@@ -20,6 +20,7 @@ fun anElementWellKnown(
identityProviderAppScheme: String? = null,
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
contentScannerUrl: String? = null,
forceDisableE2EE: Boolean? = null,
) = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
@@ -29,6 +30,7 @@ fun anElementWellKnown(
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase,
contentScannerUrl = contentScannerUrl,
forceDisableE2EE = forceDisableE2EE,
)
fun aCustomRecoveryPassphrase(