mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
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:
committed by
GitHub
parent
7c3d1e2e80
commit
885e042c12
+1
-1
Submodule enterprise updated: efdf9d9a15...bc38a11f95
@@ -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)
|
||||||
|
}
|
||||||
+2
@@ -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.room.location.BeaconInfoUpdate
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
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.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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
@@ -71,6 +72,7 @@ interface MatrixClient {
|
|||||||
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
|
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
|
||||||
val roomMembershipObserver: RoomMembershipObserver
|
val roomMembershipObserver: RoomMembershipObserver
|
||||||
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
|
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
|
||||||
|
val contentScanner: ContentScanner?
|
||||||
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
|
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
|
||||||
suspend fun getRoom(roomId: RoomId): BaseRoom?
|
suspend fun getRoom(roomId: RoomId): BaseRoom?
|
||||||
suspend fun findDM(userId: UserId): Result<RoomId?>
|
suspend fun findDM(userId: UserId): Result<RoomId?>
|
||||||
|
|||||||
+27
@@ -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>
|
||||||
|
}
|
||||||
+18
@@ -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?>
|
||||||
|
}
|
||||||
+2
@@ -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.room.join.JoinRule
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
|
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.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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||||
@@ -150,6 +151,7 @@ class RustMatrixClient(
|
|||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val workManagerScheduler: WorkManagerScheduler,
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
|
override val contentScanner: ContentScanner?,
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
override val sessionId: UserId = UserId(innerClient.userId())
|
override val sessionId: UserId = UserId(innerClient.userId())
|
||||||
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
|
||||||
|
|||||||
+23
@@ -17,11 +17,14 @@ import io.element.android.libraries.di.CacheDirectory
|
|||||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
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.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.analytics.UtdTracker
|
||||||
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
|
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.proxy.ProxyProvider
|
||||||
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
|
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.storage.SqliteStoreBuilderProvider
|
||||||
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
||||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||||
@@ -34,6 +37,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.matrix.rustcomponents.sdk.Client
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||||
|
import org.matrix.rustcomponents.sdk.ContentScanner
|
||||||
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
|
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
|
||||||
import org.matrix.rustcomponents.sdk.RequestConfig
|
import org.matrix.rustcomponents.sdk.RequestConfig
|
||||||
import org.matrix.rustcomponents.sdk.Session
|
import org.matrix.rustcomponents.sdk.Session
|
||||||
@@ -66,6 +70,7 @@ class RustMatrixClientFactory(
|
|||||||
private val clientBuilderProvider: ClientBuilderProvider,
|
private val clientBuilderProvider: ClientBuilderProvider,
|
||||||
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
||||||
private val workManagerScheduler: WorkManagerScheduler,
|
private val workManagerScheduler: WorkManagerScheduler,
|
||||||
|
private val contentScannerUrlProvider: ContentScannerUrlProvider,
|
||||||
) {
|
) {
|
||||||
private val sessionDelegate = RustClientSessionDelegate(
|
private val sessionDelegate = RustClientSessionDelegate(
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
@@ -113,6 +118,23 @@ class RustMatrixClientFactory(
|
|||||||
|
|
||||||
client.setUtdDelegate(UtdTracker(analyticsService))
|
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()
|
val syncService = client.syncService()
|
||||||
.withSharePos(true)
|
.withSharePos(true)
|
||||||
.withOfflineMode()
|
.withOfflineMode()
|
||||||
@@ -132,6 +154,7 @@ class RustMatrixClientFactory(
|
|||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
workManagerScheduler = workManagerScheduler,
|
workManagerScheduler = workManagerScheduler,
|
||||||
|
contentScanner = contentScanner,
|
||||||
).also {
|
).also {
|
||||||
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
|
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -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.room.RoomMembershipObserver
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
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.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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
@@ -96,4 +97,9 @@ object SessionMatrixModule {
|
|||||||
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
|
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
|
||||||
return matrixClient.homeserverCapabilities()
|
return matrixClient.homeserverCapabilities()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? {
|
||||||
|
return matrixClient.contentScanner
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -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)
|
||||||
|
}
|
||||||
+42
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl
|
|||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||||
import io.element.android.libraries.matrix.api.core.SessionId
|
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.auth.FakeProxyProvider
|
||||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
||||||
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
|
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
|
||||||
@@ -51,6 +52,7 @@ fun TestScope.createRustMatrixClientFactory(
|
|||||||
),
|
),
|
||||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||||
|
contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) },
|
||||||
) = RustMatrixClientFactory(
|
) = RustMatrixClientFactory(
|
||||||
cacheDirectory = cacheDirectory,
|
cacheDirectory = cacheDirectory,
|
||||||
appCoroutineScope = backgroundScope,
|
appCoroutineScope = backgroundScope,
|
||||||
@@ -65,4 +67,5 @@ fun TestScope.createRustMatrixClientFactory(
|
|||||||
clientBuilderProvider = clientBuilderProvider,
|
clientBuilderProvider = clientBuilderProvider,
|
||||||
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
||||||
workManagerScheduler = workManagerScheduler,
|
workManagerScheduler = workManagerScheduler,
|
||||||
|
contentScannerUrlProvider = contentScannerUrlProvider,
|
||||||
)
|
)
|
||||||
|
|||||||
+2
@@ -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_ROOM_ID
|
||||||
import io.element.android.libraries.matrix.test.A_USER_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.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.api.SessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||||
import io.element.android.libraries.sessionstorage.test.aSessionData
|
import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||||
@@ -156,5 +157,6 @@ class RustMatrixClientTest {
|
|||||||
featureFlagService = FakeFeatureFlagService(),
|
featureFlagService = FakeFeatureFlagService(),
|
||||||
analyticsService = FakeAnalyticsService(),
|
analyticsService = FakeAnalyticsService(),
|
||||||
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
|
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
|
||||||
|
contentScanner = FakeContentScanner(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -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.room.location.BeaconInfoUpdate
|
||||||
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
|
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.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.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
@@ -122,6 +123,7 @@ class FakeMatrixClient(
|
|||||||
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
|
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
|
||||||
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
|
||||||
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
|
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
|
||||||
|
override val contentScanner: ContentScanner? = null
|
||||||
) : MatrixClient {
|
) : MatrixClient {
|
||||||
var setDisplayNameCalled: Boolean = false
|
var setDisplayNameCalled: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
|||||||
+19
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -16,6 +16,7 @@ import dev.zacsweers.metro.SingleIn
|
|||||||
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
|
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
|
||||||
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
|
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
|
||||||
import io.element.android.libraries.network.interceptors.UserAgentInterceptor
|
import io.element.android.libraries.network.interceptors.UserAgentInterceptor
|
||||||
|
import okhttp3.Call
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -36,6 +37,9 @@ object NetworkModule {
|
|||||||
addInterceptor(dynamicHttpLoggingInterceptor)
|
addInterceptor(dynamicHttpLoggingInterceptor)
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun providesCallFactory(okHttpClient: OkHttpClient): Call.Factory = Call.Factory { request -> okHttpClient.newCall(request) }
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@SingleIn(AppScope::class)
|
@SingleIn(AppScope::class)
|
||||||
fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor {
|
fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor {
|
||||||
|
|||||||
+3
-3
@@ -11,19 +11,19 @@ package io.element.android.libraries.network
|
|||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||||
import io.element.android.libraries.core.uri.ensureTrailingSlash
|
import io.element.android.libraries.core.uri.ensureTrailingSlash
|
||||||
|
import okhttp3.Call
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
class RetrofitFactory(
|
class RetrofitFactory(
|
||||||
private val okHttpClient: () -> OkHttpClient,
|
private val callFactory: () -> Call.Factory,
|
||||||
private val json: () -> JsonProvider,
|
private val json: () -> JsonProvider,
|
||||||
) {
|
) {
|
||||||
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
|
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
|
||||||
.baseUrl(baseUrl.ensureTrailingSlash())
|
.baseUrl(baseUrl.ensureTrailingSlash())
|
||||||
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
|
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
|
||||||
.callFactory { request -> okHttpClient().newCall(request) }
|
.callFactory(callFactory())
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -16,4 +16,5 @@ data class ElementWellKnown(
|
|||||||
val notificationSound: String?,
|
val notificationSound: String?,
|
||||||
val identityProviderAppScheme: String?,
|
val identityProviderAppScheme: String?,
|
||||||
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
|
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
|
||||||
|
val contentScannerUrl: String?,
|
||||||
)
|
)
|
||||||
|
|||||||
+14
@@ -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>
|
||||||
|
}
|
||||||
+6
@@ -14,6 +14,11 @@ sealed interface WellknownRetrieverResult<out T> {
|
|||||||
*/
|
*/
|
||||||
data class Success<out T>(val data: T) : WellknownRetrieverResult<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).
|
* 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) {
|
fun dataOrNull(): T? = when (this) {
|
||||||
is Success<T> -> data
|
is Success<T> -> data
|
||||||
|
is Outdated<T> -> data
|
||||||
is Error -> null
|
is Error -> null
|
||||||
NotFound -> null
|
NotFound -> null
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-34
@@ -10,17 +10,15 @@ package io.element.android.libraries.wellknown.impl
|
|||||||
|
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
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.core.extensions.mapCatchingExceptions
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||||
import io.element.android.libraries.matrix.api.MatrixClient
|
import io.element.android.libraries.matrix.api.MatrixClient
|
||||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
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.SessionWellknownRetriever
|
||||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -29,34 +27,41 @@ import timber.log.Timber
|
|||||||
class DefaultSessionWellknownRetriever(
|
class DefaultSessionWellknownRetriever(
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val json: JsonProvider,
|
private val json: JsonProvider,
|
||||||
private val cacheStore: CacheStore,
|
|
||||||
private val systemClock: SystemClock,
|
|
||||||
@SessionCoroutineScope
|
@SessionCoroutineScope
|
||||||
private val sessionCoroutineScope: CoroutineScope,
|
private val sessionCoroutineScope: CoroutineScope,
|
||||||
|
private val elementWellknownStore: ElementWellknownStore,
|
||||||
) : SessionWellknownRetriever {
|
) : SessionWellknownRetriever {
|
||||||
private val domain by lazy { matrixClient.userIdServerName() }
|
private val domain by lazy { matrixClient.userIdServerName() }
|
||||||
|
|
||||||
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
|
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
|
||||||
val url = "https://$domain/.well-known/element/element.json"
|
val cacheData = elementWellknownStore.get(domain)
|
||||||
val cacheData = cacheStore.getData(url)
|
return when (cacheData) {
|
||||||
if (cacheData != null) {
|
is WellknownRetrieverResult.Success -> {
|
||||||
Timber.d("Element .well-known data retrieved from cache for $domain")
|
Timber.d("Using cached well-known for domain $domain")
|
||||||
// If the cache is outdated, trigger a refresh in background but still return the cached value
|
cacheData
|
||||||
if (systemClock.epochMillis() > cacheData.updatedAt + CACHE_VALIDITY_MILLIS) {
|
}
|
||||||
|
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 {
|
sessionCoroutineScope.launch {
|
||||||
|
val url = "https://$domain/.well-known/element/element.json"
|
||||||
fetchElementWellKnown(url)
|
fetchElementWellKnown(url)
|
||||||
}
|
}
|
||||||
|
cacheData
|
||||||
}
|
}
|
||||||
try {
|
is WellknownRetrieverResult.NotFound -> {
|
||||||
val parsed = json().decodeFromString<InternalElementWellKnown>(cacheData.value).map()
|
// Try to fetch from the server
|
||||||
return WellknownRetrieverResult.Success(parsed)
|
Timber.d("No cached well-known for domain $domain, fetching from network")
|
||||||
} catch (e: Exception) {
|
val url = "https://$domain/.well-known/element/element.json"
|
||||||
Timber.e(e, "Failed to parse cached Element .well-known data for $domain, deleting cache")
|
fetchElementWellKnown(url)
|
||||||
cacheStore.deleteData(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> {
|
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||||
@@ -66,13 +71,11 @@ class DefaultSessionWellknownRetriever(
|
|||||||
val data = String(it)
|
val data = String(it)
|
||||||
val parsed = json().decodeFromString<InternalElementWellKnown>(data).map()
|
val parsed = json().decodeFromString<InternalElementWellKnown>(data).map()
|
||||||
// Also store in cache, if valid
|
// Also store in cache, if valid
|
||||||
cacheStore.storeData(
|
elementWellknownStore.update(domain, data)
|
||||||
key = url,
|
.onFailure { exception ->
|
||||||
data = CacheData(
|
Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache")
|
||||||
value = data,
|
elementWellknownStore.delete(domain)
|
||||||
updatedAt = systemClock.epochMillis(),
|
}
|
||||||
)
|
|
||||||
)
|
|
||||||
parsed
|
parsed
|
||||||
}
|
}
|
||||||
.toWellknownRetrieverResult()
|
.toWellknownRetrieverResult()
|
||||||
@@ -85,16 +88,15 @@ class DefaultSessionWellknownRetriever(
|
|||||||
onFailure = {
|
onFailure = {
|
||||||
Timber.e(it, "Failed to retrieve Element .well-known from $domain")
|
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.
|
// 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) {
|
it.toWellknownRetrieverResult()
|
||||||
WellknownRetrieverResult.NotFound
|
|
||||||
} else {
|
|
||||||
WellknownRetrieverResult.Error(it as Exception)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
|
||||||
// 1 day
|
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
|
||||||
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
|
WellknownRetrieverResult.NotFound
|
||||||
|
} else {
|
||||||
|
WellknownRetrieverResult.Error(this as Exception)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+63
@@ -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
-2
@@ -10,26 +10,34 @@ package io.element.android.libraries.wellknown.impl
|
|||||||
|
|
||||||
import dev.zacsweers.metro.AppScope
|
import dev.zacsweers.metro.AppScope
|
||||||
import dev.zacsweers.metro.ContributesBinding
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||||
import io.element.android.libraries.core.uri.ensureProtocol
|
import io.element.android.libraries.core.uri.ensureProtocol
|
||||||
import io.element.android.libraries.network.RetrofitFactory
|
import io.element.android.libraries.network.RetrofitFactory
|
||||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
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.WellknownRetriever
|
||||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||||
import retrofit2.HttpException
|
import retrofit2.HttpException
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultWellknownRetriever(
|
class DefaultWellknownRetriever(
|
||||||
private val retrofitFactory: RetrofitFactory,
|
private val retrofitFactory: RetrofitFactory,
|
||||||
|
private val elementWellknownStore: ElementWellknownStore,
|
||||||
|
private val jsonProvider: JsonProvider,
|
||||||
) : WellknownRetriever {
|
) : WellknownRetriever {
|
||||||
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
|
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||||
|
val domain = URL(baseUrl).host
|
||||||
return buildWellknownApi(baseUrl)
|
return buildWellknownApi(baseUrl)
|
||||||
.map { wellknownApi ->
|
.map { wellknownApi ->
|
||||||
try {
|
try {
|
||||||
val result = wellknownApi.getElementWellKnown().map()
|
val json = jsonProvider()
|
||||||
WellknownRetrieverResult.Success(result)
|
val result = wellknownApi.getElementWellKnown()
|
||||||
|
elementWellknownStore.update(domain, json.encodeToString(result))
|
||||||
|
WellknownRetrieverResult.Success(result.map())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Is it a 404?
|
// Is it a 404?
|
||||||
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
|
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
|
||||||
|
|||||||
+2
@@ -44,6 +44,8 @@ data class InternalElementWellKnown(
|
|||||||
val identityProviderAppScheme: String? = null,
|
val identityProviderAppScheme: String? = null,
|
||||||
@SerialName("custom_recovery_passphrase")
|
@SerialName("custom_recovery_passphrase")
|
||||||
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
|
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
|
||||||
|
@SerialName("content_scanner_url")
|
||||||
|
val contentScannerUrl: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
+2
-1
@@ -22,7 +22,7 @@ private val loggerTag = LoggerTag("Wellknown")
|
|||||||
*/
|
*/
|
||||||
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
|
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
|
||||||
|
|
||||||
internal fun InternalElementWellKnown.map() = ElementWellKnown(
|
fun InternalElementWellKnown.map() = ElementWellKnown(
|
||||||
registrationHelperUrl = registrationHelperUrl,
|
registrationHelperUrl = registrationHelperUrl,
|
||||||
enforceElementPro = enforceElementPro,
|
enforceElementPro = enforceElementPro,
|
||||||
rageshakeUrl = rageshakeUrl,
|
rageshakeUrl = rageshakeUrl,
|
||||||
@@ -30,6 +30,7 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
|
|||||||
notificationSound = notificationSound,
|
notificationSound = notificationSound,
|
||||||
identityProviderAppScheme = identityProviderAppScheme,
|
identityProviderAppScheme = identityProviderAppScheme,
|
||||||
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
|
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
|
||||||
|
contentScannerUrl = contentScannerUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
|
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
|
||||||
|
|||||||
+35
-33
@@ -11,20 +11,16 @@
|
|||||||
package io.element.android.libraries.wellknown.impl
|
package io.element.android.libraries.wellknown.impl
|
||||||
|
|
||||||
import com.google.common.truth.Truth.assertThat
|
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.features.wellknown.test.anElementWellKnown
|
||||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||||
import io.element.android.libraries.cachestore.api.CacheData
|
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||||
import io.element.android.libraries.cachestore.api.CacheStore
|
|
||||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
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.CustomRecoveryPassphrase
|
||||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
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.lambdaError
|
||||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||||
import io.element.android.tests.testutils.lambda.value
|
import io.element.android.tests.testutils.lambda.value
|
||||||
@@ -53,6 +49,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
notificationSound = null,
|
notificationSound = null,
|
||||||
identityProviderAppScheme = null,
|
identityProviderAppScheme = null,
|
||||||
customRecoveryPassphrase = null,
|
customRecoveryPassphrase = null,
|
||||||
|
contentScannerUrl = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -79,6 +76,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
notificationSound = "a_notification_sound.flac",
|
notificationSound = "a_notification_sound.flac",
|
||||||
identityProviderAppScheme = "an_app_scheme",
|
identityProviderAppScheme = "an_app_scheme",
|
||||||
customRecoveryPassphrase = null,
|
customRecoveryPassphrase = null,
|
||||||
|
contentScannerUrl = "https://content-scanner.example.com",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -108,6 +106,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
brandColor = null,
|
brandColor = null,
|
||||||
notificationSound = null,
|
notificationSound = null,
|
||||||
identityProviderAppScheme = null,
|
identityProviderAppScheme = null,
|
||||||
|
contentScannerUrl = null,
|
||||||
customRecoveryPassphrase = null,
|
customRecoveryPassphrase = null,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -225,16 +224,23 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
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
|
@Test
|
||||||
fun `get element wellknown hitting cache`() = runTest {
|
fun `get element wellknown hitting cache`() = runTest {
|
||||||
val sut = createDefaultSessionWellknownRetriever(
|
val sut = createDefaultSessionWellknownRetriever(
|
||||||
getUrlLambda = { lambdaError() },
|
getUrlLambda = { lambdaError() },
|
||||||
cacheStore = InMemoryCacheStore(
|
cacheStore = FakeElementWellknownStore(
|
||||||
initialData = mapOf(
|
initialData = mapOf(
|
||||||
WELLKNOWN_URL to CacheData(
|
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
|
||||||
value = WELLKNOWN_CONTENT,
|
|
||||||
updatedAt = A_FAKE_TIMESTAMP,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -248,6 +254,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
notificationSound = "a_notification_sound.flac",
|
notificationSound = "a_notification_sound.flac",
|
||||||
identityProviderAppScheme = "an_app_scheme",
|
identityProviderAppScheme = "an_app_scheme",
|
||||||
customRecoveryPassphrase = null,
|
customRecoveryPassphrase = null,
|
||||||
|
contentScannerUrl = "https://content-scanner.example.com",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -255,12 +262,9 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `get element wellknown hitting cache containing invalid json`() = runTest {
|
fun `get element wellknown hitting cache containing invalid json`() = runTest {
|
||||||
val cacheStore = InMemoryCacheStore(
|
val cacheStore = FakeElementWellknownStore(
|
||||||
initialData = mapOf(
|
initialData = mapOf(
|
||||||
WELLKNOWN_URL to CacheData(
|
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
|
||||||
value = WELLKNOWN_CONTENT,
|
|
||||||
updatedAt = A_FAKE_TIMESTAMP,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val sut = createDefaultSessionWellknownRetriever(
|
val sut = createDefaultSessionWellknownRetriever(
|
||||||
@@ -272,28 +276,24 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
)
|
)
|
||||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||||
// Ensure that the cache is deleted after the failure to parse it
|
// 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
|
@Test
|
||||||
fun `get element wellknown hitting outdated cache`() = runTest {
|
fun `get element wellknown hitting outdated cache`() = runTest {
|
||||||
|
val cacheStore = FakeElementWellknownStore(
|
||||||
|
initialData = mapOf(
|
||||||
|
WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()),
|
||||||
|
)
|
||||||
|
)
|
||||||
val sut = createDefaultSessionWellknownRetriever(
|
val sut = createDefaultSessionWellknownRetriever(
|
||||||
getUrlLambda = {
|
getUrlLambda = {
|
||||||
Result.success("{}".toByteArray())
|
Result.success("{}".toByteArray())
|
||||||
},
|
},
|
||||||
cacheStore = InMemoryCacheStore(
|
cacheStore = cacheStore,
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||||
WellknownRetrieverResult.Success(
|
WellknownRetrieverResult.Outdated(
|
||||||
ElementWellKnown(
|
ElementWellKnown(
|
||||||
registrationHelperUrl = "a_registration_url",
|
registrationHelperUrl = "a_registration_url",
|
||||||
enforceElementPro = true,
|
enforceElementPro = true,
|
||||||
@@ -302,6 +302,7 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
notificationSound = "a_notification_sound.flac",
|
notificationSound = "a_notification_sound.flac",
|
||||||
identityProviderAppScheme = "an_app_scheme",
|
identityProviderAppScheme = "an_app_scheme",
|
||||||
customRecoveryPassphrase = null,
|
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(
|
private fun TestScope.createDefaultSessionWellknownRetriever(
|
||||||
|
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
|
||||||
getUrlLambda: (String) -> Result<ByteArray>,
|
getUrlLambda: (String) -> Result<ByteArray>,
|
||||||
jsonProvider: JsonProvider = DefaultJsonProvider(),
|
jsonProvider: JsonProvider = DefaultJsonProvider(),
|
||||||
cacheStore: CacheStore = InMemoryCacheStore(),
|
|
||||||
systemClock: SystemClock = FakeSystemClock(),
|
|
||||||
) = DefaultSessionWellknownRetriever(
|
) = DefaultSessionWellknownRetriever(
|
||||||
matrixClient = FakeMatrixClient(
|
matrixClient = FakeMatrixClient(
|
||||||
userIdServerNameLambda = { "user.domain.org" },
|
userIdServerNameLambda = { "user.domain.org" },
|
||||||
getUrlLambda = getUrlLambda,
|
getUrlLambda = getUrlLambda,
|
||||||
),
|
),
|
||||||
json = jsonProvider,
|
json = jsonProvider,
|
||||||
cacheStore = cacheStore,
|
|
||||||
systemClock = systemClock,
|
|
||||||
sessionCoroutineScope = backgroundScope,
|
sessionCoroutineScope = backgroundScope,
|
||||||
|
elementWellknownStore = cacheStore,
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -338,7 +339,8 @@ class DefaultSessionWellknownRetrieverTest {
|
|||||||
"rageshake_url": "a_rageshake_url",
|
"rageshake_url": "a_rageshake_url",
|
||||||
"brand_color": "#FF0000",
|
"brand_color": "#FF0000",
|
||||||
"notification_sound": "a_notification_sound.flac",
|
"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"
|
||||||
}"""
|
}"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+158
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+291
@@ -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 {
|
dependencies {
|
||||||
|
implementation(libs.serialization.json)
|
||||||
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.wellknown.api)
|
implementation(projects.libraries.wellknown.api)
|
||||||
|
implementation(projects.libraries.wellknown.impl)
|
||||||
implementation(projects.tests.testutils)
|
implementation(projects.tests.testutils)
|
||||||
}
|
}
|
||||||
|
|||||||
+53
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -19,6 +19,7 @@ fun anElementWellKnown(
|
|||||||
notificationSound: String? = null,
|
notificationSound: String? = null,
|
||||||
identityProviderAppScheme: String? = null,
|
identityProviderAppScheme: String? = null,
|
||||||
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
|
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
|
||||||
|
contentScannerUrl: String? = null,
|
||||||
) = ElementWellKnown(
|
) = ElementWellKnown(
|
||||||
registrationHelperUrl = registrationHelperUrl,
|
registrationHelperUrl = registrationHelperUrl,
|
||||||
enforceElementPro = enforceElementPro,
|
enforceElementPro = enforceElementPro,
|
||||||
@@ -27,6 +28,7 @@ fun anElementWellKnown(
|
|||||||
notificationSound = notificationSound,
|
notificationSound = notificationSound,
|
||||||
identityProviderAppScheme = identityProviderAppScheme,
|
identityProviderAppScheme = identityProviderAppScheme,
|
||||||
customRecoveryPassphrase = customRecoveryPassphrase,
|
customRecoveryPassphrase = customRecoveryPassphrase,
|
||||||
|
contentScannerUrl = contentScannerUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun aCustomRecoveryPassphrase(
|
fun aCustomRecoveryPassphrase(
|
||||||
|
|||||||
Reference in New Issue
Block a user