diff --git a/features/enterprise/impl-foss/build.gradle.kts b/features/enterprise/impl-foss/build.gradle.kts index a45c8ae167..2585d00d23 100644 --- a/features/enterprise/impl-foss/build.gradle.kts +++ b/features/enterprise/impl-foss/build.gradle.kts @@ -10,6 +10,7 @@ import extension.testCommonDependencies */ plugins { id("io.element.android-compose-library") + alias(libs.plugins.kotlin.serialization) } android { @@ -22,8 +23,10 @@ dependencies { implementation(projects.libraries.compound) api(projects.features.enterprise.api) implementation(projects.libraries.architecture) + implementation(projects.libraries.androidutils) implementation(projects.libraries.matrix.api) implementation(projects.libraries.wellknown.api) + implementation(libs.serialization.json) testCommonDependencies(libs) testImplementation(projects.libraries.matrix.test) diff --git a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt index bb10550369..6ccf70cd02 100644 --- a/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt +++ b/features/enterprise/impl-foss/src/main/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseService.kt @@ -14,18 +14,43 @@ import dev.zacsweers.metro.ContributesBinding import io.element.android.compound.colors.SemanticColorsLightDark import io.element.android.features.enterprise.api.BugReportUrl 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.TemporaryMatrixClientFactory import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import timber.log.Timber @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 tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url override fun homeserverAllowList(): List = emptyList() 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(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 @@ -46,3 +71,11 @@ class DefaultEnterpriseService : EnterpriseService { 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, +) diff --git a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt index 474990c6d9..026c9caf9e 100644 --- a/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt +++ b/features/enterprise/impl-foss/src/test/kotlin/io/element/android/features/enterprise/impl/DefaultEnterpriseServiceTest.kt @@ -12,33 +12,40 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.compound.colors.SemanticColorsLightDark 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_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.serialization.json.Json import org.junit.Test class DefaultEnterpriseServiceTest { @Test fun homeserverWhitelist() { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty() } @Test fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue() } @Test fun `isEnterpriseUser always return false`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.isEnterpriseUser(A_SESSION_ID)).isFalse() } @Test fun `semanticColorsFlow always emits the same value`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() defaultEnterpriseService.semanticColorsFlow(null).test { val initialState = awaitItem() assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) @@ -48,7 +55,7 @@ class DefaultEnterpriseServiceTest { @Test fun `brandColorsFlow always emits null`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() defaultEnterpriseService.brandColorsFlow(null).test { val initialState = awaitItem() assertThat(initialState).isNull() @@ -58,7 +65,7 @@ class DefaultEnterpriseServiceTest { @Test fun `semanticColorsFlow always emits the same value for a session`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() defaultEnterpriseService.semanticColorsFlow(A_SESSION_ID).test { val initialState = awaitItem() assertThat(initialState).isEqualTo(SemanticColorsLightDark.default) @@ -68,25 +75,25 @@ class DefaultEnterpriseServiceTest { @Test fun `overrideBrandColor has no effect`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() defaultEnterpriseService.overrideBrandColor(A_SESSION_ID, "aColor") } @Test fun `firebasePushGateway returns null`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.firebasePushGateway()).isNull() } @Test fun `unifiedPushDefaultPushGateway returns null`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.unifiedPushDefaultPushGateway()).isNull() } @Test fun `bugReportUrlFlow only emits UseDefault`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() defaultEnterpriseService.bugReportUrlFlow(A_SESSION_ID).test { assertThat(awaitItem()).isEqualTo(BugReportUrl.UseDefault) awaitComplete() @@ -95,7 +102,34 @@ class DefaultEnterpriseServiceTest { @Test fun `getNoisyNotificationChannelId returns null`() = runTest { - val defaultEnterpriseService = DefaultEnterpriseService() + val defaultEnterpriseService = createDefaultEnterpriseService() assertThat(defaultEnterpriseService.getNoisyNotificationChannelId(A_SESSION_ID)).isNull() } + + @Test + fun `isElementProEnforced checks using a temporary client`() = runTest { + val closeLambda = lambdaRecorder {} + val getUrlLambda = lambdaRecorder> { + 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, + ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt index 0e7d8bf2c5..98de23c7c3 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactory.kt @@ -10,9 +10,11 @@ package io.element.android.libraries.matrix.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding 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.TemporaryMatrixClientFactory import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory +import java.net.URL @ContributesBinding(AppScope::class) class RustTemporaryMatrixClientFactory( @@ -21,6 +23,12 @@ class RustTemporaryMatrixClientFactory( ) : TemporaryMatrixClientFactory { override suspend fun create(serverName: String): Result { 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 client = rustMatrixClientFactory.getBaseClientBuilder( sessionPaths = sessionPaths, @@ -28,7 +36,7 @@ class RustTemporaryMatrixClientFactory( slidingSyncType = ClientBuilderSlidingSync.Native, isMessageSearchAvailable = false, ) - .serverName(serverName) + .serverName(formattedServerName) .build() RustTemporaryMatrixClient(client, sessionPaths) }