From 8512279d9619d97370984e1dd426cee5d6e0e2ed Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Wed, 29 Apr 2026 22:05:14 +0200 Subject: [PATCH] Add a cache for Element .well-known file. --- libraries/wellknown/impl/build.gradle.kts | 4 + .../impl/DefaultSessionWellknownRetriever.kt | 48 ++++++- .../DefaultSessionWellknownRetrieverTest.kt | 132 ++++++++++++++++-- 3 files changed, 173 insertions(+), 11 deletions(-) diff --git a/libraries/wellknown/impl/build.gradle.kts b/libraries/wellknown/impl/build.gradle.kts index f803eeec3c..1e2c4d7d61 100644 --- a/libraries/wellknown/impl/build.gradle.kts +++ b/libraries/wellknown/impl/build.gradle.kts @@ -33,9 +33,13 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.network) + implementation(projects.libraries.cachestore.api) + implementation(projects.services.toolbox.api) testCommonDependencies(libs) testImplementation(libs.coroutines.core) + testImplementation(projects.libraries.cachestore.test) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.wellknown.test) testImplementation(projects.services.toolbox.test) } 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 3bcf9bf573..a0223e93cc 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,29 +10,70 @@ 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.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 @ContributesBinding(SessionScope::class) class DefaultSessionWellknownRetriever( private val matrixClient: MatrixClient, private val json: JsonProvider, + private val cacheStore: CacheStore, + private val systemClock: SystemClock, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, ) : 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) { + sessionCoroutineScope.launch { + fetchElementWellKnown(url) + } + } + 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) + } + } + + return fetchElementWellKnown(url) + } + + private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult { return matrixClient .getUrl(url) .mapCatchingExceptions { val data = String(it) - json().decodeFromString(data).map() + val parsed = json().decodeFromString(data).map() + // Also store in cache, if valid + cacheStore.storeData( + key = url, + data = CacheData( + value = data, + updatedAt = systemClock.epochMillis(), + ) + ) + parsed } .toWellknownRetrieverResult() } @@ -51,4 +92,9 @@ class DefaultSessionWellknownRetriever( } } ) + + companion object { + // 1 day + private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L + } } 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 8a230c0317..2824a7e112 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 @@ -6,16 +6,30 @@ * 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.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.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.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 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test @@ -49,14 +63,7 @@ class DefaultSessionWellknownRetrieverTest { val sut = createDefaultSessionWellknownRetriever( getUrlLambda = { Result.success( - """{ - "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" - }""".trimIndent().toByteArray() + WELLKNOWN_CONTENT.toByteArray() ) } ) @@ -127,13 +134,118 @@ class DefaultSessionWellknownRetrieverTest { assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java) } - private fun createDefaultSessionWellknownRetriever( + @Test + fun `get element wellknown hitting cache`() = runTest { + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { lambdaError() }, + cacheStore = InMemoryCacheStore( + initialData = mapOf( + WELLKNOWN_URL to CacheData( + value = WELLKNOWN_CONTENT, + updatedAt = A_FAKE_TIMESTAMP, + ) + ) + ) + ) + assertThat(sut.getElementWellKnown()).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", + ) + ) + ) + } + + @Test + fun `get element wellknown hitting cache containing invalid json`() = runTest { + val cacheStore = InMemoryCacheStore( + initialData = mapOf( + WELLKNOWN_URL to CacheData( + value = WELLKNOWN_CONTENT, + updatedAt = A_FAKE_TIMESTAMP, + ) + ) + ) + val sut = createDefaultSessionWellknownRetriever( + getUrlLambda = { + Result.success("{}".toByteArray()) + }, + cacheStore = cacheStore, + jsonProvider = JsonProvider { throw Exception("Failed to parse JSON") } + ) + assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java) + // Ensure that the cache is deleted after the failure to parse it + assertThat(cacheStore.dataMap).isEmpty() + } + + @Test + fun `get element wellknown hitting outdated cache`() = runTest { + 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) + ) + assertThat(sut.getElementWellKnown()).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", + ) + ) + ) + // Next call returns the updated value + runCurrent() + assertThat(sut.getElementWellKnown()).isEqualTo( + WellknownRetrieverResult.Success( + anElementWellKnown() + ) + ) + } + + private fun TestScope.createDefaultSessionWellknownRetriever( getUrlLambda: (String) -> Result, + jsonProvider: JsonProvider = DefaultJsonProvider(), + cacheStore: CacheStore = InMemoryCacheStore(), + systemClock: SystemClock = FakeSystemClock(), ) = DefaultSessionWellknownRetriever( matrixClient = FakeMatrixClient( userIdServerNameLambda = { "user.domain.org" }, getUrlLambda = getUrlLambda, ), - json = DefaultJsonProvider(), + json = jsonProvider, + cacheStore = cacheStore, + systemClock = systemClock, + sessionCoroutineScope = backgroundScope, ) + + companion object { + private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json" + private const val WELLKNOWN_CONTENT = """{ + "registration_helper_url": "a_registration_url", + "enforce_element_pro": true, + "rageshake_url": "a_rageshake_url", + "brand_color": "#FF0000", + "notification_sound": "a_notification_sound.flac", + "idp_app_scheme": "an_app_scheme" + }""" + } }