Make sure enforce_element_pro is taken into account in FOSS (#7631)

* Make sure `enforce_element_pro` is taken into account in FOSS

* Make sure we crate `RustTemporaryMatrixClient` instances with a proper `serverName` value
This commit is contained in:
Jorge Martin Espinosa
2026-09-03 10:05:40 +02:00
committed by GitHub
parent 3b2e416855
commit dcd0bc782f
4 changed files with 92 additions and 14 deletions
@@ -10,6 +10,7 @@ import extension.testCommonDependencies
*/ */
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.kotlin.serialization)
} }
android { android {
@@ -22,8 +23,10 @@ dependencies {
implementation(projects.libraries.compound) implementation(projects.libraries.compound)
api(projects.features.enterprise.api) api(projects.features.enterprise.api)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api) implementation(projects.libraries.wellknown.api)
implementation(libs.serialization.json)
testCommonDependencies(libs) testCommonDependencies(libs)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
@@ -14,18 +14,43 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import timber.log.Timber
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultEnterpriseService : EnterpriseService { class DefaultEnterpriseService(
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
private val jsonProvider: JsonProvider,
) : EnterpriseService {
override suspend fun isEnterpriseUser(sessionId: SessionId) = false override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
override fun homeserverAllowList(): List<String> = emptyList() override fun homeserverAllowList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override suspend fun isElementProEnforced(serverName: String): Boolean = false override suspend fun isElementProEnforced(serverName: String): Boolean {
val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false }
return temporaryMatrixClient.use { client ->
val baseUrl = serverName.ensureProtocol().removeSuffix("/")
// We'll always perform a network request here since we're not interested in any cached value.
client.getUrl("$baseUrl/.well-known/element/element.json")
.mapCatchingExceptions { response ->
val remoteConfig = jsonProvider().decodeFromString<MinimalEnterpriseConfig>(String(response))
remoteConfig.enforceElementPro ?: false
}
.onFailure {
Timber.e(it, "Failed to fetch enterprise config for checking if Element Pro is enforced for $serverName")
}
.getOrElse { false }
}
}
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
@@ -46,3 +71,11 @@ class DefaultEnterpriseService : EnterpriseService {
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
} }
/**
* A minimal version of the enterprise config that only contains the fields we need to check if Element Pro is enforced.
*/
@Serializable
private data class MinimalEnterpriseConfig(
@SerialName("enforce_element_pro") val enforceElementPro: Boolean? = null,
)
@@ -12,33 +12,40 @@ import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test import org.junit.Test
class DefaultEnterpriseServiceTest { class DefaultEnterpriseServiceTest {
@Test @Test
fun homeserverWhitelist() { fun homeserverWhitelist() {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty() assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty()
} }
@Test @Test
fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest { fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue() assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue()
} }
@Test @Test
fun `isEnterpriseUser always return false`() = runTest { fun `isEnterpriseUser always return false`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isEnterpriseUser(A_SESSION_ID)).isFalse() assertThat(defaultEnterpriseService.isEnterpriseUser(A_SESSION_ID)).isFalse()
} }
@Test @Test
fun `semanticColorsFlow always emits the same value`() = runTest { fun `semanticColorsFlow always emits the same value`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
defaultEnterpriseService.semanticColorsFlow(null).test { defaultEnterpriseService.semanticColorsFlow(null).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) assertThat(initialState).isEqualTo(SemanticColorsLightDark.default)
@@ -48,7 +55,7 @@ class DefaultEnterpriseServiceTest {
@Test @Test
fun `brandColorsFlow always emits null`() = runTest { fun `brandColorsFlow always emits null`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
defaultEnterpriseService.brandColorsFlow(null).test { defaultEnterpriseService.brandColorsFlow(null).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState).isNull() assertThat(initialState).isNull()
@@ -58,7 +65,7 @@ class DefaultEnterpriseServiceTest {
@Test @Test
fun `semanticColorsFlow always emits the same value for a session`() = runTest { fun `semanticColorsFlow always emits the same value for a session`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
defaultEnterpriseService.semanticColorsFlow(A_SESSION_ID).test { defaultEnterpriseService.semanticColorsFlow(A_SESSION_ID).test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) assertThat(initialState).isEqualTo(SemanticColorsLightDark.default)
@@ -68,25 +75,25 @@ class DefaultEnterpriseServiceTest {
@Test @Test
fun `overrideBrandColor has no effect`() = runTest { fun `overrideBrandColor has no effect`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "aColor") defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "aColor")
} }
@Test @Test
fun `firebasePushGateway returns null`() = runTest { fun `firebasePushGateway returns null`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.firebasePushGateway()).isNull() assertThat(defaultEnterpriseService.firebasePushGateway()).isNull()
} }
@Test @Test
fun `unifiedPushDefaultPushGateway returns null`() = runTest { fun `unifiedPushDefaultPushGateway returns null`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.unifiedPushDefaultPushGateway()).isNull() assertThat(defaultEnterpriseService.unifiedPushDefaultPushGateway()).isNull()
} }
@Test @Test
fun `bugReportUrlFlow only emits UseDefault`() = runTest { fun `bugReportUrlFlow only emits UseDefault`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
defaultEnterpriseService.bugReportUrlFlow(A_SESSION_ID).test { defaultEnterpriseService.bugReportUrlFlow(A_SESSION_ID).test {
assertThat(awaitItem()).isEqualTo(BugReportUrl.UseDefault) assertThat(awaitItem()).isEqualTo(BugReportUrl.UseDefault)
awaitComplete() awaitComplete()
@@ -95,7 +102,34 @@ class DefaultEnterpriseServiceTest {
@Test @Test
fun `getNoisyNotificationChannelId returns null`() = runTest { fun `getNoisyNotificationChannelId returns null`() = runTest {
val defaultEnterpriseService = DefaultEnterpriseService() val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.getNoisyNotificationChannelId(A_SESSION_ID)).isNull() assertThat(defaultEnterpriseService.getNoisyNotificationChannelId(A_SESSION_ID)).isNull()
} }
@Test
fun `isElementProEnforced checks using a temporary client`() = runTest {
val closeLambda = lambdaRecorder<Unit> {}
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {
Result.success("""{"enforce_element_pro": true}""".toByteArray())
}
val client = FakeTemporaryMatrixClient(
getUrlResult = getUrlLambda,
closeLambda = closeLambda,
)
val defaultEnterpriseService = createDefaultEnterpriseService(client = client)
assertThat(defaultEnterpriseService.isElementProEnforced(A_HOMESERVER_URL)).isTrue()
// Verify that the temporary client was used to fetch the URL and then closed
val expectedUrl = "${A_HOMESERVER_URL.ensureProtocol()}/.well-known/element/element.json"
getUrlLambda.assertions().isCalledOnce().with(value(expectedUrl))
closeLambda.assertions().isCalledOnce()
}
private fun createDefaultEnterpriseService(
client: FakeTemporaryMatrixClient = FakeTemporaryMatrixClient(),
jsonProvider: JsonProvider = { Json }
) = DefaultEnterpriseService(
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(createResult = { Result.success(client) }),
jsonProvider = jsonProvider,
)
} }
@@ -10,9 +10,11 @@ package io.element.android.libraries.matrix.impl
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.TemporaryMatrixClient import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import java.net.URL
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class RustTemporaryMatrixClientFactory( class RustTemporaryMatrixClientFactory(
@@ -21,6 +23,12 @@ class RustTemporaryMatrixClientFactory(
) : TemporaryMatrixClientFactory { ) : TemporaryMatrixClientFactory {
override suspend fun create(serverName: String): Result<TemporaryMatrixClient> { override suspend fun create(serverName: String): Result<TemporaryMatrixClient> {
return runCatchingExceptions { return runCatchingExceptions {
// In case the 'serverName' is a full URL, we need to extract the host and port to pass to the client builder.
val parsedUrl = URL(serverName.ensureProtocol())
val domain = parsedUrl.host ?: error("Invalid server name: $serverName")
val port = parsedUrl.port
val formattedServerName = if (port != -1) "$domain:$port" else domain
val sessionPaths = sessionPathsFactory.create() val sessionPaths = sessionPathsFactory.create()
val client = rustMatrixClientFactory.getBaseClientBuilder( val client = rustMatrixClientFactory.getBaseClientBuilder(
sessionPaths = sessionPaths, sessionPaths = sessionPaths,
@@ -28,7 +36,7 @@ class RustTemporaryMatrixClientFactory(
slidingSyncType = ClientBuilderSlidingSync.Native, slidingSyncType = ClientBuilderSlidingSync.Native,
isMessageSearchAvailable = false, isMessageSearchAvailable = false,
) )
.serverName(serverName) .serverName(formattedServerName)
.build() .build()
RustTemporaryMatrixClient(client, sessionPaths) RustTemporaryMatrixClient(client, sessionPaths)
} }