diff --git a/enterprise b/enterprise index efdf9d9a15..bc38a11f95 160000 --- a/enterprise +++ b/enterprise @@ -1 +1 @@ -Subproject commit efdf9d9a15a5b600c55f674d64794e7737ad906e +Subproject commit bc38a11f95988cc1bbc209decb998f21c465ded0 diff --git a/features/contentscanner/api/build.gradle.kts b/features/contentscanner/api/build.gradle.kts new file mode 100644 index 0000000000..0e3d592878 --- /dev/null +++ b/features/contentscanner/api/build.gradle.kts @@ -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) +} diff --git a/features/contentscanner/impl/build.gradle.kts b/features/contentscanner/impl/build.gradle.kts new file mode 100644 index 0000000000..53e8843b1d --- /dev/null +++ b/features/contentscanner/impl/build.gradle.kts @@ -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) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 6546e4c41a..8f9c6601ca 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -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> val roomMembershipObserver: RoomMembershipObserver val ownBeaconInfoUpdates: Flow + val contentScanner: ContentScanner? suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom? suspend fun getRoom(roomId: RoomId): BaseRoom? suspend fun findDM(userId: UserId): Result diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScanner.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScanner.kt new file mode 100644 index 0000000000..e2c4839c4d --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScanner.kt @@ -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 +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt new file mode 100644 index 0000000000..600866f586 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/scanner/ContentScannerUrlProvider.kt @@ -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 +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 2cfbbbdc73..5c81a3f085 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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()) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt index fae99b75c8..b4a9272166 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactory.kt @@ -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'") } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt index bef24003b9..d4f49af416 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/di/SessionMatrixModule.kt @@ -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 + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt new file mode 100644 index 0000000000..b43dcfb3e8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/DefaultContentScannerUrlProvider.kt @@ -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 = Result.success(null) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt new file mode 100644 index 0000000000..ecfa4f0bcd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt @@ -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 { + 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 + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 1c25acf088..d1536d949e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -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, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index a8e812d031..745fbf02d5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -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(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/FakeFfiContentScanner.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/FakeFfiContentScanner.kt new file mode 100644 index 0000000000..a359747198 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/FakeFfiContentScanner.kt @@ -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) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScannerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScannerTest.kt new file mode 100644 index 0000000000..8a1522c2f2 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScannerTest.kt @@ -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() + } + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 2397f12443..98fe01553c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -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 = { lambdaError() }, private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, + override val contentScanner: ContentScanner? = null ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/scanner/FakeContentScanner.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/scanner/FakeContentScanner.kt new file mode 100644 index 0000000000..a4d9ce72e7 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/scanner/FakeContentScanner.kt @@ -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 = { Result.success(true) }, +) : ContentScanner { + override suspend fun scan(mediaSource: MediaSource): Result { + return scan.invoke(mediaSource) + } +} diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt index d8809e042c..90e78d860e 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/NetworkModule.kt @@ -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 { diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt index 39a54dca61..ecb1004b90 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/RetrofitFactory.kt @@ -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() } diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt index 8f2c07ab68..45448f66c2 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellKnown.kt @@ -16,4 +16,5 @@ data class ElementWellKnown( val notificationSound: String?, val identityProviderAppScheme: String?, val customRecoveryPassphrase: CustomRecoveryPassphrase?, + val contentScannerUrl: String?, ) diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt new file mode 100644 index 0000000000..80b2ab4c5e --- /dev/null +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/ElementWellknownStore.kt @@ -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 + suspend fun update(domain: String, wellknown: String): Result + suspend fun delete(domain: String): Result +} diff --git a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt index 3236f18660..fcc54df0b5 100644 --- a/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt +++ b/libraries/wellknown/api/src/main/kotlin/io/element/android/libraries/wellknown/api/WellknownRetrieverResult.kt @@ -14,6 +14,11 @@ sealed interface WellknownRetrieverResult { */ data class Success(val data: T) : WellknownRetrieverResult + /** + * Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched. + */ + data class Outdated(val data: T) : WellknownRetrieverResult + /** * Well-known data is not found (file does not exist server side, we got a 404). */ @@ -26,6 +31,7 @@ sealed interface WellknownRetrieverResult { fun dataOrNull(): T? = when (this) { is Success -> data + is Outdated -> data is Error -> null NotFound -> null } diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt index a0223e93cc..3446cdcb65 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetriever.kt @@ -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 { - 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(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 { @@ -66,13 +71,11 @@ class DefaultSessionWellknownRetriever( val data = String(it) val parsed = json().decodeFromString(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 Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult { + return if ((this as? ClientException.Generic)?.message?.contains("404") == true) { + WellknownRetrieverResult.NotFound + } else { + WellknownRetrieverResult.Error(this as Exception) + } } } diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt new file mode 100644 index 0000000000..c3cfcc5019 --- /dev/null +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStore.kt @@ -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 { + return runCatchingExceptions { + val cachedData = cacheStore.getData(key(domain)) + if (cachedData != null) { + val data = json().decodeFromString(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 { + return runCatchingExceptions { + cacheStore.storeData(key(domain), CacheData(wellknown, systemClock.epochMillis())) + } + } + + override suspend fun delete(domain: String): Result { + 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 + } +} diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt index 02dae72a43..a43584d542 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetriever.kt @@ -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 { + 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") diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt index cd0f39c77f..ad60caefa5 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/InternalElementWellKnown.kt @@ -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 diff --git a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt index c8760881d4..420dea2bae 100644 --- a/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt +++ b/libraries/wellknown/impl/src/main/kotlin/io/element/android/libraries/wellknown/impl/Mapper.kt @@ -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 { diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt index a64d156f6e..ec939b9988 100644 --- a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultSessionWellknownRetrieverTest.kt @@ -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(WELLKNOWN_CONTENT).map() + private fun TestScope.createDefaultSessionWellknownRetriever( + cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(), getUrlLambda: (String) -> Result, 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" }""" } } diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt new file mode 100644 index 0000000000..11ec521fed --- /dev/null +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellKnownStoreTest.kt @@ -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, + ) +} diff --git a/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt new file mode 100644 index 0000000000..0e9288f658 --- /dev/null +++ b/libraries/wellknown/impl/src/test/kotlin/io/element/android/libraries/wellknown/impl/DefaultWellknownRetrieverTest.kt @@ -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 { + if (error != null) { + every { enqueue(any()) } answers { + val callback = firstArg() + callback.onFailure(this@mockk, error) + } + } else if (response != null) { + every { enqueue(any()) } answers { + val callback = firstArg() + callback.onResponse(this@mockk, response) + } + } else { + error("Either response or error must be provided") + } +} diff --git a/libraries/wellknown/test/build.gradle.kts b/libraries/wellknown/test/build.gradle.kts index 0ce22348b3..8eb47ef5ca 100644 --- a/libraries/wellknown/test/build.gradle.kts +++ b/libraries/wellknown/test/build.gradle.kts @@ -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) } diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt new file mode 100644 index 0000000000..c5e2d2ae5b --- /dev/null +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/FakeElementWellknownStore.kt @@ -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> = emptyMap(), + private val jsonProvider: JsonProvider = DefaultJsonProvider(), +) : ElementWellknownStore { + private val cachedData = mutableMapOf>() + + init { + initialData.forEach { (url, result) -> + cachedData[URL(url).host] = result + } + } + + override suspend fun get(domain: String): WellknownRetrieverResult { + return cachedData[domain] ?: WellknownRetrieverResult.NotFound + } + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun update(domain: String, wellknown: String): Result { + val json = jsonProvider() + val mapped = try { + json.decodeFromString(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 { + cachedData.remove(domain) + return Result.success(Unit) + } +} diff --git a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt index 51b06f7226..8ec8874ad9 100644 --- a/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt +++ b/libraries/wellknown/test/src/main/kotlin/io/element/android/features/wellknown/test/Fixtures.kt @@ -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(