mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +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
+36
-34
@@ -10,17 +10,15 @@ package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.cachestore.api.CacheData
|
||||
import io.element.android.libraries.cachestore.api.CacheStore
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
@@ -29,34 +27,41 @@ import timber.log.Timber
|
||||
class DefaultSessionWellknownRetriever(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val json: JsonProvider,
|
||||
private val cacheStore: CacheStore,
|
||||
private val systemClock: SystemClock,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val elementWellknownStore: ElementWellknownStore,
|
||||
) : SessionWellknownRetriever {
|
||||
private val domain by lazy { matrixClient.userIdServerName() }
|
||||
|
||||
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
val cacheData = cacheStore.getData(url)
|
||||
if (cacheData != null) {
|
||||
Timber.d("Element .well-known data retrieved from cache for $domain")
|
||||
// If the cache is outdated, trigger a refresh in background but still return the cached value
|
||||
if (systemClock.epochMillis() > cacheData.updatedAt + CACHE_VALIDITY_MILLIS) {
|
||||
val cacheData = elementWellknownStore.get(domain)
|
||||
return when (cacheData) {
|
||||
is WellknownRetrieverResult.Success -> {
|
||||
Timber.d("Using cached well-known for domain $domain")
|
||||
cacheData
|
||||
}
|
||||
is WellknownRetrieverResult.Outdated -> {
|
||||
// Return the outdated data but refresh in the background
|
||||
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
|
||||
Timber.d("Outdated cached well-known for domain $domain, returning existing value and fetching new one from network")
|
||||
sessionCoroutineScope.launch {
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
fetchElementWellKnown(url)
|
||||
}
|
||||
cacheData
|
||||
}
|
||||
try {
|
||||
val parsed = json().decodeFromString<InternalElementWellKnown>(cacheData.value).map()
|
||||
return WellknownRetrieverResult.Success(parsed)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Failed to parse cached Element .well-known data for $domain, deleting cache")
|
||||
cacheStore.deleteData(url)
|
||||
is WellknownRetrieverResult.NotFound -> {
|
||||
// Try to fetch from the server
|
||||
Timber.d("No cached well-known for domain $domain, fetching from network")
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
fetchElementWellKnown(url)
|
||||
}
|
||||
is WellknownRetrieverResult.Error -> {
|
||||
// Return the error
|
||||
Timber.e(cacheData.exception, "Error retrieving well-known for domain $domain")
|
||||
cacheData.exception.toWellknownRetrieverResult()
|
||||
}
|
||||
}
|
||||
|
||||
return fetchElementWellKnown(url)
|
||||
}
|
||||
|
||||
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
@@ -66,13 +71,11 @@ class DefaultSessionWellknownRetriever(
|
||||
val data = String(it)
|
||||
val parsed = json().decodeFromString<InternalElementWellKnown>(data).map()
|
||||
// Also store in cache, if valid
|
||||
cacheStore.storeData(
|
||||
key = url,
|
||||
data = CacheData(
|
||||
value = data,
|
||||
updatedAt = systemClock.epochMillis(),
|
||||
)
|
||||
)
|
||||
elementWellknownStore.update(domain, data)
|
||||
.onFailure { exception ->
|
||||
Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache")
|
||||
elementWellknownStore.delete(domain)
|
||||
}
|
||||
parsed
|
||||
}
|
||||
.toWellknownRetrieverResult()
|
||||
@@ -85,16 +88,15 @@ class DefaultSessionWellknownRetriever(
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to retrieve Element .well-known from $domain")
|
||||
// This check on message value is not ideal but this is what we got from the SDK.
|
||||
if ((it as? ClientException.Generic)?.message?.contains("404") == true) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Error(it as Exception)
|
||||
}
|
||||
it.toWellknownRetrieverResult()
|
||||
}
|
||||
)
|
||||
|
||||
companion object {
|
||||
// 1 day
|
||||
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
|
||||
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
|
||||
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Error(this as Exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+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.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.network.RetrofitFactory
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import retrofit2.HttpException
|
||||
import timber.log.Timber
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultWellknownRetriever(
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val elementWellknownStore: ElementWellknownStore,
|
||||
private val jsonProvider: JsonProvider,
|
||||
) : WellknownRetriever {
|
||||
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
val domain = URL(baseUrl).host
|
||||
return buildWellknownApi(baseUrl)
|
||||
.map { wellknownApi ->
|
||||
try {
|
||||
val result = wellknownApi.getElementWellKnown().map()
|
||||
WellknownRetrieverResult.Success(result)
|
||||
val json = jsonProvider()
|
||||
val result = wellknownApi.getElementWellKnown()
|
||||
elementWellknownStore.update(domain, json.encodeToString(result))
|
||||
WellknownRetrieverResult.Success(result.map())
|
||||
} catch (e: Exception) {
|
||||
// Is it a 404?
|
||||
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
-1
@@ -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 {
|
||||
|
||||
+35
-33
@@ -11,20 +11,16 @@
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.wellknown.test.FakeElementWellknownStore
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.cachestore.api.CacheData
|
||||
import io.element.android.libraries.cachestore.api.CacheStore
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
|
||||
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -53,6 +49,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -79,6 +76,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -108,6 +106,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
brandColor = null,
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
contentScannerUrl = null,
|
||||
customRecoveryPassphrase = null,
|
||||
)
|
||||
)
|
||||
@@ -225,16 +224,23 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown 404 http error counts as not found`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.failure(ClientException.Generic("Not Found: 404 status code", "The file could not be found"))
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting cache`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = { lambdaError() },
|
||||
cacheStore = InMemoryCacheStore(
|
||||
cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to CacheData(
|
||||
value = WELLKNOWN_CONTENT,
|
||||
updatedAt = A_FAKE_TIMESTAMP,
|
||||
)
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -248,6 +254,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -255,12 +262,9 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting cache containing invalid json`() = runTest {
|
||||
val cacheStore = InMemoryCacheStore(
|
||||
val cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to CacheData(
|
||||
value = WELLKNOWN_CONTENT,
|
||||
updatedAt = A_FAKE_TIMESTAMP,
|
||||
)
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
|
||||
)
|
||||
)
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
@@ -272,28 +276,24 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
// Ensure that the cache is deleted after the failure to parse it
|
||||
assertThat(cacheStore.dataMap).isEmpty()
|
||||
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting outdated cache`() = runTest {
|
||||
val cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()),
|
||||
)
|
||||
)
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success("{}".toByteArray())
|
||||
},
|
||||
cacheStore = InMemoryCacheStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to CacheData(
|
||||
value = WELLKNOWN_CONTENT,
|
||||
updatedAt = 0L,
|
||||
)
|
||||
),
|
||||
),
|
||||
// 3 days later, so the cache is outdated
|
||||
systemClock = FakeSystemClock(3 * 24 * 60 * 60 * 1000L)
|
||||
cacheStore = cacheStore,
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
WellknownRetrieverResult.Outdated(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
@@ -302,6 +302,7 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -314,20 +315,20 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString<InternalElementWellKnown>(WELLKNOWN_CONTENT).map()
|
||||
|
||||
private fun TestScope.createDefaultSessionWellknownRetriever(
|
||||
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
|
||||
getUrlLambda: (String) -> Result<ByteArray>,
|
||||
jsonProvider: JsonProvider = DefaultJsonProvider(),
|
||||
cacheStore: CacheStore = InMemoryCacheStore(),
|
||||
systemClock: SystemClock = FakeSystemClock(),
|
||||
) = DefaultSessionWellknownRetriever(
|
||||
matrixClient = FakeMatrixClient(
|
||||
userIdServerNameLambda = { "user.domain.org" },
|
||||
getUrlLambda = getUrlLambda,
|
||||
),
|
||||
json = jsonProvider,
|
||||
cacheStore = cacheStore,
|
||||
systemClock = systemClock,
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
elementWellknownStore = cacheStore,
|
||||
)
|
||||
|
||||
companion object {
|
||||
@@ -338,7 +339,8 @@ class DefaultSessionWellknownRetrieverTest {
|
||||
"rageshake_url": "a_rageshake_url",
|
||||
"brand_color": "#FF0000",
|
||||
"notification_sound": "a_notification_sound.flac",
|
||||
"idp_app_scheme": "an_app_scheme"
|
||||
"idp_app_scheme": "an_app_scheme",
|
||||
"content_scanner_url": "https://content-scanner.example.com"
|
||||
}"""
|
||||
}
|
||||
}
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user