Move the :libraries:wellknown logic into enterprise modules (#7429)

* Extract `IsEnterpriseBuild` use case

Having it inside `EnterpriseService` made having DI cycles with the `BuildMeta` provider too easy

* Rename `EnterpriseService.defaultHomeserverList()` to `homeserverAllowList()`

This seems more aligned with the way this list works

* Move most of the logic from `:libraries:wellknown` to `:enterprise:feature:enterprise`, both `shared` and `impl` modules

Ideally I would have added everything into `impl`, but some of the classes needed to be shared between the different `:enterprise:features` modules, and those are in the `shared` module, lacking a better name/strategy

* Add `EnterpriseService.isElementProEnforced`

With this, we don't need to provide the `RemoteEnterpriseConfig` anymore to any consumers, everything goes through `EnterpriseService` or other providers

* Remove `EnterpriseService.overriddenElementWellKnown`

This leaks the remote enterprise config/element well known contents to the app in a public interface.

* Create `CustomMapTilerConfigProvider` to avoid leaking the `RemoteEnterpriseConfig` in public APIs

This commit also starts moving some of the well-known related values to the enterprise modules

* Add missing docs to `EnterpriseService` and `SessionEnterpriseService`

* Fix tests

* Fix lint issues

* Bump enterprise module
This commit is contained in:
Jorge Martin Espinosa
2026-08-17 15:21:47 +02:00
committed by GitHub
parent f94566e44f
commit 8b2c207728
72 changed files with 246 additions and 1607 deletions
+1
View File
@@ -259,6 +259,7 @@ dependencies {
if (isEnterpriseBuild) {
allEnterpriseImpl(project)
implementation(projects.appicon.enterprise)
implementation(project(":enterprise:features:enterprise:shared"))
} else {
implementation(projects.features.enterprise.implFoss)
implementation(projects.appicon.element)
@@ -18,7 +18,7 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.ApplicationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta
@@ -74,17 +74,18 @@ object AppModule {
fun providesBuildMeta(
@ApplicationContext context: Context,
buildType: BuildType,
enterpriseService: EnterpriseService,
isEnterpriseBuild: IsEnterpriseBuild,
): BuildMeta {
val applicationName = ApplicationConfig.APPLICATION_NAME.takeIf { it.isNotEmpty() } ?: context.getString(R.string.app_name)
val isEnterprise = isEnterpriseBuild()
return BuildMeta(
isDebuggable = BuildConfig.DEBUG,
buildType = buildType,
applicationName = applicationName,
productionApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME,
desktopApplicationName = if (enterpriseService.isEnterpriseBuild) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME,
productionApplicationName = if (isEnterprise) applicationName else ApplicationConfig.PRODUCTION_APPLICATION_NAME,
desktopApplicationName = if (isEnterprise) applicationName else ApplicationConfig.DESKTOP_APPLICATION_NAME,
applicationId = BuildConfig.APPLICATION_ID,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
isEnterpriseBuild = isEnterprise,
// TODO EAx Config.LOW_PRIVACY_LOG_ENABLE,
lowPrivacyLoggingEnabled = false,
versionName = BuildConfig.VERSION_NAME,
@@ -51,7 +51,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
@@ -157,7 +157,7 @@ class LoggedInFlowNode(
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
@@ -685,7 +685,7 @@ class LoggedInFlowNode(
val currentMapTilerConfig = LocalMapTilerConfig.current
val updatedMapTilerConfig by produceState(currentMapTilerConfig) {
value = remoteEnterpriseConfigProvider.get(matrixClient.sessionId).dataOrNull()?.mapTilerConfig ?: currentMapTilerConfig
value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig
}
ElementThemeApp(
@@ -12,7 +12,6 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
/**
@@ -21,9 +20,6 @@ import kotlinx.coroutines.flow.Flow
* On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves.
*/
interface EnterpriseService {
/** Whether this build is an enterprise one; `false` for the public app. */
val isEnterpriseBuild: Boolean
/**
* Whether the given session belongs to an enterprise deployment, which can be true even on a standard build.
*
@@ -41,17 +37,26 @@ interface EnterpriseService {
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
/**
* The homeservers the user is allowed to sign in to; an empty list, or one containing [ANY_ACCOUNT_PROVIDER], means no restriction.
* Returns the list of homeservers the user is allowed to sign in to.
*
* If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver.
*/
fun defaultHomeserverList(): List<String>
fun homeserverAllowList(): List<String>
/**
* Whether the user is allowed to sign in to a given homeserver, according to [defaultHomeserverList].
* Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList].
*
* @param homeserverUrl the server the user is trying to use.
*/
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
/**
* Whether the given homeserver enforces the use of Element Pro or a derived app.
*
* @param homeserverUrl the homeserver to check.
*/
suspend fun isElementProEnforced(homeserverUrl: String): Boolean
/**
* Override the brand color.
* @param sessionId the session to override the brand color for, or null to set the brand color to use when there is no session.
@@ -94,11 +99,6 @@ interface EnterpriseService {
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
/**
* Gets the overridden Element Well-Known data if it has been set, or null if not set.
*/
fun overriddenElementWellKnown(): ElementWellKnown?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
* Returns `null` when this build does not read its configuration from an ESS deployment.
@@ -113,7 +113,7 @@ interface EnterpriseService {
}
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
return defaultHomeserverList().let {
return homeserverAllowList().let {
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
}
@@ -0,0 +1,18 @@
/*
* 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.enterprise.api
/**
* A use case to check if this build of the app is an enterprise build.
*/
fun interface IsEnterpriseBuild {
/**
* Returns `true` if this build of the app is an enterprise build, `false` otherwise.
*/
operator fun invoke(): Boolean
}
@@ -14,6 +14,9 @@ package io.element.android.features.enterprise.api
* [init] must have run before the other members return meaningful answers.
*/
interface SessionEnterpriseService {
/** Loads the deployment configuration of this session; to be called once when the session starts. */
suspend fun init()
/** Whether Element Call may be used on this session, which a deployment can turn off. */
suspend fun isElementCallAvailable(): Boolean
@@ -26,7 +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
/** Loads the deployment configuration of this session; to be called once when the session starts. */
suspend fun init()
}
@@ -0,0 +1,15 @@
/*
* 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.enterprise.api.remoteconfig
/**
* A provider of a runtime-fetched MapTiler configuration, which can be used to override the default MapTiler configuration.
*/
fun interface CustomMapTilerConfigProvider {
suspend fun get(): Result<MapTilerConfig?>
}
@@ -1,29 +1,18 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
* 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.libraries.wellknown.api
package io.element.android.features.enterprise.api.remoteconfig
data class ElementWellKnown(
val registrationHelperUrl: String?,
val enforceElementPro: Boolean?,
val rageshakeUrl: String?,
val brandColor: String?,
val notificationSound: String?,
val identityProviderAppScheme: String?,
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
val contentScannerUrl: String?,
val forceDisableE2EE: Boolean?,
val mapTilerConfig: MapTilerConfig?,
)
import androidx.compose.runtime.Immutable
/**
* A `MapTiler` configuration received from the server. This configuration is used to display maps in the app.
*/
@Immutable
data class MapTilerConfig(
/** The API key to use with MapTiler. This is always not null, but can be empty/blank. */
val apiKey: String,
@@ -1,17 +0,0 @@
/*
* 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.enterprise.api.remoteconfig
import io.element.android.libraries.wellknown.api.ElementWellKnown
/**
* Typealias for the well-known configuration used for enterprise features.
*
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
*/
typealias RemoteEnterpriseConfig = ElementWellKnown
@@ -1,35 +0,0 @@
/*
* 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.enterprise.api.remoteconfig
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
/**
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
*/
interface RemoteEnterpriseConfigProvider {
/**
* Returns the configuration of a session, served from the cache when possible.
* A stale entry is reported as outdated while a refresh runs in the background, so this rarely waits on the network.
*
* @param sessionId the session whose configuration is requested.
*/
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
/**
* Creates a provider, which needs a fetcher because the configuration is read over HTTP.
*/
interface Factory {
/**
* @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works.
*/
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
}
}
@@ -1,33 +1,32 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
* 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.libraries.wellknown.api
package io.element.android.features.enterprise.api.remoteconfig
sealed interface WellknownRetrieverResult<out T> {
sealed interface RemoteEnterpriseConfigResult<out T> {
/**
* Well-known data has been successfully retrieved.
*/
data class Success<out T>(val data: T) : WellknownRetrieverResult<T>
data class Success<out T>(val data: T) : RemoteEnterpriseConfigResult<T>
/**
* Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched.
*/
data class Outdated<out T>(val data: T) : WellknownRetrieverResult<T>
data class Outdated<out T>(val data: T) : RemoteEnterpriseConfigResult<T>
/**
* Well-known data is not found (file does not exist server side, we got a 404).
*/
data object NotFound : WellknownRetrieverResult<Nothing>
data object NotFound : RemoteEnterpriseConfigResult<Nothing>
/**
* Any other error.
*/
data class Error(val exception: Exception) : WellknownRetrieverResult<Nothing>
data class Error(val exception: Exception) : RemoteEnterpriseConfigResult<Nothing>
fun dataOrNull(): T? = when (this) {
is Success<T> -> data
@@ -16,18 +16,16 @@ import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@ContributesBinding(AppScope::class)
class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override fun defaultHomeserverList(): List<String> = emptyList()
override fun homeserverAllowList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = false
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = Unit
@@ -48,7 +46,5 @@ class DefaultEnterpriseService : EnterpriseService {
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null
override fun essConfigEndpointUrl(domain: String): String? = null
}
@@ -0,0 +1,17 @@
/*
* 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.enterprise.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.IsEnterpriseBuild
@ContributesBinding(AppScope::class)
class DefaultIsEnterpriseBuild : IsEnterpriseBuild {
override fun invoke(): Boolean = false
}
@@ -0,0 +1,18 @@
/*
* 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.enterprise.impl.remoteconfig
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.libraries.di.SessionScope
@ContributesBinding(SessionScope::class)
class DefaultCustomMapTilerConfigProvider : CustomMapTilerConfigProvider {
override suspend fun get(): Result<MapTilerConfig?> = Result.success(null)
}
@@ -1,30 +0,0 @@
/*
* 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.enterprise.impl.remoteconfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@ContributesBinding(AppScope::class)
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -19,15 +19,9 @@ import org.junit.Test
class DefaultEnterpriseServiceTest {
@Test
fun `isEnterpriseBuild is false`() {
fun homeserverWhitelist() {
val defaultEnterpriseService = DefaultEnterpriseService()
assertThat(defaultEnterpriseService.isEnterpriseBuild).isFalse()
}
@Test
fun `defaultHomeserverList should return empty list`() {
val defaultEnterpriseService = DefaultEnterpriseService()
assertThat(defaultEnterpriseService.defaultHomeserverList()).isEmpty()
assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty()
}
@Test
@@ -14,7 +14,6 @@ import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
import kotlinx.coroutines.flow.Flow
@@ -22,7 +21,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class FakeEnterpriseService(
override val isEnterpriseBuild: Boolean = false,
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
@@ -33,8 +31,8 @@ class FakeEnterpriseService(
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
private val isElementProEnforcedResult: (String) -> Boolean = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -47,7 +45,7 @@ class FakeEnterpriseService(
tweakMasUrlResult(url, homeserver, urlContentFetcher)
}
override fun defaultHomeserverList(): List<String> {
override fun homeserverAllowList(): List<String> {
return defaultHomeserverListResult()
}
@@ -55,6 +53,10 @@ class FakeEnterpriseService(
isAllowedToConnectToHomeserverResult(homeserverUrl)
}
override suspend fun isElementProEnforced(homeserverUrl: String): Boolean = simulateLongTask {
isElementProEnforcedResult(homeserverUrl)
}
override suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?) = simulateLongTask {
overrideBrandColorResult(sessionId, brandColor)
}
@@ -84,10 +86,6 @@ class FakeEnterpriseService(
return getNoisyNotificationChannelIdResult(sessionId)
}
override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult()
}
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
@@ -1,21 +0,0 @@
/*
* 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.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeRemoteEnterpriseConfigProvider(
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -1,17 +0,0 @@
/*
* 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.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
class FakeRemoteEnterpriseConfigProviderFactory(
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
}
@@ -0,0 +1,22 @@
/*
* 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.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
fun aMapTilerConfig(
apiKey: String = "test_api_key",
baseUrl: String? = null,
lightStyleId: String? = null,
darkStyleId: String? = null,
) = MapTilerConfig(
apiKey = apiKey,
baseUrl = baseUrl,
lightStyleId = lightStyleId,
darkStyleId = darkStyleId,
)
+1
View File
@@ -71,6 +71,7 @@ dependencies {
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(projects.features.enterprise.api)
implementation(libs.coil.compose)
implementation(libs.datetime)
@@ -8,7 +8,7 @@
package io.element.android.features.location.api
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.wellknown.api.MapTilerConfig
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Provides a [MapTilerConfig] for the MapLibre maps.
@@ -37,6 +37,7 @@ import coil3.compose.rememberAsyncImagePainter
import coil3.request.ImageRequest
import coil3.request.SuccessResult
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberStaticMapBuilder
@@ -45,7 +46,6 @@ import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.wellknown.api.MapTilerConfig
/**
* Shows a static map image downloaded via a third party service's static maps API.
@@ -8,8 +8,8 @@
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlin.math.roundToInt
/**
@@ -10,8 +10,8 @@
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
import io.element.android.libraries.wellknown.api.MapTilerConfig
internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
@@ -10,8 +10,7 @@ package io.element.android.features.location.api.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.libraries.wellknown.api.MapTilerConfig
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
@@ -11,7 +11,7 @@ package io.element.android.features.location.api.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.wellknown.api.MapTilerConfig
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Builds a style URI for a MapLibre compatible tile server.
@@ -9,7 +9,7 @@
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.wellknown.api.MapTilerConfig
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
@@ -43,6 +43,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.architecture.AsyncData
@@ -50,7 +51,6 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.wellknown.api.MapTilerConfig
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions
@@ -22,7 +22,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
@@ -73,7 +73,7 @@ class ShareLocationPresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
) : Presenter<ShareLocationState> {
@AssistedFactory
fun interface Factory {
@@ -96,7 +96,7 @@ class ShareLocationPresenter(
val currentUser by client.userProfile.collectAsState()
val customMapConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(remoteEnterpriseConfigProvider.get(client.sessionId).dataOrNull()?.mapTilerConfig)
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
@@ -8,12 +8,12 @@
package io.element.android.features.location.impl.share
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
@@ -9,13 +9,13 @@
package io.element.android.features.location.impl.share
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
@@ -22,7 +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.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
@@ -47,7 +47,6 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
@@ -58,18 +57,18 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
@AssistedInject class ShowLocationPresenter(
@AssistedInject
class ShowLocationPresenter(
@Assisted private val mode: ShowLocationMode,
permissionsPresenterFactory: PermissionsPresenter.Factory,
private val locationActions: LocationActions,
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
private val sessionId: SessionId,
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
) : Presenter<ShowLocationState> {
@AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter
@@ -89,7 +88,7 @@ import kotlinx.coroutines.launch
val customMapTilerConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(remoteEnterpriseConfigProvider.get(sessionId).dataOrNull()?.mapTilerConfig)
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
fun checkLocationConstraints() {
@@ -8,6 +8,7 @@
package io.element.android.features.location.impl.show
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData
@@ -17,7 +18,6 @@ import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
@@ -9,6 +9,7 @@
package io.element.android.features.location.impl.show
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
@@ -18,7 +19,6 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.toImmutableList
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
@@ -11,7 +11,6 @@ package io.element.android.features.location.impl.share
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
@@ -59,7 +58,7 @@ class DefaultShareLocationEntryPointTest {
sessionId = room.sessionId,
),
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
customMapTilerConfigProvider = { Result.success(null) },
)
},
analyticsService = FakeAnalyticsService(),
@@ -19,8 +19,8 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
@@ -32,8 +32,6 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.wellknown.test.aMapTilerConfig
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -55,7 +53,6 @@ import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermiss
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
@@ -92,7 +89,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
client: FakeMatrixClient = fakeMatrixClient,
remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
customMapTilerConfigProvider: () -> Result<MapTilerConfig?> = { Result.success(null) },
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
@@ -106,7 +103,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider,
customMapTilerConfigProvider = customMapTilerConfigProvider,
)
@Test
@@ -134,9 +131,7 @@ class ShareLocationPresenterTest {
client = FakeMatrixClient(
sessionId = A_USER_ID,
),
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(
getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) }
)
customMapTilerConfigProvider = { Result.success(mapTilerConfig) }
)
shareLocationPresenter.test {
val state = awaitItem()
@@ -11,7 +11,6 @@ package io.element.android.features.location.impl.show
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
@@ -21,7 +20,6 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -51,10 +49,9 @@ class DefaultShowLocationEntryPointTest {
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
sessionId = A_SESSION_ID,
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
customMapTilerConfigProvider = { Result.success(null) },
)
},
analyticsService = FakeAnalyticsService(),
@@ -12,7 +12,8 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState
@@ -23,20 +24,15 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.wellknown.test.aMapTilerConfig
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
@@ -69,9 +65,8 @@ class ShowLocationPresenterTest {
),
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
sessionId: SessionId = A_SESSION_ID,
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
remoteEnterpriseConfigProvider: FakeRemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) },
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
@@ -80,21 +75,15 @@ class ShowLocationPresenterTest {
dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
sessionId = sessionId,
liveLocationShareManager = liveLocationShareManager,
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider,
customMapTilerConfigProvider = customMapTilerConfigProvider,
)
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShowLocationPresenter(
sessionId = A_SESSION_ID,
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(
getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) }
)
)
val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) })
shareLocationPresenter.test {
val state = awaitItem()
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
@@ -131,12 +120,7 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
val presenter = createShowLocationPresenter(
sessionId = A_SESSION_ID,
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(
getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = aMapTilerConfig())) }
)
)
val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) })
presenter.test {
val initialState = awaitItem()
@@ -11,17 +11,14 @@ package io.element.android.features.login.impl.accesscontrol
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
@ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl(
private val isEnterpriseBuild: IsEnterpriseBuild,
private val enterpriseService: EnterpriseService,
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider(
@@ -38,29 +35,19 @@ class DefaultAccountProviderAccessControl(
title: String,
accountProviderUrl: String,
) {
if (enterpriseService.isEnterpriseBuild.not()) {
if (isEnterpriseBuild().not()) {
// Ensure that Element Pro is not required for this account provider
val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow()
temporaryMatrixClient.use {
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
val wellKnown = wellknownRetriever.getElementWellKnown(
host = accountProviderUrl,
// It's expected this is hardcoded to the well-known endpoint
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT,
).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
}
if (enterpriseService.isElementProEnforced(accountProviderUrl)) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
}
}
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
throw AccountProviderAccessException.UnauthorizedAccountProviderException(
unauthorisedAccountProviderTitle = title,
authorisedAccountProviderTitles = enterpriseService.defaultHomeserverList(),
authorisedAccountProviderTitles = enterpriseService.homeserverAllowList(),
)
}
}
@@ -23,7 +23,7 @@ class AccountProviderDataSource(
enterpriseService: EnterpriseService,
) {
private val defaultAccountProvider = createAccountProvider(
url = enterpriseService.defaultHomeserverList()
url = enterpriseService.homeserverAllowList()
.firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
?: AuthenticationConfig.MATRIX_ORG_URL
)
@@ -28,7 +28,7 @@ class ChangeAccountProviderPresenter(
@Composable
override fun present(): ChangeAccountProviderState {
val staticAccountProviderList = remember {
enterpriseService.defaultHomeserverList()
enterpriseService.homeserverAllowList()
.filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
.map { it.ensureProtocol() }
.ifEmpty { listOf(AuthenticationConfig.MATRIX_ORG_URL) }
@@ -60,7 +60,7 @@ class ChooseAccountProviderPresenter(
val staticAccountProviderList = remember {
// The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point
enterpriseService.defaultHomeserverList()
enterpriseService.homeserverAllowList()
.map { it.ensureProtocol() }
.map { url ->
AccountProvider(
@@ -62,13 +62,13 @@ class OnBoardingPresenter(
val forcedAccountProvider = remember {
// If defaultHomeserverList() returns a singleton list, this is the default account provider.
// In this case, the user can sign in using this homeserver, or use QrCode login
enterpriseService.defaultHomeserverList().singleOrNull()
enterpriseService.homeserverAllowList().singleOrNull()
}
val canConnectToAnyHomeserver = remember {
enterpriseService.canConnectToAnyHomeserver()
}
val mustChooseAccountProvider = remember {
!canConnectToAnyHomeserver && enterpriseService.defaultHomeserverList().size > 1
!canConnectToAnyHomeserver && enterpriseService.homeserverAllowList().size > 1
}
val linkAccountProvider by produceState<String?>(initialValue = null) {
// Account provider from the link, if allowed by the enterprise service
@@ -11,15 +11,9 @@ package io.element.android.features.login.impl.accesscontrol
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_2
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertThrows
import org.junit.Test
@@ -30,9 +24,7 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
),
enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }),
)
accessControl.expectNeedElementProException()
}
@@ -43,9 +35,7 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = false,
// false here.
isAllowedToConnectToHomeserver = false,
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
),
enterpriseService = FakeEnterpriseService(isElementProEnforcedResult = { true }),
)
accessControl.expectNeedElementProException()
}
@@ -55,8 +45,9 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false }
),
)
accessControl.expectAllowed()
@@ -67,19 +58,15 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
elementWellKnown = anElementWellKnown(
enforceElementPro = null,
),
)
accessControl.expectAllowed()
}
@Test
fun `foss build should allow using account provider twith missing wellknown`() = runTest {
fun `foss build should allow using account provider with missing wellknown`() = runTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = true,
elementWellKnown = null,
)
accessControl.expectAllowed()
}
@@ -90,8 +77,10 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = false,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false },
isElementProEnforcedResult = { false },
defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) },
),
)
accessControl.expectUnauthorizedAccountProviderException()
@@ -102,8 +91,9 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true,
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { true },
),
)
accessControl.expectAllowed()
@@ -114,8 +104,9 @@ class DefaultAccountProviderAccessControlTest {
val accessControl = createDefaultAccountProviderAccessControl(
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = true,
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false },
),
)
accessControl.expectAllowed()
@@ -127,8 +118,10 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
elementWellKnown = anElementWellKnown(
enforceElementPro = true,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false },
isElementProEnforcedResult = { true },
defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) },
),
)
accessControl.expectUnauthorizedAccountProviderException()
@@ -140,8 +133,10 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild = true,
isAllowedToConnectToHomeserver = false,
allowedAccountProviders = listOf(AN_ACCOUNT_PROVIDER_2),
elementWellKnown = anElementWellKnown(
enforceElementPro = false,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { false },
isElementProEnforcedResult = { false },
defaultHomeserverListResult = { listOf(AN_ACCOUNT_PROVIDER_2) },
),
)
accessControl.expectUnauthorizedAccountProviderException()
@@ -151,25 +146,14 @@ class DefaultAccountProviderAccessControlTest {
isEnterpriseBuild: Boolean = false,
isAllowedToConnectToHomeserver: Boolean = false,
allowedAccountProviders: List<String> = emptyList(),
elementWellKnown: ElementWellKnown? = null,
) = DefaultAccountProviderAccessControl(
enterpriseService = FakeEnterpriseService(
isEnterpriseBuild = isEnterpriseBuild,
enterpriseService: FakeEnterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { isAllowedToConnectToHomeserver },
defaultHomeserverListResult = { allowedAccountProviders },
),
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(
wellknownRetriever = FakeWellknownRetriever(
getElementWellKnownResult = { _, _ ->
if (elementWellKnown == null) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Success(elementWellKnown)
}
},
),
),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
isElementProEnforcedResult = { false },
)
) = DefaultAccountProviderAccessControl(
isEnterpriseBuild = { isEnterpriseBuild },
enterpriseService = enterpriseService,
)
private fun DefaultAccountProviderAccessControl.expectNeedElementProException() {
@@ -16,22 +16,15 @@ import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.auth.aMatrixHomeServerDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialog
import io.element.android.libraries.permissions.test.FakeLocalNetworkPermissionAdvisor
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -64,6 +57,7 @@ class ChangeServerPresenterTest {
authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false },
),
).test {
val initialState = awaitItem()
@@ -86,6 +80,7 @@ class ChangeServerPresenterTest {
createPresenter(
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false },
),
authenticationService = authenticationService,
).test {
@@ -113,6 +108,7 @@ class ChangeServerPresenterTest {
createPresenter(
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false },
),
authenticationService = authenticationService,
).test {
@@ -136,6 +132,7 @@ class ChangeServerPresenterTest {
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = isAllowedToConnectToHomeserverResult,
defaultHomeserverListResult = { listOf("element.io") },
isElementProEnforcedResult = { false },
),
).test {
val initialState = awaitItem()
@@ -159,23 +156,19 @@ class ChangeServerPresenterTest {
@Test
fun `present - change server element pro required error`() = runTest {
val getElementWellKnownResult = lambdaRecorder { homeserver: String, _: EnterpriseRemoteConfigSource ->
assertThat(homeserver).isEqualTo(A_HOMESERVER_URL)
WellknownRetrieverResult.Success(
anElementWellKnown(
enforceElementPro = true,
)
)
}
val isEnterpriseBuildLambda = lambdaRecorder<Boolean> { false }
createPresenter(
wellknownRetriever = FakeWellknownRetriever(
getElementWellKnownResult = getElementWellKnownResult,
),
isEnterpriseBuild = isEnterpriseBuildLambda,
enterpriseService = FakeEnterpriseService(
isElementProEnforcedResult = { true },
)
).test {
val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(AsyncData.Uninitialized)
val anAccountProvider = AccountProvider(url = A_HOMESERVER_URL)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(anAccountProvider))
// Skip loading state
skipItems(1)
val failureState = awaitItem()
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).unauthorisedAccountProviderTitle
@@ -183,7 +176,7 @@ class ChangeServerPresenterTest {
assertThat(
(failureState.changeServerAction.errorOrNull() as ChangeServerError.NeedElementPro).applicationId
).isEqualTo("io.element.enterprise")
getElementWellKnownResult.assertions().isCalledOnce()
isEnterpriseBuildLambda.assertions().isCalledOnce()
}
}
@@ -237,7 +230,7 @@ class ChangeServerPresenterTest {
val permissionsPresenter = FakePermissionsPresenter()
createPresenter(
authenticationService = authenticationService,
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }),
enterpriseService = FakeEnterpriseService(isAllowedToConnectToHomeserverResult = { true }, isElementProEnforcedResult = { false }),
localNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(shouldPrompt = true),
permissionsPresenter = permissionsPresenter,
).test {
@@ -259,16 +252,15 @@ class ChangeServerPresenterTest {
authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(),
accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(FakeEnterpriseService()),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
localNetworkPermissionAdvisor: FakeLocalNetworkPermissionAdvisor = FakeLocalNetworkPermissionAdvisor(),
permissionsPresenter: FakePermissionsPresenter = FakePermissionsPresenter(),
isEnterpriseBuild: () -> Boolean = { false },
) = ChangeServerPresenter(
authenticationService = authenticationService,
accountProviderDataSource = accountProviderDataSource,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
isEnterpriseBuild = isEnterpriseBuild,
),
localNetworkPermissionGate = LocalNetworkPermissionGate(
advisor = localNetworkPermissionAdvisor,
@@ -12,13 +12,12 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.features.login.impl.login.LoginModePresenter
import io.element.android.features.wellknown.test.FakeWellknownRetriever
import io.element.android.features.wellknown.test.FakeWellknownRetrieverFactory
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
@@ -29,7 +28,6 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
import io.element.android.libraries.matrix.test.A_LOGIN_HINT
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.oauth.api.OAuthActionFlow
@@ -41,7 +39,6 @@ import io.element.android.libraries.permissions.test.FakePermissionsPresenterFac
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.Flow
@@ -186,6 +183,7 @@ class OnBoardingPresenterTest {
enterpriseService = FakeEnterpriseService(
defaultHomeserverListResult = { listOf(ACCOUNT_PROVIDER_FROM_CONFIG, EnterpriseService.ANY_ACCOUNT_PROVIDER) },
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false },
),
)
presenter.test {
@@ -259,6 +257,7 @@ class OnBoardingPresenterTest {
),
enterpriseService = FakeEnterpriseService(
isAllowedToConnectToHomeserverResult = { true },
isElementProEnforcedResult = { false },
),
loginModePresenter = createLoginModePresenter(
authenticationService = authenticationService,
@@ -295,7 +294,7 @@ private fun createPresenter(
),
buildMeta: BuildMeta = aBuildMeta(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
isEnterpriseBuild: IsEnterpriseBuild = { false },
rageshakeFeatureAvailability: () -> Flow<Boolean> = { flowOf(true) },
loginModePresenter: LoginModePresenter = createLoginModePresenter(),
onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider = OnBoardingLogoResIdProvider { null },
@@ -307,8 +306,7 @@ private fun createPresenter(
enterpriseService = enterpriseService,
defaultAccountProviderAccessControl = DefaultAccountProviderAccessControl(
enterpriseService = enterpriseService,
wellknownRetrieverFactory = FakeWellknownRetrieverFactory(wellknownRetriever = wellknownRetriever),
temporaryMatrixClientFactory = FakeTemporaryMatrixClientFactory(),
isEnterpriseBuild = isEnterpriseBuild,
),
rageshakeFeatureAvailability = rageshakeFeatureAvailability,
loginModePresenter = loginModePresenter,
@@ -30,6 +30,7 @@ import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
@@ -50,6 +51,7 @@ class DeveloperSettingsPresenter(
private val databaseSizesUseCase: GetDatabaseSizesUseCase,
private val fileSizeFormatter: FileSizeFormatter,
private val markAllRoomsAsRead: MarkAllRoomsAsRead,
private val buildMeta: BuildMeta,
) : Presenter<DeveloperSettingsState> {
@Composable
override fun present(): DeveloperSettingsState {
@@ -117,7 +119,7 @@ class DeveloperSettingsPresenter(
databaseSizes = databaseSizes.value,
clearCacheAction = clearCacheAction.value,
markAllRoomsAsReadAction = markAllRoomsAsReadAction.value,
isEnterpriseBuild = enterpriseService.isEnterpriseBuild,
isEnterpriseBuild = buildMeta.isEnterpriseBuild,
showColorPicker = showColorPicker,
deviceId = deviceId,
eventSink = ::handleEvent,
@@ -23,12 +23,14 @@ import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.data.megaBytes
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -108,10 +110,8 @@ class DeveloperSettingsPresenterTest {
fun `present - enterprise build can change the brand color`() = runTest {
val overrideBrandColorResult = lambdaRecorder<SessionId?, String?, Unit> { _, _ -> }
val presenter = createDeveloperSettingsPresenter(
enterpriseService = FakeEnterpriseService(
isEnterpriseBuild = true,
overrideBrandColorResult = overrideBrandColorResult,
)
enterpriseService = FakeEnterpriseService(overrideBrandColorResult = overrideBrandColorResult),
buildMeta = aBuildMeta(isEnterpriseBuild = true),
)
presenter.test {
skipItems(1)
@@ -179,6 +179,7 @@ class DeveloperSettingsPresenterTest {
vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {},
databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) },
markAllRoomsAsRead: FakeMarkAllRoomsAsRead = FakeMarkAllRoomsAsRead(),
buildMeta: BuildMeta = aBuildMeta(),
): DeveloperSettingsPresenter {
return DeveloperSettingsPresenter(
appDeveloperSettingsPresenter = { anAppDeveloperSettingsState() },
@@ -191,6 +192,7 @@ class DeveloperSettingsPresenterTest {
databaseSizesUseCase = databaseSizesUseCase,
fileSizeFormatter = FakeFileSizeFormatter(),
markAllRoomsAsRead = markAllRoomsAsRead,
buildMeta = buildMeta,
)
}
}
@@ -1,23 +0,0 @@
/*
* 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.libraries.wellknown.api
/**
* Server-driven settings for a user-chosen recovery passphrase, advertised under the well-known
* `custom_recovery_passphrase` key. Today the only rule is a minimum character count; additional
* settings can be added here as the schema grows.
*/
data class CustomRecoveryPassphrase(
val minCharacterCount: Int,
) {
/** True when [input] meets every active rule. */
fun isSatisfiedBy(input: String): Boolean = isSatisfiedBy(input.length)
/** True when an input of [length] characters meets every active rule. */
fun isSatisfiedBy(length: Int): Boolean = length >= minCharacterCount
}
@@ -1,20 +0,0 @@
/*
* 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.libraries.wellknown.api
/**
* Parses the raw JSON of an Element `.well-known` document.
*/
fun interface ElementWellKnownParser {
/**
* Parses the document, failing when it is not valid JSON; unknown fields are ignored so a newer server does not break an older client.
*
* @param json the raw document as served by the server.
*/
fun parse(json: String): Result<ElementWellKnown>
}
@@ -1,47 +0,0 @@
/*
* 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.libraries.wellknown.api
/**
* On-disk cache of the Element `.well-known` documents already fetched, so the app has a configuration to work with while offline.
*/
interface ElementWellknownStore {
/**
* Reads the cached document of a domain.
* Entries older than a day are reported as outdated rather than missing, so the caller can use them while refreshing;
* a document that no longer parses is reported as an error.
*
* @param domain the server whose cached document is requested.
*/
suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown>
/**
* Stores the document of a domain and stamps it as fetched now, replacing any previous entry.
*
* @param domain the server the document belongs to.
* @param wellknown the raw document, stored as-is rather than parsed.
*/
suspend fun update(domain: String, wellknown: String): Result<Unit>
/**
* Removes the cached document of a domain, used when the stored value turns out to be unusable.
*
* @param domain the server whose entry is removed.
*/
suspend fun delete(domain: String): Result<Unit>
/**
* Creates a store, one per configuration source so that their entries never collide.
*/
fun interface Factory {
/**
* @param prefix namespaces the cache keys, or `null` for the default well-known endpoint.
*/
fun create(prefix: String?): ElementWellknownStore
}
}
@@ -1,23 +0,0 @@
/*
* 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.libraries.wellknown.api
/**
* The source from which the enterprise remote configuration is retrieved.
*/
enum class EnterpriseRemoteConfigSource {
/**
* Retrieve the enterprise remote configuration from the Element Server Suite configuration (ESS_CONFIG) endpoint.
*/
ESS_CONFIG,
/**
* Retrieve the enterprise remote configuration from the Element server's well-known endpoint.
*/
WELLKNOWN_ENDPOINT,
}
@@ -1,39 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.libraries.wellknown.api
import io.element.android.libraries.matrix.api.UrlContentFetcher
/**
* Retrieves the Element specific `.well-known` configuration of a server, which is how a deployment advertises its own settings.
*/
interface WellknownRetriever {
/**
* Returns the configuration of a host, served from the local cache when possible.
* A cached value that has gone stale is returned as outdated while a refresh runs in the background, so this rarely waits on the network.
* An enterprise build with a hardcoded configuration always short-circuits and returns that instead.
*
* @param host the server to look up; a full URL is accepted and reduced to its host.
* @param source which endpoint to read the configuration from, which also selects the cache it is stored in.
*/
suspend fun getElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource
): WellknownRetrieverResult<ElementWellKnown>
/**
* Creates a retriever, which needs a fetcher because the configuration is read over HTTP.
*/
interface Factory {
/**
* @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works.
*/
fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever
}
}
@@ -1,27 +0,0 @@
/*
* 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.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
@ContributesBinding(AppScope::class)
class DefaultElementWellKnownParser(
private val jsonProvider: JsonProvider,
) : ElementWellKnownParser {
override fun parse(json: String): Result<ElementWellKnown> {
return runCatchingExceptions {
val decoder = jsonProvider()
decoder.decodeFromString<InternalElementWellKnown>(json).map()
}
}
}
@@ -1,77 +0,0 @@
/*
* 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.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
@AssistedInject
class DefaultWellKnownStore(
@Assisted
private val prefix: String?,
private val cacheStore: CacheStore,
private val elementWellKnownParser: ElementWellKnownParser,
private val systemClock: SystemClock,
) : ElementWellknownStore {
@AssistedFactory
@ContributesBinding(AppScope::class)
fun interface Factory : ElementWellknownStore.Factory {
override fun create(prefix: String?): DefaultWellKnownStore
}
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
return runCatchingExceptions {
val cachedData = cacheStore.getData(key(domain))
if (cachedData != null) {
val data = elementWellKnownParser.parse(cachedData.value).getOrThrow()
if (systemClock.epochMillis() > cachedData.updatedAt + CACHE_VALIDITY_MILLIS) {
WellknownRetrieverResult.Outdated(data)
} else {
WellknownRetrieverResult.Success(data)
}
} else {
WellknownRetrieverResult.NotFound
}
}.recover {
WellknownRetrieverResult.Error(it as Exception)
}.getOrThrow()
}
override suspend fun update(domain: String, wellknown: String): Result<Unit> {
return runCatchingExceptions {
cacheStore.storeData(key(domain), CacheData(wellknown, systemClock.epochMillis()))
}
}
override suspend fun delete(domain: String): Result<Unit> {
return runCatchingExceptions {
cacheStore.deleteData(key(domain))
}
}
private fun key(domain: String): String = if (prefix != null) {
"$prefix-$domain"
} else {
"https://$domain/.well-known/element/element.json"
}
companion object {
// 1 day
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
}
}
@@ -1,142 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.text.takeIfNotBlank
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.net.URL
@AssistedInject
class DefaultWellknownRetriever(
private val elementWellknownStoreFactory: ElementWellknownStore.Factory,
private val enterpriseService: EnterpriseService,
private val elementWellKnownParser: ElementWellKnownParser,
@Assisted private val urlContentFetcher: UrlContentFetcher,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : WellknownRetriever {
@ContributesBinding(AppScope::class)
@AssistedFactory
fun interface Factory : WellknownRetriever.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): DefaultWellknownRetriever
}
override suspend fun getElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource
): WellknownRetrieverResult<ElementWellKnown> {
val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown()
if (overriddenElementWellKnown != null) {
return WellknownRetrieverResult.Success(overriddenElementWellKnown)
}
// The passed `host` function may be a full URL, in that case we're only interested in the actual host
val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() }
.getOrElse { return WellknownRetrieverResult.Error(IllegalArgumentException("host parameter is not valid", it)) }
?: host
// We instantiate different stores for different sources, so that we can cache them separately.
// The ESS_CONFIG source is cached with a prefix to differentiate it from the WELLKNOWN_ENDPOINT.
val prefix = when (source) {
EnterpriseRemoteConfigSource.ESS_CONFIG -> "ess_config"
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> null
}
val store = elementWellknownStoreFactory.create(prefix)
return when (val cacheData = store.get(checkedHost)) {
is WellknownRetrieverResult.Success -> {
Timber.d("Using cached well-known for domain $checkedHost")
cacheData
}
is WellknownRetrieverResult.Outdated -> {
// Return the outdated data but refresh in the background
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
Timber.d("Outdated cached well-known for domain $checkedHost, returning existing value and fetching new one from network")
coroutineScope.launch { fetchElementWellKnown(checkedHost, source, store) }
cacheData
}
is WellknownRetrieverResult.NotFound -> {
// Try to fetch from the server
Timber.d("No cached well-known for domain $checkedHost, fetching from network")
fetchElementWellKnown(checkedHost, source, store)
}
is WellknownRetrieverResult.Error -> {
// Return the error
Timber.e(cacheData.exception, "Error retrieving well-known for domain $checkedHost")
cacheData.exception.toWellknownRetrieverResult()
}
}
}
private suspend fun fetchElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource,
store: ElementWellknownStore
): WellknownRetrieverResult<ElementWellKnown> {
return urlContentFetcher
.getUrl(url(host, source))
.mapCatchingExceptions {
val data = String(it)
val parsed = elementWellKnownParser.parse(data).getOrThrow()
// Also store in cache, if valid
store.update(host, data)
.onFailure { exception ->
Timber.e(exception, "Failed to parse cached Element .well-known data for $host, deleting cache")
store.delete(host)
}
parsed
}
.toWellknownRetrieverResult(host)
}
private fun <T> Result<T>.toWellknownRetrieverResult(host: String): WellknownRetrieverResult<T> = fold(
onSuccess = {
WellknownRetrieverResult.Success(it)
},
onFailure = {
Timber.e(it, "Failed to retrieve Element .well-known from $host")
// This check on message value is not ideal but this is what we got from the SDK.
it.toWellknownRetrieverResult()
}
)
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(this as Exception)
}
}
private fun url(host: String, source: EnterpriseRemoteConfigSource): String {
val elementWellKnownUrl = "https://$host/.well-known/element/element.json"
return when (source) {
EnterpriseRemoteConfigSource.ESS_CONFIG -> enterpriseService.essConfigEndpointUrl(host) ?: elementWellKnownUrl
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> elementWellKnownUrl
}
}
}
@@ -1,71 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.libraries.wellknown.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Example:
* <pre>
* {
* "registration_helper_url": "https://element.io",
* "enforce_element_pro": true,
* "rageshake_url": "https://example.org/rageshake",
* "brand_color": "#FF0000",
* "notification_sound": "ring.flac",
* "idp_app_scheme": "io.element.app",
* "custom_recovery_passphrase": {
* "min_character_count": 8
* }
* }
* </pre>
* .
*/
@Serializable
data class InternalElementWellKnown(
@SerialName("registration_helper_url")
val registrationHelperUrl: String? = null,
@SerialName("enforce_element_pro")
val enforceElementPro: Boolean? = null,
@SerialName("rageshake_url")
val rageshakeUrl: String? = null,
@SerialName("brand_color")
val brandColor: String? = null,
@SerialName("notification_sound")
val notificationSound: String? = null,
@SerialName("idp_app_scheme")
val identityProviderAppScheme: String? = null,
@SerialName("custom_recovery_passphrase")
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
@SerialName("content_scanner_url")
val contentScannerUrl: String? = null,
@SerialName("force_disable_e2ee")
val forceDisableE2EE: Boolean? = null,
@SerialName("map_tiler")
val mapTilerConfig: InternalMapTilerConfig? = null,
)
@Serializable
data class InternalCustomRecoveryPassphrase(
@SerialName("min_character_count")
val minCharacterCount: Int? = null,
)
@Serializable
data class InternalMapTilerConfig(
@SerialName("api_key")
val apiKey: String,
@SerialName("light_style_id")
val lightStyleId: String? = null,
@SerialName("dark_style_id")
val darkStyleId: String? = null,
@SerialName("base_url")
val baseUrl: String? = null,
)
@@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.libraries.wellknown.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "m.homeserver": {
* "base_url": "https://matrix.org"
* },
* "m.identity_server": {
* "base_url": "https://vector.im"
* },
* }
* </pre>
* .
*/
@Serializable
data class InternalWellKnown(
@SerialName("m.homeserver")
val homeServer: InternalWellKnownBaseConfig? = null,
@SerialName("m.identity_server")
val identityServer: InternalWellKnownBaseConfig? = null,
)
@@ -1,27 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.libraries.wellknown.impl
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
* <pre>
* {
* "base_url": "https://element.io"
* }
* </pre>
* .
*/
@Serializable
data class InternalWellKnownBaseConfig(
@SerialName("base_url")
val baseURL: String? = null
)
@@ -1,59 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.libraries.wellknown.impl
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.MapTilerConfig
import timber.log.Timber
private val loggerTag = LoggerTag("Wellknown")
/**
* Floor for the recovery passphrase minimum length. A value of 1 guarantees the derived passphrase is
* never empty (an empty passphrase derives a recoverable but effectively unprotected secret-storage
* key). Anything stronger than non-empty is left to the homeserver operator's configuration.
*/
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
rageshakeUrl = rageshakeUrl,
brandColor = brandColor,
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
contentScannerUrl = contentScannerUrl,
forceDisableE2EE = forceDisableE2EE,
mapTilerConfig = mapTilerConfig?.let {
MapTilerConfig(
apiKey = it.apiKey,
lightStyleId = it.lightStyleId,
darkStyleId = it.darkStyleId,
baseUrl = it.baseUrl,
)
}
)
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
// Whenever the homeserver advertises the settings block we run the custom passphrase flow, flooring
// the minimum at 1 so the passphrase can never be empty even if the server omits min_character_count
// or advertises a non-positive value. The operator owns any stronger minimum.
val min = (minCharacterCount ?: MINIMUM_PASSPHRASE_CHARACTER_COUNT).coerceAtLeast(MINIMUM_PASSPHRASE_CHARACTER_COUNT)
if (min != minCharacterCount) {
Timber.tag(loggerTag.value).w(
"custom_recovery_passphrase.min_character_count was %s; flooring to %d",
minCharacterCount,
min,
)
}
return CustomRecoveryPassphrase(minCharacterCount = min)
}
@@ -1,41 +0,0 @@
/*
* 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.libraries.wellknown.impl
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetriever
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(SessionScope::class)
object SessionWellknownModule {
@Provides
fun provideWellKnownRetriever(
elementWellknownStoreFactory: ElementWellknownStore.Factory,
enterpriseService: EnterpriseService,
elementWellKnownParser: ElementWellKnownParser,
urlContentFetcher: UrlContentFetcher,
@SessionCoroutineScope coroutineScope: CoroutineScope,
): WellknownRetriever {
return DefaultWellknownRetriever(
elementWellknownStoreFactory = elementWellknownStoreFactory,
enterpriseService = enterpriseService,
elementWellKnownParser = elementWellKnownParser,
urlContentFetcher = urlContentFetcher,
coroutineScope = coroutineScope
)
}
}
@@ -1,19 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.libraries.wellknown.impl
import retrofit2.http.GET
internal interface WellknownAPI {
@GET(".well-known/matrix/client")
suspend fun getWellKnown(): InternalWellKnown
@GET(".well-known/element/element.json")
suspend fun getElementWellKnown(): InternalElementWellKnown
}
@@ -1,33 +0,0 @@
/*
* 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.libraries.wellknown.api
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class CustomRecoveryPassphraseTest {
@Test
fun `empty input never satisfies a positive minimum`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 1).isSatisfiedBy("")).isFalse()
}
@Test
fun `input shorter than minimum is not satisfied`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abc")).isFalse()
}
@Test
fun `input exactly at minimum is satisfied`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 8).isSatisfiedBy("abcdefgh")).isTrue()
}
@Test
fun `input longer than minimum is satisfied`() {
assertThat(CustomRecoveryPassphrase(minCharacterCount = 4).isSatisfiedBy("abcdefgh")).isTrue()
}
}
@@ -1,174 +0,0 @@
/*
* 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.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val A_DOMAIN = "matrix.example.com"
private const val A_CACHE_KEY = "https://$A_DOMAIN/.well-known/element/element.json"
// 1 day in millis
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
class DefaultWellKnownStoreTest {
private val jsonProvider = DefaultJsonProvider()
@Test
fun `get returns NotFound when cache is empty`() = runTest {
val sut = createDefaultWellKnownStore()
assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound)
}
@Test
fun `get returns Success when cache has fresh data`() = runTest {
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
val jsonString = jsonProvider().encodeToString(wellKnown)
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map()))
}
@Test
fun `get returns Outdated when cached data has expired`() = runTest {
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
val jsonString = jsonProvider().encodeToString(wellKnown)
// Store data at timestamp 0, but clock is past the validity window
val expiredTimestamp = 0L
val nowTimestamp = expiredTimestamp + CACHE_VALIDITY_MILLIS + 1
val clock = FakeSystemClock(epochMillisResult = nowTimestamp)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, expiredTimestamp))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Outdated(wellKnown.map()))
}
@Test
fun `get returns Error when cached data is invalid JSON`() = runTest {
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData("not valid json at all", A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore)
val result = sut.get(A_DOMAIN)
assertThat(result).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `update stores data and get returns Success`() = runTest {
val wellKnown = """
{
"registration_helper_url": "https://element.io",
"enforce_element_pro": true,
"rageshake_url": "https://example.org/rageshake",
"brand_color": "#FF0000",
"notification_sound": "ring.flac",
"idp_app_scheme": "io.element.app",
"custom_recovery_passphrase": {
"min_character_count": 8
}
}
""".trimIndent()
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore()
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val updateResult = sut.update(A_DOMAIN, wellKnown)
assertThat(updateResult.isSuccess).isTrue()
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(anElementWellKnown(
registrationHelperUrl = "https://element.io",
enforceElementPro = true,
rageshakeUrl = "https://example.org/rageshake",
brandColor = "#FF0000",
notificationSound = "ring.flac",
identityProviderAppScheme = "io.element.app",
customRecoveryPassphrase = CustomRecoveryPassphrase(8),
)))
}
@Test
fun `delete removes data and get returns NotFound`() = runTest {
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
val jsonString = jsonProvider().encodeToString(wellKnown)
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val deleteResult = sut.delete(A_DOMAIN)
assertThat(deleteResult.isSuccess).isTrue()
assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound)
}
@Test
fun `get returns data at the exact cache validity boundary as Success`() = runTest {
val wellKnown = InternalElementWellKnown(notificationSound = "ping.flac")
val jsonString = jsonProvider().encodeToString(wellKnown)
// Exactly at the boundary: epochMillis == updatedAt + CACHE_VALIDITY_MILLIS → not expired
val storedAt = 1000L
val nowAtBoundary = storedAt + CACHE_VALIDITY_MILLIS
val clock = FakeSystemClock(epochMillisResult = nowAtBoundary)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, storedAt))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map()))
}
@Test
fun `prefix is used`() = runTest {
val prefix = "test-prefix"
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore(
initialData = mapOf("$prefix-$A_DOMAIN" to CacheData("{}", A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(prefix = prefix, cacheStore = cacheStore, systemClock = clock)
assertThat(sut.get(A_DOMAIN)).isInstanceOf(WellknownRetrieverResult.Success::class.java)
}
private fun createDefaultWellKnownStore(
prefix: String? = null,
cacheStore: InMemoryCacheStore = InMemoryCacheStore(),
systemClock: FakeSystemClock = FakeSystemClock(),
elementWellKnownParser: ElementWellKnownParser = DefaultElementWellKnownParser(jsonProvider),
) = DefaultWellKnownStore(
prefix = prefix,
cacheStore = cacheStore,
systemClock = systemClock,
elementWellKnownParser = elementWellKnownParser,
)
}
@@ -1,241 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.wellknown.test.FakeElementWellknownStore
import io.element.android.features.wellknown.test.aMapTilerConfig
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.net.URL
class DefaultWellknownRetrieverTest {
@Test
fun `get element wellknown calls resolver and parser`() = runTest {
val parser = lambdaRecorder<String, Result<ElementWellKnown>> { Result.success(anElementWellKnown()) }
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
elementWellKnownParser = parser,
urlResolver = resolver,
)
sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)
resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL))
parser.assertions().isCalledOnce().with(value(WELLKNOWN_CONTENT))
}
@Test
fun `get element wellknown parser error`() = runTest {
val sut = createDefaultWellknownRetriever(
elementWellKnownParser = { Result.failure(IllegalStateException("Invalid JSON")) },
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown network error`() = runTest {
val resolver = SpyUrlContentFetcher { Result.failure(IllegalStateException("Network error")) }
val sut = createDefaultWellknownRetriever(urlResolver = resolver)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown 404 http error counts as not found`() = runTest {
val resolver = SpyUrlContentFetcher { Result.failure(ClientException.Generic("Http error: 404", null)) }
val sut = createDefaultWellknownRetriever(urlResolver = resolver)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
}
@Test
fun `get element wellknown will return an error if the provided host is not valid`() = runTest {
val sut = createDefaultWellknownRetriever()
assertThat(sut.getElementWellKnown("!not valid", EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown will return a cached value if present instead of fetching`() = runTest {
val host = URL(WELLKNOWN_URL).host.ensureProtocol()
val sut = createDefaultWellknownRetriever(
cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Success(expectedElementWellKnown))) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Success::class.java)
}
@Test
fun `get element wellknown will return an outdated value first then fetch the current one`() = runTest {
val host = URL(WELLKNOWN_URL).host.ensureProtocol()
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
urlResolver = resolver,
cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Outdated(expectedElementWellKnown))) }
)
// The first call returns the outdated value
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Outdated::class.java)
// The resolver is not called yet because the fetch is done in the background
resolver.assertions().isNeverCalled()
// We give it some time to run the background fetch
runCurrent()
// The resolver is called during the background fetch
resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL))
}
@Test
fun `get element wellknown will return an error if it happens when trying to retrieve the cached value`() = runTest {
val host = URL(WELLKNOWN_URL).host.ensureProtocol()
val sut = createDefaultWellknownRetriever(
cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Error(IllegalStateException("BOOM")))) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown will return an error if it fails to serialize the retrieved value into the cache`() = runTest {
val sut = createDefaultWellknownRetriever(
elementWellKnownParser = { Result.failure(IllegalStateException("BOOM")) },
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `WELLKNOWN_ENDPOINT source creates a store with no prefix`() = runTest {
val storeFactory = lambdaRecorder<String?, ElementWellknownStore> { FakeElementWellknownStore() }
val sut = createDefaultWellknownRetriever(cacheStoreFactory = storeFactory)
sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)
storeFactory.assertions().isCalledOnce().with(value(null))
}
@Test
fun `ESS_CONFIG source creates a store with ess prefix and checks the ESS config endpoint`() = runTest {
val essConfigUrl = "https://ess-config.example.com"
val storeFactory = lambdaRecorder<String?, ElementWellknownStore> { FakeElementWellknownStore() }
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
cacheStoreFactory = storeFactory,
enterpriseService = FakeEnterpriseService(
overrideWellKnownResult = { null },
essConfigEndpointUrlResult = { essConfigUrl },
),
urlResolver = resolver,
)
sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.ESS_CONFIG)
storeFactory.assertions().isCalledOnce().with(value("ess_config"))
resolver.assertions().isCalledOnce().with(value(essConfigUrl))
}
@Test
fun `get element wellknown was overridden`() = runTest {
val wellKnown = anElementWellKnown()
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
enterpriseService = FakeEnterpriseService(
overrideWellKnownResult = { wellKnown },
),
urlResolver = resolver,
)
// The overridden value is returned
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)).isEqualTo(
WellknownRetrieverResult.Success(wellKnown)
)
// And the endpoint is never hit
resolver.assertions().isNeverCalled()
}
private fun TestScope.createDefaultWellknownRetriever(
cacheStoreFactory: ElementWellknownStore.Factory = { FakeElementWellknownStore() },
elementWellKnownParser: ElementWellKnownParser = { Result.success(expectedElementWellKnown) },
enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }),
urlResolver: UrlContentFetcher = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) },
) = DefaultWellknownRetriever(
elementWellknownStoreFactory = cacheStoreFactory,
enterpriseService = enterpriseService,
elementWellKnownParser = elementWellKnownParser,
urlContentFetcher = urlResolver,
coroutineScope = backgroundScope,
)
companion object {
private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json"
private const val WELLKNOWN_CONTENT = """{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url",
"brand_color": "#FF0000",
"notification_sound": "a_notification_sound.flac",
"idp_app_scheme": "an_app_scheme",
"content_scanner_url": "https://content-scanner.example.com",
"force_disable_e2ee": false,
"map_tiler": {
"api_key": "test_api_key",
"base_url": "https://tile-server.example.com"
}
}"""
private val expectedElementWellKnown = ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
contentScannerUrl = "https://content-scanner.example.com",
customRecoveryPassphrase = null,
forceDisableE2EE = false,
mapTilerConfig = aMapTilerConfig(
apiKey = "test_api_key",
baseUrl = "https://tile-server.example.com",
)
)
}
}
private class SpyUrlContentFetcher(lambda: (String) -> Result<ByteArray>) : UrlContentFetcher {
private val spy = lambdaRecorder<String, Result<ByteArray>> { lambda(it) }
override suspend fun getUrl(url: String): Result<ByteArray> {
return spy(url)
}
fun assertions() = spy.assertions()
}
@@ -1,53 +0,0 @@
/*
* 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.wellknown.test
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.libraries.wellknown.impl.InternalElementWellKnown
import io.element.android.libraries.wellknown.impl.map
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.JsonDecodingException
import java.net.URL
class FakeElementWellknownStore(
initialData: Map<String, WellknownRetrieverResult<ElementWellKnown>> = emptyMap(),
private val jsonProvider: JsonProvider = DefaultJsonProvider(),
) : ElementWellknownStore {
private val cachedData = mutableMapOf<String, WellknownRetrieverResult<ElementWellKnown>>()
init {
initialData.forEach { (url, result) ->
cachedData[URL(url).host] = result
}
}
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
return cachedData[domain] ?: WellknownRetrieverResult.NotFound
}
@OptIn(ExperimentalSerializationApi::class)
override suspend fun update(domain: String, wellknown: String): Result<Unit> {
val json = jsonProvider()
val mapped = try {
json.decodeFromString<InternalElementWellKnown>(wellknown).map()
} catch (e: JsonDecodingException) {
return Result.failure(e)
}
cachedData[domain] = WellknownRetrieverResult.Success(mapped)
return Result.success(Unit)
}
override suspend fun delete(domain: String): Result<Unit> {
cachedData.remove(domain)
return Result.success(Unit)
}
}
@@ -1,24 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.wellknown.test
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeWellknownRetriever(
private val getElementWellKnownResult: (String, EnterpriseRemoteConfigSource) -> WellknownRetrieverResult<ElementWellKnown> = { _, _ ->
WellknownRetrieverResult.NotFound
},
) : WellknownRetriever {
override suspend fun getElementWellKnown(host: String, source: EnterpriseRemoteConfigSource): WellknownRetrieverResult<ElementWellKnown> {
return getElementWellKnownResult(host, source)
}
}
@@ -1,17 +0,0 @@
/*
* 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.wellknown.test
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.wellknown.api.WellknownRetriever
class FakeWellknownRetrieverFactory(
private val wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) : WellknownRetriever.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever = wellknownRetriever
}
@@ -1,55 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector 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.wellknown.test
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.MapTilerConfig
fun anElementWellKnown(
registrationHelperUrl: String? = null,
enforceElementPro: Boolean? = null,
rageshakeUrl: String? = null,
brandColor: String? = null,
notificationSound: String? = null,
identityProviderAppScheme: String? = null,
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
contentScannerUrl: String? = null,
forceDisableE2EE: Boolean? = null,
mapTilerConfig: MapTilerConfig? = null,
) = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
rageshakeUrl = rageshakeUrl,
brandColor = brandColor,
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase,
contentScannerUrl = contentScannerUrl,
forceDisableE2EE = forceDisableE2EE,
mapTilerConfig = mapTilerConfig,
)
fun aCustomRecoveryPassphrase(
minCharacterCount: Int = 8,
) = CustomRecoveryPassphrase(
minCharacterCount = minCharacterCount,
)
fun aMapTilerConfig(
apiKey: String = "test_api_key",
baseUrl: String? = null,
lightStyleId: String? = null,
darkStyleId: String? = null,
) = MapTilerConfig(
apiKey = apiKey,
baseUrl = baseUrl,
lightStyleId = lightStyleId,
darkStyleId = darkStyleId,
)
@@ -9,7 +9,6 @@
package io.element.android.services.analyticsproviders.posthog
import dev.zacsweers.metro.Inject
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.extensions.isElement
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
@@ -17,10 +16,9 @@ import io.element.android.libraries.core.meta.BuildType
@Inject
class PosthogEndpointConfigProvider(
private val buildMeta: BuildMeta,
private val enterpriseService: EnterpriseService,
) {
fun provide(): PosthogEndpointConfig? {
return if (enterpriseService.isEnterpriseBuild) {
return if (buildMeta.isEnterpriseBuild) {
PosthogEndpointConfig(
host = BuildConfig.POSTHOG_HOST,
apiKey = BuildConfig.POSTHOG_APIKEY,