Initial content scanner integration (#7139)

* Create a `ContentScanner` component:

- Add `:features:contentscanner:api` module with the interface.
- Implement the concrete component using the Rust SDK in `:libraries:matrix:impl`.

This can be used to both set up the content scanner media fetching in the SDK Client and manually scanning media sources from the Android side

* Create `ContentScannerUrlProvider` in a new `:features:contentscaner:api` module as a noop implementation

Use it as a placeholder for the code to instantiate a `RustContentScanner` with the fetched URL and set it as part of the SDK client.

* Expose the optional `ContentScanner` in the `@SessionScope`

* Split the storage part of the Element well-known retriever so it can be reused for the non-session bound retriever.

Also add the new `content_scanner_url` field to it.

This will be used by the enterprise code to fetch the actual content scanner url.

* Bump enterprise commit to have access to the actual content scanner url provider implementation

* Fix tests and lint issues

* Use the right namespace for `:features:contentscanner:api`, also use the right namespace for `impl`

---------

Co-authored-by: Benoit Marty <benoitm@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-07-08 09:50:22 +02:00
committed by GitHub
parent 7c3d1e2e80
commit 885e042c12
33 changed files with 988 additions and 74 deletions
@@ -0,0 +1,23 @@
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.api"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -0,0 +1,35 @@
/*
* 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.
*/
import extension.setupDependencyInjection
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
}
android {
namespace = "io.element.android.features.contentscanner.impl"
}
setupDependencyInjection()
dependencies {
api(projects.features.contentscanner.api)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
}
@@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
@@ -71,6 +72,7 @@ interface MatrixClient {
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
val roomMembershipObserver: RoomMembershipObserver
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
val contentScanner: ContentScanner?
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
suspend fun getRoom(roomId: RoomId): BaseRoom?
suspend fun findDM(userId: UserId): Result<RoomId?>
@@ -0,0 +1,27 @@
/*
* 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.matrix.api.scanner
import io.element.android.libraries.matrix.api.media.MediaSource
/**
* Component used to manually scan media content for potential security risks.
*
* While the Matrix SDK automatically scans media content we load if a content scanner instance is provided, this interface allows for scanning
* some media sources in advance without actually loading their contents, which can be useful for pre-fetching scenarios.
*/
interface ContentScanner {
/**
* Manually scans the given [mediaSource] for potential security risks.
*
* @param mediaSource The media source to scan.
* @return A [Result] containing a [Boolean] indicating whether the content is safe (true) or potentially unsafe (false).
* If the scan fails, the [Result] will contain an exception.
*/
suspend fun scan(mediaSource: MediaSource): Result<Boolean>
}
@@ -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.libraries.matrix.api.scanner
/**
* Provides the URL of the content scanner service for a given homeserver, if any is set up.
*/
fun interface ContentScannerUrlProvider {
/**
* Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up.
*/
suspend fun getContentScannerUrl(homeserver: String): Result<String?>
}
@@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibilit
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState
@@ -150,6 +151,7 @@ class RustMatrixClient(
private val featureFlagService: FeatureFlagService,
private val analyticsService: AnalyticsService,
private val workManagerScheduler: WorkManagerScheduler,
override val contentScanner: ContentScanner?,
) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
@@ -17,11 +17,14 @@ import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.scanner.RustContentScanner
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.network.useragent.UserAgentProvider
@@ -34,6 +37,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.Session
@@ -66,6 +70,7 @@ class RustMatrixClientFactory(
private val clientBuilderProvider: ClientBuilderProvider,
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
private val workManagerScheduler: WorkManagerScheduler,
private val contentScannerUrlProvider: ContentScannerUrlProvider,
) {
private val sessionDelegate = RustClientSessionDelegate(
sessionStore = sessionStore,
@@ -113,6 +118,23 @@ class RustMatrixClientFactory(
client.setUtdDelegate(UtdTracker(analyticsService))
val domainName = UserId(client.userId()).domainName
// If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client.
// This allows the SDK to use the content scanner for automatic media scanning.
// If no content scanner URL is available, the contentScanner will be null.
val contentScanner = domainName?.let {
contentScannerUrlProvider.getContentScannerUrl(domainName)
.getOrNull()
?.let { contentScannerUrl ->
val contentScanner = ContentScanner(contentScannerUrl)
client.setContentScanner(contentScanner)
RustContentScanner(
client = client,
rustScanner = contentScanner,
)
}
}
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
@@ -132,6 +154,7 @@ class RustMatrixClientFactory(
featureFlagService = featureFlagService,
analyticsService = analyticsService,
workManagerScheduler = workManagerScheduler,
contentScanner = contentScanner,
).also {
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
}
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -96,4 +97,9 @@ object SessionMatrixModule {
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
return matrixClient.homeserverCapabilities()
}
@Provides
fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? {
return matrixClient.contentScanner
}
}
@@ -0,0 +1,20 @@
/*
* 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.matrix.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
}
@@ -0,0 +1,42 @@
/*
* 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.matrix.impl.scanner
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustContentScanner(
private val client: Client,
private val rustScanner: RustScanner,
) : ContentScanner {
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
return runCatchingExceptions {
rustScanner.scan(client, mediaSource.toRustMediaSource()).clean
}
}
}
private fun MediaSource.toRustMediaSource(): RustMediaSource {
val json = this.json
return try {
if (json != null) {
RustMediaSource.fromJson(json)
} else {
RustMediaSource.fromUrl(safeUrl)
}
} catch (e: LinkageError) {
// Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
if (isTest) RustMediaSource(NoHandle) else throw e
}
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
@@ -51,6 +52,7 @@ fun TestScope.createRustMatrixClientFactory(
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -65,4 +67,5 @@ fun TestScope.createRustMatrixClientFactory(
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProvider = contentScannerUrlProvider,
)
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -156,5 +157,6 @@ class RustMatrixClientTest {
featureFlagService = FakeFeatureFlagService(),
analyticsService = FakeAnalyticsService(),
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
contentScanner = FakeContentScanner(),
)
}
@@ -0,0 +1,24 @@
/*
* 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.matrix.impl.scanner
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.NoHandle
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
class FakeFfiContentScanner(
private val scan: suspend (client: Client, mediaSource: MediaSource) -> MediaScanResponse = { _, _ ->
MediaScanResponse(clean = true, info = "Just peachy")
},
) : ContentScanner(NoHandle) {
override suspend fun scan(client: Client, mediaSource: MediaSource): MediaScanResponse {
return scan.invoke(client, mediaSource)
}
}
@@ -0,0 +1,56 @@
/*
* 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.matrix.impl.scanner
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.test.media.aMediaSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
class RustContentScannerTest {
@Test
fun `test successful valid scan`() = runTest {
val scanner = RustContentScanner(
client = FakeFfiClient(),
rustScanner = FakeFfiContentScanner(),
)
scanner.scan(aMediaSource()).run {
assertThat(isSuccess).isTrue()
assertThat(getOrNull()).isTrue()
}
}
@Test
fun `test successful invalid scan`() = runTest {
val scanner = RustContentScanner(
client = FakeFfiClient(),
rustScanner = FakeFfiContentScanner(scan = { _, _ -> MediaScanResponse(clean = false, info = "Not clean") })
)
scanner.scan(aMediaSource()).run {
assertThat(isSuccess).isTrue()
assertThat(getOrNull()).isFalse()
}
}
@Test
fun `test failed scan`() = runTest {
val scanner = RustContentScanner(
client = FakeFfiClient(),
rustScanner = FakeFfiContentScanner(scan = { _, _ -> throw IllegalStateException("BOOM") })
)
scanner.scan(aMediaSource()).run {
assertThat(isFailure).isTrue()
assertThat(getOrNull()).isNull()
}
}
}
@@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias
import io.element.android.libraries.matrix.api.room.location.BeaconInfoUpdate
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncService
@@ -122,6 +123,7 @@ class FakeMatrixClient(
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
override val contentScanner: ContentScanner? = null
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -0,0 +1,19 @@
/*
* 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.matrix.test.scanner
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
class FakeContentScanner(
private val scan: suspend (mediaSource: MediaSource) -> Result<Boolean> = { Result.success(true) },
) : ContentScanner {
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
return scan.invoke(mediaSource)
}
}
@@ -16,6 +16,7 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
import io.element.android.libraries.network.interceptors.UserAgentInterceptor
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
@@ -36,6 +37,9 @@ object NetworkModule {
addInterceptor(dynamicHttpLoggingInterceptor)
}.build()
@Provides
fun providesCallFactory(okHttpClient: OkHttpClient): Call.Factory = Call.Factory { request -> okHttpClient.newCall(request) }
@Provides
@SingleIn(AppScope::class)
fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor {
@@ -11,19 +11,19 @@ package io.element.android.libraries.network
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.uri.ensureTrailingSlash
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
@Inject
class RetrofitFactory(
private val okHttpClient: () -> OkHttpClient,
private val callFactory: () -> Call.Factory,
private val json: () -> JsonProvider,
) {
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
.callFactory { request -> okHttpClient().newCall(request) }
.callFactory(callFactory())
.build()
}
@@ -16,4 +16,5 @@ data class ElementWellKnown(
val notificationSound: String?,
val identityProviderAppScheme: String?,
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
val contentScannerUrl: String?,
)
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.api
interface ElementWellknownStore {
suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown>
suspend fun update(domain: String, wellknown: String): Result<Unit>
suspend fun delete(domain: String): Result<Unit>
}
@@ -14,6 +14,11 @@ sealed interface WellknownRetrieverResult<out T> {
*/
data class Success<out T>(val data: T) : WellknownRetrieverResult<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>
/**
* Well-known data is not found (file does not exist server side, we got a 404).
*/
@@ -26,6 +31,7 @@ sealed interface WellknownRetrieverResult<out T> {
fun dataOrNull(): T? = when (this) {
is Success<T> -> data
is Outdated<T> -> data
is Error -> null
NotFound -> null
}
@@ -10,17 +10,15 @@ package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -29,34 +27,41 @@ import timber.log.Timber
class DefaultSessionWellknownRetriever(
private val matrixClient: MatrixClient,
private val json: JsonProvider,
private val cacheStore: CacheStore,
private val systemClock: SystemClock,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val elementWellknownStore: ElementWellknownStore,
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
val url = "https://$domain/.well-known/element/element.json"
val cacheData = cacheStore.getData(url)
if (cacheData != null) {
Timber.d("Element .well-known data retrieved from cache for $domain")
// If the cache is outdated, trigger a refresh in background but still return the cached value
if (systemClock.epochMillis() > cacheData.updatedAt + CACHE_VALIDITY_MILLIS) {
val cacheData = elementWellknownStore.get(domain)
return when (cacheData) {
is WellknownRetrieverResult.Success -> {
Timber.d("Using cached well-known for domain $domain")
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 $domain, returning existing value and fetching new one from network")
sessionCoroutineScope.launch {
val url = "https://$domain/.well-known/element/element.json"
fetchElementWellKnown(url)
}
cacheData
}
try {
val parsed = json().decodeFromString<InternalElementWellKnown>(cacheData.value).map()
return WellknownRetrieverResult.Success(parsed)
} catch (e: Exception) {
Timber.e(e, "Failed to parse cached Element .well-known data for $domain, deleting cache")
cacheStore.deleteData(url)
is WellknownRetrieverResult.NotFound -> {
// Try to fetch from the server
Timber.d("No cached well-known for domain $domain, fetching from network")
val url = "https://$domain/.well-known/element/element.json"
fetchElementWellKnown(url)
}
is WellknownRetrieverResult.Error -> {
// Return the error
Timber.e(cacheData.exception, "Error retrieving well-known for domain $domain")
cacheData.exception.toWellknownRetrieverResult()
}
}
return fetchElementWellKnown(url)
}
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
@@ -66,13 +71,11 @@ class DefaultSessionWellknownRetriever(
val data = String(it)
val parsed = json().decodeFromString<InternalElementWellKnown>(data).map()
// Also store in cache, if valid
cacheStore.storeData(
key = url,
data = CacheData(
value = data,
updatedAt = systemClock.epochMillis(),
)
)
elementWellknownStore.update(domain, data)
.onFailure { exception ->
Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache")
elementWellknownStore.delete(domain)
}
parsed
}
.toWellknownRetrieverResult()
@@ -85,16 +88,15 @@ class DefaultSessionWellknownRetriever(
onFailure = {
Timber.e(it, "Failed to retrieve Element .well-known from $domain")
// This check on message value is not ideal but this is what we got from the SDK.
if ((it as? ClientException.Generic)?.message?.contains("404") == true) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(it as Exception)
}
it.toWellknownRetrieverResult()
}
)
companion object {
// 1 day
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
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)
}
}
}
@@ -0,0 +1,63 @@
/*
* 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.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.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesBinding(AppScope::class)
class DefaultWellKnownStore(
private val cacheStore: CacheStore,
private val json: JsonProvider,
private val systemClock: SystemClock,
) : ElementWellknownStore {
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
return runCatchingExceptions {
val cachedData = cacheStore.getData(key(domain))
if (cachedData != null) {
val data = json().decodeFromString<InternalElementWellKnown>(cachedData.value).map()
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 = "https://$domain/.well-known/element/element.json"
companion object {
// 1 day
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
}
}
@@ -10,26 +10,34 @@ 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.core.uri.ensureProtocol
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import retrofit2.HttpException
import timber.log.Timber
import java.net.HttpURLConnection
import java.net.URL
@ContributesBinding(AppScope::class)
class DefaultWellknownRetriever(
private val retrofitFactory: RetrofitFactory,
private val elementWellknownStore: ElementWellknownStore,
private val jsonProvider: JsonProvider,
) : WellknownRetriever {
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
val domain = URL(baseUrl).host
return buildWellknownApi(baseUrl)
.map { wellknownApi ->
try {
val result = wellknownApi.getElementWellKnown().map()
WellknownRetrieverResult.Success(result)
val json = jsonProvider()
val result = wellknownApi.getElementWellKnown()
elementWellknownStore.update(domain, json.encodeToString(result))
WellknownRetrieverResult.Success(result.map())
} catch (e: Exception) {
// Is it a 404?
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
@@ -44,6 +44,8 @@ data class InternalElementWellKnown(
val identityProviderAppScheme: String? = null,
@SerialName("custom_recovery_passphrase")
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
@SerialName("content_scanner_url")
val contentScannerUrl: String? = null,
)
@Serializable
@@ -22,7 +22,7 @@ private val loggerTag = LoggerTag("Wellknown")
*/
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
internal fun InternalElementWellKnown.map() = ElementWellKnown(
fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
rageshakeUrl = rageshakeUrl,
@@ -30,6 +30,7 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
contentScannerUrl = contentScannerUrl,
)
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
@@ -11,20 +11,16 @@
package io.element.android.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wellknown.test.FakeElementWellknownStore
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -53,6 +49,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
)
)
)
@@ -79,6 +76,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
@@ -108,6 +106,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
)
)
@@ -225,16 +224,23 @@ class DefaultSessionWellknownRetrieverTest {
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown 404 http error counts as not found`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(ClientException.Generic("Not Found: 404 status code", "The file could not be found"))
}
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
}
@Test
fun `get element wellknown hitting cache`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = { lambdaError() },
cacheStore = InMemoryCacheStore(
cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to CacheData(
value = WELLKNOWN_CONTENT,
updatedAt = A_FAKE_TIMESTAMP,
)
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
)
)
)
@@ -248,6 +254,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
@@ -255,12 +262,9 @@ class DefaultSessionWellknownRetrieverTest {
@Test
fun `get element wellknown hitting cache containing invalid json`() = runTest {
val cacheStore = InMemoryCacheStore(
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to CacheData(
value = WELLKNOWN_CONTENT,
updatedAt = A_FAKE_TIMESTAMP,
)
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
)
)
val sut = createDefaultSessionWellknownRetriever(
@@ -272,28 +276,24 @@ class DefaultSessionWellknownRetrieverTest {
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
// Ensure that the cache is deleted after the failure to parse it
assertThat(cacheStore.dataMap).isEmpty()
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
}
@Test
fun `get element wellknown hitting outdated cache`() = runTest {
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()),
)
)
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success("{}".toByteArray())
},
cacheStore = InMemoryCacheStore(
initialData = mapOf(
WELLKNOWN_URL to CacheData(
value = WELLKNOWN_CONTENT,
updatedAt = 0L,
)
),
),
// 3 days later, so the cache is outdated
systemClock = FakeSystemClock(3 * 24 * 60 * 60 * 1000L)
cacheStore = cacheStore,
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
WellknownRetrieverResult.Outdated(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
@@ -302,6 +302,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
@@ -314,20 +315,20 @@ class DefaultSessionWellknownRetrieverTest {
)
}
private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString<InternalElementWellKnown>(WELLKNOWN_CONTENT).map()
private fun TestScope.createDefaultSessionWellknownRetriever(
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
getUrlLambda: (String) -> Result<ByteArray>,
jsonProvider: JsonProvider = DefaultJsonProvider(),
cacheStore: CacheStore = InMemoryCacheStore(),
systemClock: SystemClock = FakeSystemClock(),
) = DefaultSessionWellknownRetriever(
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { "user.domain.org" },
getUrlLambda = getUrlLambda,
),
json = jsonProvider,
cacheStore = cacheStore,
systemClock = systemClock,
sessionCoroutineScope = backgroundScope,
elementWellknownStore = cacheStore,
)
companion object {
@@ -338,7 +339,8 @@ class DefaultSessionWellknownRetrieverTest {
"rageshake_url": "a_rageshake_url",
"brand_color": "#FF0000",
"notification_sound": "a_notification_sound.flac",
"idp_app_scheme": "an_app_scheme"
"idp_app_scheme": "an_app_scheme",
"content_scanner_url": "https://content-scanner.example.com"
}"""
}
}
@@ -0,0 +1,158 @@
/*
* 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.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()))
}
private fun createDefaultWellKnownStore(
cacheStore: InMemoryCacheStore = InMemoryCacheStore(),
systemClock: FakeSystemClock = FakeSystemClock(),
) = DefaultWellKnownStore(
cacheStore = cacheStore,
json = jsonProvider,
systemClock = systemClock,
)
}
@@ -0,0 +1,291 @@
/*
* 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.wellknown.test.FakeElementWellknownStore
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.IOException
import org.junit.Test
import java.net.PortUnreachableException
class DefaultWellknownRetrieverTest {
@Test
fun `get empty element wellknown`() = runTest {
val mockCall = mockCall(defaultResponse(body = "{}"))
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall },
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = null,
enforceElementPro = null,
rageshakeUrl = null,
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
)
)
)
verify(exactly = 1) { mockCall.enqueue(any()) }
}
@Test
fun `get element wellknown with full content`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse()) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
}
@Test
fun `get element wellknown with unknown key`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(defaultResponse(body = """{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url",
// Note the trailing comma, and the comment!
"other": true,
}""".trimIndent()))
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(defaultResponse(body = """{
"custom_recovery_passphrase": {
"min_character_count": 8
}
}""".trimIndent()))
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8)
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(defaultResponse(body = """{
"custom_recovery_passphrase": {}
}""".trimIndent()))
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with zero min character count floors to 1`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(
defaultResponse(
body = """{
"custom_recovery_passphrase": {
"min_character_count": 0
}
}""".trimIndent()
)
)
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with negative min character count floors to 1`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(
defaultResponse(
body = """{
"custom_recovery_passphrase": {
"min_character_count": -5
}
}""".trimIndent()
)
)
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown json error`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse(status = 500)) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown network error`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(error = PortUnreachableException()) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown 404 http error counts as not found`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse(status = 404)) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
}
@Test
fun `get element wellknown hitting cache containing invalid json`() = runTest {
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
)
)
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse(body = "invalid json")) },
cacheStore = cacheStore,
jsonProvider = JsonProvider { error("Failed to parse JSON") }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
// Ensure that the cache is deleted after the failure to parse it
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
}
private fun defaultResponse(
body: String = WELLKNOWN_CONTENT,
status: Int = 200,
) = Response.Builder()
.code(status)
.message("OK")
.protocol(okhttp3.Protocol.HTTP_1_1)
.request(Request.Builder().url(WELLKNOWN_URL).build())
.body(body.toByteArray().toResponseBody())
.build()
private fun createDefaultWellknownRetriever(
callFactory: Call.Factory = Call.Factory { _: Request -> mockCall(defaultResponse()) },
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
jsonProvider: JsonProvider = DefaultJsonProvider(),
) = DefaultWellknownRetriever(
retrofitFactory = RetrofitFactory(
callFactory = { callFactory },
json = { jsonProvider }
),
jsonProvider = jsonProvider,
elementWellknownStore = cacheStore,
)
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"
}"""
}
}
private fun mockCall(
response: Response? = null,
error: IOException? = null,
): Call = mockk<Call> {
if (error != null) {
every { enqueue(any()) } answers {
val callback = firstArg<okhttp3.Callback>()
callback.onFailure(this@mockk, error)
}
} else if (response != null) {
every { enqueue(any()) } answers {
val callback = firstArg<okhttp3.Callback>()
callback.onResponse(this@mockk, response)
}
} else {
error("Either response or error must be provided")
}
}
@@ -15,6 +15,9 @@ android {
}
dependencies {
implementation(libs.serialization.json)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.wellknown.api)
implementation(projects.libraries.wellknown.impl)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,53 @@
/*
* 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)
}
}
@@ -19,6 +19,7 @@ fun anElementWellKnown(
notificationSound: String? = null,
identityProviderAppScheme: String? = null,
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
contentScannerUrl: String? = null,
) = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
@@ -27,6 +28,7 @@ fun anElementWellKnown(
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase,
contentScannerUrl = contentScannerUrl,
)
fun aCustomRecoveryPassphrase(