Merge branch 'develop' into feature/fga/local_network_permission

# Conflicts:
#	libraries/androidutils/src/main/AndroidManifest.xml
This commit is contained in:
ganfra
2026-07-09 17:50:24 +02:00
114 changed files with 2715 additions and 192 deletions
@@ -24,6 +24,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.InputTransformation
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldDecorator
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -161,6 +166,55 @@ fun TextField(
}
}
@Composable
fun TextField(
state: TextFieldState,
modifier: Modifier = Modifier,
label: String? = null,
supportingText: String? = null,
placeholder: String? = null,
leadingIcon: @Composable (() -> Unit)? = null,
trailingIcon: @Composable (() -> Unit)? = null,
validity: TextFieldValidity = TextFieldValidity.None,
enabled: Boolean = true,
readOnly: Boolean = false,
inputTransformation: InputTransformation? = null,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
onKeyboardAction: KeyboardActionHandler? = null,
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val isFocused by interactionSource.collectIsFocusedAsState()
BasicTextField(
state = state,
modifier = modifier,
textStyle = textFieldStyle(enabled),
interactionSource = interactionSource,
enabled = enabled,
readOnly = readOnly,
inputTransformation = inputTransformation,
cursorBrush = SolidColor(ElementTheme.colors.textPrimary),
keyboardOptions = keyboardOptions,
onKeyboardAction = onKeyboardAction,
lineLimits = lineLimits,
decorator = TextFieldDecorator { innerTextField ->
DecorationBox(
label = label,
readOnly = readOnly,
enabled = enabled,
isFocused = isFocused,
validity = validity,
leadingIcon = leadingIcon,
placeholder = placeholder,
isTextEmpty = state.text.isEmpty(),
innerTextField = innerTextField,
trailingIcon = trailingIcon,
supportingText = supportingText,
)
}
)
}
@Composable
private fun DecorationBox(
label: String?,
@@ -152,4 +152,11 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
UserStatus(
key = "feature.user_status",
title = "User status",
description = "Allow users to set a status (e.g. In a meeting, Away) visible to their contacts.",
defaultValue = { false },
isFinished = false,
),
}
@@ -38,12 +38,14 @@ 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
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -74,6 +76,7 @@ interface MatrixClient {
val ignoredUsersFlow: StateFlow<ImmutableList<UserId>>
val roomMembershipObserver: RoomMembershipObserver
val ownBeaconInfoUpdates: Flow<BeaconInfoUpdate>
val contentScanner: ContentScanner?
suspend fun getJoinedRoom(roomId: RoomId): JoinedRoom?
suspend fun getRoom(roomId: RoomId): BaseRoom?
suspend fun findDM(userId: UserId): Result<RoomId?>
@@ -87,6 +90,10 @@ interface MatrixClient {
suspend fun setDisplayName(displayName: String): Result<Unit>
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
suspend fun setUserStatus(status: UserStatus): Result<Unit>
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
suspend fun clearUserStatus(): Result<Unit>
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
@@ -22,3 +22,6 @@ sealed class ClientException(message: String, val details: String?, cause: Throw
fun ClientException.isNetworkError(): Boolean {
return this is ClientException.Generic && message?.contains("error sending request for url", ignoreCase = true) == true
}
fun Throwable.isNetworkError(): Boolean =
(this as? ClientException)?.isNetworkError() == true
@@ -0,0 +1,27 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.scanner
import io.element.android.libraries.matrix.api.media.MediaSource
/**
* Component used to manually scan media content for potential security risks.
*
* While the Matrix SDK automatically scans media content we load if a content scanner instance is provided, this interface allows for scanning
* some media sources in advance without actually loading their contents, which can be useful for pre-fetching scenarios.
*/
interface ContentScanner {
/**
* Manually scans the given [mediaSource] for potential security risks.
*
* @param mediaSource The media source to scan.
* @return A [Result] containing a [Boolean] indicating whether the content is safe (true) or potentially unsafe (false).
* If the scan fails, the [Result] will contain an exception.
*/
suspend fun scan(mediaSource: MediaSource): Result<Boolean>
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.scanner
/**
* Provides the URL of the content scanner service for a given homeserver, if any is set up.
*/
fun interface ContentScannerUrlProvider {
/**
* Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up.
*/
suspend fun getContentScannerUrl(homeserver: String): Result<String?>
}
@@ -0,0 +1,26 @@
/*
* 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.user
import android.os.Parcelable
import androidx.compose.runtime.Immutable
import kotlinx.parcelize.Parcelize
@Immutable
sealed interface DisplayedStatus : Parcelable {
/** Status set manually by the user via m.status. */
@Parcelize
data class UserSet(val status: UserStatus) : DisplayedStatus
/** Status set automatically when the user is in a call via m.call. */
@Parcelize
data class InCall(
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
val callJoinedTs: Long,
) : DisplayedStatus
}
@@ -17,4 +17,6 @@ data class MatrixUser(
val userId: UserId,
val displayName: String? = null,
val avatarUrl: String? = null,
val displayedStatus: DisplayedStatus? = null,
val rawStatus: UserStatus? = null,
) : Parcelable
@@ -0,0 +1,17 @@
/*
* 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.user
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class UserStatus(
val emoji: String,
val text: String,
) : Parcelable
@@ -45,11 +45,14 @@ 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
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
@@ -136,6 +139,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
@Suppress("LargeClass")
class RustMatrixClient(
override val sessionPaths: SessionPaths,
private val innerClient: Client,
@@ -150,6 +154,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())
@@ -259,6 +264,8 @@ class RustMatrixClient(
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
@Volatile private var localUserStatus: UserStatus? = null
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
@@ -444,7 +451,12 @@ class RustMatrixClient(
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { matrixUser ->
_userProfile.emit(matrixUser)
_userProfile.emit(
matrixUser.copy(
rawStatus = localUserStatus,
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
)
)
// Also update our session storage
sessionStore.updateUserProfile(
sessionId = sessionId.value,
@@ -475,6 +487,28 @@ class RustMatrixClient(
runCatchingExceptions { innerClient.removeAvatar() }
}
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = status
_userProfile.emit(
_userProfile.value.copy(
rawStatus = status,
displayedStatus = DisplayedStatus.UserSet(status),
)
)
Result.success(Unit)
}
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = null
_userProfile.emit(
_userProfile.value.copy(
rawStatus = null,
displayedStatus = null,
)
)
Result.success(Unit)
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.joinRoomById(roomId.value).destroy()
@@ -17,11 +17,14 @@ import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.scanner.RustContentScanner
import io.element.android.libraries.matrix.impl.storage.SqliteStoreBuilderProvider
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.network.useragent.UserAgentProvider
@@ -34,6 +37,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.CrossProcessLockConfig
import org.matrix.rustcomponents.sdk.RequestConfig
import org.matrix.rustcomponents.sdk.Session
@@ -66,6 +70,7 @@ class RustMatrixClientFactory(
private val clientBuilderProvider: ClientBuilderProvider,
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
private val workManagerScheduler: WorkManagerScheduler,
private val contentScannerUrlProvider: ContentScannerUrlProvider,
) {
private val sessionDelegate = RustClientSessionDelegate(
sessionStore = sessionStore,
@@ -113,6 +118,23 @@ class RustMatrixClientFactory(
client.setUtdDelegate(UtdTracker(analyticsService))
val domainName = UserId(client.userId()).domainName
// If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client.
// This allows the SDK to use the content scanner for automatic media scanning.
// If no content scanner URL is available, the contentScanner will be null.
val contentScanner = domainName?.let {
contentScannerUrlProvider.getContentScannerUrl(domainName)
.getOrNull()
?.let { contentScannerUrl ->
val contentScanner = ContentScanner(contentScannerUrl)
client.setContentScanner(contentScanner)
RustContentScanner(
client = client,
rustScanner = contentScanner,
)
}
}
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
@@ -132,6 +154,7 @@ class RustMatrixClientFactory(
featureFlagService = featureFlagService,
analyticsService = analyticsService,
workManagerScheduler = workManagerScheduler,
contentScanner = contentScanner,
).also {
Timber.tag("RustMatrixClient").i("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'")
}
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@@ -96,4 +97,9 @@ object SessionMatrixModule {
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
return matrixClient.homeserverCapabilities()
}
@Provides
fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? {
return matrixClient.contentScanner
}
}
@@ -9,11 +9,13 @@
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.impl.exception.mapClientException
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.use
@@ -37,7 +39,7 @@ class RustMediaLoader(
source.toRustMediaSource().use { source ->
innerClient.getMediaContent(source)
}
}
}.mapFailure { it.mapClientException() }
}
override suspend fun loadMediaThumbnail(
@@ -54,7 +56,7 @@ class RustMediaLoader(
height = height.toULong()
)
}
}
}.mapFailure { it.mapClientException() }
}
override suspend fun downloadMediaFile(
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.scanner
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustContentScanner(
private val client: Client,
private val rustScanner: RustScanner,
) : ContentScanner {
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
return runCatchingExceptions {
rustScanner.scan(client, mediaSource.toRustMediaSource()).clean
}
}
}
private fun MediaSource.toRustMediaSource(): RustMediaSource {
val json = this.json
return try {
if (json != null) {
RustMediaSource.fromJson(json)
} else {
RustMediaSource.fromUrl(safeUrl)
}
} catch (e: LinkageError) {
// Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
if (isTest) RustMediaSource(NoHandle) else throw e
}
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
@@ -51,6 +52,7 @@ fun TestScope.createRustMatrixClientFactory(
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -65,4 +67,5 @@ fun TestScope.createRustMatrixClientFactory(
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProvider = contentScannerUrlProvider,
)
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.scanner.FakeContentScanner
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
@@ -156,5 +157,6 @@ class RustMatrixClientTest {
featureFlagService = FakeFeatureFlagService(),
analyticsService = FakeAnalyticsService(),
workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}),
contentScanner = FakeContentScanner(),
)
}
@@ -0,0 +1,24 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.scanner
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ContentScanner
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.NoHandle
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
class FakeFfiContentScanner(
private val scan: suspend (client: Client, mediaSource: MediaSource) -> MediaScanResponse = { _, _ ->
MediaScanResponse(clean = true, info = "Just peachy")
},
) : ContentScanner(NoHandle) {
override suspend fun scan(client: Client, mediaSource: MediaSource): MediaScanResponse {
return scan.invoke(client, mediaSource)
}
}
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.scanner
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.test.media.aMediaSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
class RustContentScannerTest {
@Test
fun `test successful valid scan`() = runTest {
val scanner = RustContentScanner(
client = FakeFfiClient(),
rustScanner = FakeFfiContentScanner(),
)
scanner.scan(aMediaSource()).run {
assertThat(isSuccess).isTrue()
assertThat(getOrNull()).isTrue()
}
}
@Test
fun `test successful invalid scan`() = runTest {
val scanner = RustContentScanner(
client = FakeFfiClient(),
rustScanner = FakeFfiContentScanner(scan = { _, _ -> MediaScanResponse(clean = false, info = "Not clean") })
)
scanner.scan(aMediaSource()).run {
assertThat(isSuccess).isTrue()
assertThat(getOrNull()).isFalse()
}
}
@Test
fun `test failed scan`() = runTest {
val scanner = RustContentScanner(
client = FakeFfiClient(),
rustScanner = FakeFfiContentScanner(scan = { _, _ -> throw IllegalStateException("BOOM") })
)
scanner.scan(aMediaSource()).run {
assertThat(isFailure).isTrue()
assertThat(getOrNull()).isNull()
}
}
}
@@ -38,11 +38,14 @@ 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
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
@@ -123,6 +126,7 @@ class FakeMatrixClient(
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
override val contentScanner: ContentScanner? = null
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
private set
@@ -130,6 +134,12 @@ class FakeMatrixClient(
private set
var removeAvatarCalled: Boolean = false
private set
var setUserStatusCalled: Boolean = false
private set
var clearUserStatusCalled: Boolean = false
private set
var setUserStatusResult: Result<Unit> = Result.success(Unit)
var clearUserStatusResult: Result<Unit> = Result.success(Unit)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile
@@ -252,6 +262,28 @@ class FakeMatrixClient(
return removeAvatarResult
}
override suspend fun setUserStatus(status: UserStatus): Result<Unit> {
setUserStatusCalled = true
if (setUserStatusResult.isSuccess) {
_userProfile.emit(_userProfile.value.copy(
rawStatus = status,
displayedStatus = DisplayedStatus.UserSet(status),
))
}
return setUserStatusResult
}
override suspend fun clearUserStatus(): Result<Unit> {
clearUserStatusCalled = true
if (clearUserStatusResult.isSuccess) {
_userProfile.emit(_userProfile.value.copy(
rawStatus = null,
displayedStatus = null,
))
}
return clearUserStatusResult
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test.scanner
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
class FakeContentScanner(
private val scan: suspend (mediaSource: MediaSource) -> Result<Boolean> = { Result.success(true) },
) : ContentScanner {
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
return scan.invoke(mediaSource)
}
}
@@ -26,6 +26,7 @@ dependencies {
implementation(projects.libraries.designsystem)
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.svg)
implementation(libs.coil.network.okhttp)
testCommonDependencies(libs, true)
@@ -13,6 +13,7 @@ import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
import io.element.android.libraries.matrix.api.exception.isNetworkError
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.toFile
@@ -34,7 +35,18 @@ internal class CoilMediaFetcher(
}
return when (val kind = mediaData.kind) {
is MediaRequestData.Kind.Content -> fetchContent(mediaSource)
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaSource, kind)
is MediaRequestData.Kind.Thumbnail -> {
fetchThumbnail(mediaSource, kind).fold(
onSuccess = { it },
onFailure = { error ->
if (error.isNetworkError()) {
null
} else {
fetchContent(mediaSource)
}
}
)
}
is MediaRequestData.Kind.File -> fetchFile(mediaSource, kind)
}
}
@@ -74,7 +86,7 @@ internal class CoilMediaFetcher(
}.getOrNull()
}
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): FetchResult? {
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): Result<FetchResult> {
return mediaLoader.loadMediaThumbnail(
source = mediaSource,
width = kind.width,
@@ -83,7 +95,7 @@ internal class CoilMediaFetcher(
byteArray.asSourceResult()
}.onFailure {
Timber.e(it)
}.getOrNull()
}
}
private fun ByteArray.asSourceResult(): SourceFetchResult {
@@ -14,6 +14,7 @@ import coil3.ImageLoader
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.svg.SvgDecoder
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.annotations.ApplicationContext
@@ -49,6 +50,8 @@ class DefaultImageLoaderFactory(
return ImageLoader.Builder(context)
.components {
add(okHttpNetworkFetcherFactory)
// Add svg support
add(SvgDecoder.Factory())
// Add gif support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(AnimatedImageDecoder.Factory())
@@ -19,6 +19,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider
class FakePickerProvider : PickerProvider {
private var mimeType = MimeTypes.Any
private var result: Uri? = null
private var multipleResults: List<Uri>? = null
@Composable
override fun registerGalleryPicker(onResult: (uri: Uri?, mimeType: String?) -> Unit): PickerLauncher<PickVisualMediaRequest, Uri?> {
@@ -32,7 +33,7 @@ class FakePickerProvider : PickerProvider {
@Composable
override fun registerGalleryMultiPicker(onResult: (uris: List<Uri>) -> Unit): PickerLauncher<PickVisualMediaRequest, List<Uri>> {
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
return NoOpPickerLauncher { onResult(multipleResults ?: result?.let { listOf(it) } ?: emptyList()) }
}
@Composable
@@ -42,7 +43,7 @@ class FakePickerProvider : PickerProvider {
@Composable
override fun registerFileMultiPicker(mimeType: String, onResult: (uris: List<Uri>) -> Unit): PickerLauncher<Array<String>, List<Uri>> {
return NoOpPickerLauncher { onResult(result?.let { listOf(it) } ?: emptyList()) }
return NoOpPickerLauncher { onResult(multipleResults ?: result?.let { listOf(it) } ?: emptyList()) }
}
@Composable
@@ -59,6 +60,10 @@ class FakePickerProvider : PickerProvider {
this.result = value
}
fun givenMultipleResults(uris: List<Uri>) {
this.multipleResults = uris
}
fun givenMimeType(mimeType: String) {
this.mimeType = mimeType
}
@@ -67,7 +67,7 @@ class AndroidMediaPreProcessor(
*/
private const val IMAGE_SCALE_REF_SIZE = 640
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP, MimeTypes.Svg)
private val notCompressibleImageTypes = listOf(MimeTypes.Gif, MimeTypes.WebP)
}
private val contentResolver = context.contentResolver
@@ -83,10 +83,7 @@ class AndroidMediaPreProcessor(
): Result<MediaUploadInfo> = withContext(coroutineDispatchers.computation) {
runCatchingExceptions {
val result = when {
// Special case for SVG, since Android can't read its metadata or create a thumbnail, it must be sent as a file
mimeType == MimeTypes.Svg -> {
processFile(uri, mimeType)
}
mimeType == MimeTypes.Svg -> processSvgImage(uri, mimeType)
mimeType.isMimeTypeImage() -> {
val shouldBeCompressed = mediaOptimizationConfig.compressImages && mimeType !in notCompressibleImageTypes
processImage(uri, mimeType, shouldBeCompressed)
@@ -160,6 +157,7 @@ class AndroidMediaPreProcessor(
private suspend fun processImage(uri: Uri, mimeType: String, shouldBeCompressed: Boolean): MediaUploadInfo {
Timber.d("Processing image ${uri.path.orEmpty().hash()}")
suspend fun processImageWithCompression(): MediaUploadInfo {
// Read the orientation metadata from its own stream. Trying to reuse this stream for compression will fail.
val orientation = contentResolver.openInputStream(uri).use { input ->
@@ -220,6 +218,28 @@ class AndroidMediaPreProcessor(
}
}
private val svgDimensionExtractor = SvgDimensionExtractor()
private suspend fun processSvgImage(uri: Uri, mimeType: String): MediaUploadInfo {
Timber.d("Processing SVG image ${uri.path.orEmpty().hash()}")
val file = copyToTmpFile(uri)
val size = svgDimensionExtractor.extractDimensions(file)
val imageInfo = ImageInfo(
width = size.width.toLong(),
height = size.height.toLong(),
mimetype = mimeType,
size = file.length(),
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
return MediaUploadInfo.Image(
file = file,
imageInfo = imageInfo,
thumbnailFile = null,
)
}
private suspend fun processVideo(uri: Uri, mimeType: String?, videoCompressionPreset: VideoCompressionPreset): MediaUploadInfo {
Timber.d("Processing video ${uri.path.orEmpty().hash()}")
val resultFile = runCatchingExceptions {
@@ -0,0 +1,67 @@
/*
* 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.mediaupload.impl
import android.util.Xml
import org.xmlpull.v1.XmlPullParser
import java.io.File
class SvgDimensionExtractor(
private val defaultWidth: Long = 640L,
private val defaultHeight: Long = 480L,
) {
fun extractDimensions(file: File): android.util.Size {
return file.inputStream().use { inputStream ->
try {
val parser = Xml.newPullParser()
parser.setInput(inputStream, null)
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && parser.name.equals("svg", ignoreCase = true)) {
val width = parser.getAttributeValue(null, "width")
val height = parser.getAttributeValue(null, "height")
val viewBox = parser.getAttributeValue(null, "viewBox")
val parsedWidth = width?.let { parseLength(it) }
val parsedHeight = height?.let { parseLength(it) }
if (parsedWidth != null && parsedHeight != null) {
return android.util.Size(parsedWidth.toInt(), parsedHeight.toInt())
}
if (viewBox != null) {
val parts = viewBox.trim().split("\\s+".toRegex()).map { it.toFloatOrNull() }
if (parts.size == 4 && parts[2] != null && parts[3] != null) {
val vbWidth = parts[2]!!.toLong().coerceAtLeast(1)
val vbHeight = parts[3]!!.toLong().coerceAtLeast(1)
return android.util.Size(vbWidth.toInt(), vbHeight.toInt())
}
}
return android.util.Size((parsedWidth ?: defaultWidth).toInt(), (parsedHeight ?: defaultHeight).toInt())
}
eventType = parser.next()
}
android.util.Size(defaultWidth.toInt(), defaultHeight.toInt())
} catch (_: Exception) {
android.util.Size(defaultWidth.toInt(), defaultHeight.toInt())
}
}
}
private fun parseLength(value: String): Long? {
val trimmed = value.trim()
if (trimmed.isEmpty()) return null
return try {
val numericPart = trimmed.replace(Regex("[^\\d.]"), "")
if (numericPart.isEmpty()) null else numericPart.toFloat().toLong().coerceAtLeast(1)
} catch (_: NumberFormatException) {
null
}
}
}
Binary file not shown.
@@ -399,6 +399,54 @@ class AndroidMediaPreProcessorTest : RobolectricTest() {
)
}
@Test
fun `test processing svg`() = runTest {
val mediaUploadInfo = process(
asset = assetImageSvg,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = true,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
width = 800,
height = 600,
mimetype = MimeTypes.Svg,
size = 210,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
assertThat(info.thumbnailFile).isNull()
}
@Test
fun `test processing svg without compression`() = runTest {
val mediaUploadInfo = process(
asset = assetImageSvg,
mediaOptimizationConfig = MediaOptimizationConfig(
compressImages = false,
videoCompressionPreset = VideoCompressionPreset.STANDARD,
),
)
val info = mediaUploadInfo as MediaUploadInfo.Image
assertThat(info.imageInfo).isEqualTo(
ImageInfo(
width = 800,
height = 600,
mimetype = MimeTypes.Svg,
size = 210,
thumbnailInfo = null,
thumbnailSource = null,
blurhash = null,
)
)
assertThat(info.thumbnailFile).isNull()
}
@Test
fun `test processing audio`() = runTest {
val mediaUploadInfo = process(
@@ -83,3 +83,14 @@ val assetAnimatedGif = Asset(
width = 800,
height = 600,
)
/**
* "image.svg" is an 800 x 600 SVG image with a size of 210 bytes.
*/
val assetImageSvg = Asset(
filename = "image.svg",
mimeType = MimeTypes.Svg,
size = 210,
width = 800,
height = 600,
)
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- To be able to install APK from the application -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
</manifest>
@@ -16,6 +16,7 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
import io.element.android.libraries.network.interceptors.FormattedJsonHttpLogger
import io.element.android.libraries.network.interceptors.UserAgentInterceptor
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import java.util.concurrent.TimeUnit
@@ -36,6 +37,9 @@ object NetworkModule {
addInterceptor(dynamicHttpLoggingInterceptor)
}.build()
@Provides
fun providesCallFactory(okHttpClient: OkHttpClient): Call.Factory = Call.Factory { request -> okHttpClient.newCall(request) }
@Provides
@SingleIn(AppScope::class)
fun providesHttpLoggingInterceptor(): HttpLoggingInterceptor {
@@ -11,19 +11,19 @@ package io.element.android.libraries.network
import dev.zacsweers.metro.Inject
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.uri.ensureTrailingSlash
import okhttp3.Call
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
@Inject
class RetrofitFactory(
private val okHttpClient: () -> OkHttpClient,
private val callFactory: () -> Call.Factory,
private val json: () -> JsonProvider,
) {
fun create(baseUrl: String): Retrofit = Retrofit.Builder()
.baseUrl(baseUrl.ensureTrailingSlash())
.addConverterFactory(json()().asConverterFactory("application/json".toMediaType()))
.callFactory { request -> okHttpClient().newCall(request) }
.callFactory(callFactory())
.build()
}
@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2025 Element Creations Ltd.
~ Copyright 2022 New Vector Ltd.
~ Copyright 2023 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
</manifest>
@@ -16,4 +16,5 @@ data class ElementWellKnown(
val notificationSound: String?,
val identityProviderAppScheme: String?,
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
val contentScannerUrl: String?,
)
@@ -0,0 +1,14 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.api
interface ElementWellknownStore {
suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown>
suspend fun update(domain: String, wellknown: String): Result<Unit>
suspend fun delete(domain: String): Result<Unit>
}
@@ -14,6 +14,11 @@ sealed interface WellknownRetrieverResult<out T> {
*/
data class Success<out T>(val data: T) : WellknownRetrieverResult<T>
/**
* Well-known data has been retrieved from the local cache but is outdated and a manual refresh has been launched.
*/
data class Outdated<out T>(val data: T) : WellknownRetrieverResult<T>
/**
* Well-known data is not found (file does not exist server side, we got a 404).
*/
@@ -26,6 +31,7 @@ sealed interface WellknownRetrieverResult<out T> {
fun dataOrNull(): T? = when (this) {
is Success<T> -> data
is Outdated<T> -> data
is Error -> null
NotFound -> null
}
@@ -10,17 +10,15 @@ package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -29,34 +27,41 @@ import timber.log.Timber
class DefaultSessionWellknownRetriever(
private val matrixClient: MatrixClient,
private val json: JsonProvider,
private val cacheStore: CacheStore,
private val systemClock: SystemClock,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val elementWellknownStore: ElementWellknownStore,
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
val url = "https://$domain/.well-known/element/element.json"
val cacheData = cacheStore.getData(url)
if (cacheData != null) {
Timber.d("Element .well-known data retrieved from cache for $domain")
// If the cache is outdated, trigger a refresh in background but still return the cached value
if (systemClock.epochMillis() > cacheData.updatedAt + CACHE_VALIDITY_MILLIS) {
val cacheData = elementWellknownStore.get(domain)
return when (cacheData) {
is WellknownRetrieverResult.Success -> {
Timber.d("Using cached well-known for domain $domain")
cacheData
}
is WellknownRetrieverResult.Outdated -> {
// Return the outdated data but refresh in the background
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
Timber.d("Outdated cached well-known for domain $domain, returning existing value and fetching new one from network")
sessionCoroutineScope.launch {
val url = "https://$domain/.well-known/element/element.json"
fetchElementWellKnown(url)
}
cacheData
}
try {
val parsed = json().decodeFromString<InternalElementWellKnown>(cacheData.value).map()
return WellknownRetrieverResult.Success(parsed)
} catch (e: Exception) {
Timber.e(e, "Failed to parse cached Element .well-known data for $domain, deleting cache")
cacheStore.deleteData(url)
is WellknownRetrieverResult.NotFound -> {
// Try to fetch from the server
Timber.d("No cached well-known for domain $domain, fetching from network")
val url = "https://$domain/.well-known/element/element.json"
fetchElementWellKnown(url)
}
is WellknownRetrieverResult.Error -> {
// Return the error
Timber.e(cacheData.exception, "Error retrieving well-known for domain $domain")
cacheData.exception.toWellknownRetrieverResult()
}
}
return fetchElementWellKnown(url)
}
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
@@ -66,13 +71,11 @@ class DefaultSessionWellknownRetriever(
val data = String(it)
val parsed = json().decodeFromString<InternalElementWellKnown>(data).map()
// Also store in cache, if valid
cacheStore.storeData(
key = url,
data = CacheData(
value = data,
updatedAt = systemClock.epochMillis(),
)
)
elementWellknownStore.update(domain, data)
.onFailure { exception ->
Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache")
elementWellknownStore.delete(domain)
}
parsed
}
.toWellknownRetrieverResult()
@@ -85,16 +88,15 @@ class DefaultSessionWellknownRetriever(
onFailure = {
Timber.e(it, "Failed to retrieve Element .well-known from $domain")
// This check on message value is not ideal but this is what we got from the SDK.
if ((it as? ClientException.Generic)?.message?.contains("404") == true) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(it as Exception)
}
it.toWellknownRetrieverResult()
}
)
companion object {
// 1 day
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(this as Exception)
}
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesBinding(AppScope::class)
class DefaultWellKnownStore(
private val cacheStore: CacheStore,
private val json: JsonProvider,
private val systemClock: SystemClock,
) : ElementWellknownStore {
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
return runCatchingExceptions {
val cachedData = cacheStore.getData(key(domain))
if (cachedData != null) {
val data = json().decodeFromString<InternalElementWellKnown>(cachedData.value).map()
if (systemClock.epochMillis() > cachedData.updatedAt + CACHE_VALIDITY_MILLIS) {
WellknownRetrieverResult.Outdated(data)
} else {
WellknownRetrieverResult.Success(data)
}
} else {
WellknownRetrieverResult.NotFound
}
}.recover {
WellknownRetrieverResult.Error(it as Exception)
}.getOrThrow()
}
override suspend fun update(domain: String, wellknown: String): Result<Unit> {
return runCatchingExceptions {
cacheStore.storeData(key(domain), CacheData(wellknown, systemClock.epochMillis()))
}
}
override suspend fun delete(domain: String): Result<Unit> {
return runCatchingExceptions {
cacheStore.deleteData(key(domain))
}
}
private fun key(domain: String): String = "https://$domain/.well-known/element/element.json"
companion object {
// 1 day
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
}
}
@@ -10,26 +10,34 @@ package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import retrofit2.HttpException
import timber.log.Timber
import java.net.HttpURLConnection
import java.net.URL
@ContributesBinding(AppScope::class)
class DefaultWellknownRetriever(
private val retrofitFactory: RetrofitFactory,
private val elementWellknownStore: ElementWellknownStore,
private val jsonProvider: JsonProvider,
) : WellknownRetriever {
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
val domain = URL(baseUrl).host
return buildWellknownApi(baseUrl)
.map { wellknownApi ->
try {
val result = wellknownApi.getElementWellKnown().map()
WellknownRetrieverResult.Success(result)
val json = jsonProvider()
val result = wellknownApi.getElementWellKnown()
elementWellknownStore.update(domain, json.encodeToString(result))
WellknownRetrieverResult.Success(result.map())
} catch (e: Exception) {
// Is it a 404?
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
@@ -44,6 +44,8 @@ data class InternalElementWellKnown(
val identityProviderAppScheme: String? = null,
@SerialName("custom_recovery_passphrase")
val customRecoveryPassphrase: InternalCustomRecoveryPassphrase? = null,
@SerialName("content_scanner_url")
val contentScannerUrl: String? = null,
)
@Serializable
@@ -22,7 +22,7 @@ private val loggerTag = LoggerTag("Wellknown")
*/
private const val MINIMUM_PASSPHRASE_CHARACTER_COUNT = 1
internal fun InternalElementWellKnown.map() = ElementWellKnown(
fun InternalElementWellKnown.map() = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
rageshakeUrl = rageshakeUrl,
@@ -30,6 +30,7 @@ internal fun InternalElementWellKnown.map() = ElementWellKnown(
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase?.toPublic(),
contentScannerUrl = contentScannerUrl,
)
private fun InternalCustomRecoveryPassphrase.toPublic(): CustomRecoveryPassphrase {
@@ -11,20 +11,16 @@
package io.element.android.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wellknown.test.FakeElementWellknownStore
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -53,6 +49,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
)
)
)
@@ -79,6 +76,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
@@ -108,6 +106,7 @@ class DefaultSessionWellknownRetrieverTest {
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
)
)
@@ -225,16 +224,23 @@ class DefaultSessionWellknownRetrieverTest {
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown 404 http error counts as not found`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(ClientException.Generic("Not Found: 404 status code", "The file could not be found"))
}
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
}
@Test
fun `get element wellknown hitting cache`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = { lambdaError() },
cacheStore = InMemoryCacheStore(
cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to CacheData(
value = WELLKNOWN_CONTENT,
updatedAt = A_FAKE_TIMESTAMP,
)
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
)
)
)
@@ -248,6 +254,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
@@ -255,12 +262,9 @@ class DefaultSessionWellknownRetrieverTest {
@Test
fun `get element wellknown hitting cache containing invalid json`() = runTest {
val cacheStore = InMemoryCacheStore(
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to CacheData(
value = WELLKNOWN_CONTENT,
updatedAt = A_FAKE_TIMESTAMP,
)
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
)
)
val sut = createDefaultSessionWellknownRetriever(
@@ -272,28 +276,24 @@ class DefaultSessionWellknownRetrieverTest {
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
// Ensure that the cache is deleted after the failure to parse it
assertThat(cacheStore.dataMap).isEmpty()
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
}
@Test
fun `get element wellknown hitting outdated cache`() = runTest {
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()),
)
)
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success("{}".toByteArray())
},
cacheStore = InMemoryCacheStore(
initialData = mapOf(
WELLKNOWN_URL to CacheData(
value = WELLKNOWN_CONTENT,
updatedAt = 0L,
)
),
),
// 3 days later, so the cache is outdated
systemClock = FakeSystemClock(3 * 24 * 60 * 60 * 1000L)
cacheStore = cacheStore,
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
WellknownRetrieverResult.Outdated(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
@@ -302,6 +302,7 @@ class DefaultSessionWellknownRetrieverTest {
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
@@ -314,20 +315,20 @@ class DefaultSessionWellknownRetrieverTest {
)
}
private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString<InternalElementWellKnown>(WELLKNOWN_CONTENT).map()
private fun TestScope.createDefaultSessionWellknownRetriever(
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
getUrlLambda: (String) -> Result<ByteArray>,
jsonProvider: JsonProvider = DefaultJsonProvider(),
cacheStore: CacheStore = InMemoryCacheStore(),
systemClock: SystemClock = FakeSystemClock(),
) = DefaultSessionWellknownRetriever(
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { "user.domain.org" },
getUrlLambda = getUrlLambda,
),
json = jsonProvider,
cacheStore = cacheStore,
systemClock = systemClock,
sessionCoroutineScope = backgroundScope,
elementWellknownStore = cacheStore,
)
companion object {
@@ -338,7 +339,8 @@ class DefaultSessionWellknownRetrieverTest {
"rageshake_url": "a_rageshake_url",
"brand_color": "#FF0000",
"notification_sound": "a_notification_sound.flac",
"idp_app_scheme": "an_app_scheme"
"idp_app_scheme": "an_app_scheme",
"content_scanner_url": "https://content-scanner.example.com"
}"""
}
}
@@ -0,0 +1,158 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.sessionstorage.test.InMemoryCacheStore
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val A_DOMAIN = "matrix.example.com"
private const val A_CACHE_KEY = "https://$A_DOMAIN/.well-known/element/element.json"
// 1 day in millis
private const val CACHE_VALIDITY_MILLIS = 1 * 24 * 60 * 60 * 1000L
class DefaultWellKnownStoreTest {
private val jsonProvider = DefaultJsonProvider()
@Test
fun `get returns NotFound when cache is empty`() = runTest {
val sut = createDefaultWellKnownStore()
assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound)
}
@Test
fun `get returns Success when cache has fresh data`() = runTest {
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
val jsonString = jsonProvider().encodeToString(wellKnown)
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map()))
}
@Test
fun `get returns Outdated when cached data has expired`() = runTest {
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
val jsonString = jsonProvider().encodeToString(wellKnown)
// Store data at timestamp 0, but clock is past the validity window
val expiredTimestamp = 0L
val nowTimestamp = expiredTimestamp + CACHE_VALIDITY_MILLIS + 1
val clock = FakeSystemClock(epochMillisResult = nowTimestamp)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, expiredTimestamp))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Outdated(wellKnown.map()))
}
@Test
fun `get returns Error when cached data is invalid JSON`() = runTest {
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData("not valid json at all", A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore)
val result = sut.get(A_DOMAIN)
assertThat(result).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `update stores data and get returns Success`() = runTest {
val wellKnown = """
{
"registration_helper_url": "https://element.io",
"enforce_element_pro": true,
"rageshake_url": "https://example.org/rageshake",
"brand_color": "#FF0000",
"notification_sound": "ring.flac",
"idp_app_scheme": "io.element.app",
"custom_recovery_passphrase": {
"min_character_count": 8
}
}
""".trimIndent()
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore()
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val updateResult = sut.update(A_DOMAIN, wellKnown)
assertThat(updateResult.isSuccess).isTrue()
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(anElementWellKnown(
registrationHelperUrl = "https://element.io",
enforceElementPro = true,
rageshakeUrl = "https://example.org/rageshake",
brandColor = "#FF0000",
notificationSound = "ring.flac",
identityProviderAppScheme = "io.element.app",
customRecoveryPassphrase = CustomRecoveryPassphrase(8),
)))
}
@Test
fun `delete removes data and get returns NotFound`() = runTest {
val wellKnown = InternalElementWellKnown(rageshakeUrl = "https://rageshake.example.com")
val jsonString = jsonProvider().encodeToString(wellKnown)
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val deleteResult = sut.delete(A_DOMAIN)
assertThat(deleteResult.isSuccess).isTrue()
assertThat(sut.get(A_DOMAIN)).isEqualTo(WellknownRetrieverResult.NotFound)
}
@Test
fun `get returns data at the exact cache validity boundary as Success`() = runTest {
val wellKnown = InternalElementWellKnown(notificationSound = "ping.flac")
val jsonString = jsonProvider().encodeToString(wellKnown)
// Exactly at the boundary: epochMillis == updatedAt + CACHE_VALIDITY_MILLIS → not expired
val storedAt = 1000L
val nowAtBoundary = storedAt + CACHE_VALIDITY_MILLIS
val clock = FakeSystemClock(epochMillisResult = nowAtBoundary)
val cacheStore = InMemoryCacheStore(
initialData = mapOf(A_CACHE_KEY to CacheData(jsonString, storedAt))
)
val sut = createDefaultWellKnownStore(cacheStore = cacheStore, systemClock = clock)
val result = sut.get(A_DOMAIN)
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map()))
}
private fun createDefaultWellKnownStore(
cacheStore: InMemoryCacheStore = InMemoryCacheStore(),
systemClock: FakeSystemClock = FakeSystemClock(),
) = DefaultWellKnownStore(
cacheStore = cacheStore,
json = jsonProvider,
systemClock = systemClock,
)
}
@@ -0,0 +1,291 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.wellknown.test.FakeElementWellknownStore
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.IOException
import org.junit.Test
import java.net.PortUnreachableException
class DefaultWellknownRetrieverTest {
@Test
fun `get empty element wellknown`() = runTest {
val mockCall = mockCall(defaultResponse(body = "{}"))
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall },
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = null,
enforceElementPro = null,
rageshakeUrl = null,
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
)
)
)
verify(exactly = 1) { mockCall.enqueue(any()) }
}
@Test
fun `get element wellknown with full content`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse()) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
)
)
)
}
@Test
fun `get element wellknown with unknown key`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(defaultResponse(body = """{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url",
// Note the trailing comma, and the comment!
"other": true,
}""".trimIndent()))
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(defaultResponse(body = """{
"custom_recovery_passphrase": {
"min_character_count": 8
}
}""".trimIndent()))
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8)
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(defaultResponse(body = """{
"custom_recovery_passphrase": {}
}""".trimIndent()))
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with zero min character count floors to 1`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(
defaultResponse(
body = """{
"custom_recovery_passphrase": {
"min_character_count": 0
}
}""".trimIndent()
)
)
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with negative min character count floors to 1`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = {
mockCall(
defaultResponse(
body = """{
"custom_recovery_passphrase": {
"min_character_count": -5
}
}""".trimIndent()
)
)
}
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown json error`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse(status = 500)) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown network error`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(error = PortUnreachableException()) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown 404 http error counts as not found`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse(status = 404)) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
}
@Test
fun `get element wellknown hitting cache containing invalid json`() = runTest {
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
)
)
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse(body = "invalid json")) },
cacheStore = cacheStore,
jsonProvider = JsonProvider { error("Failed to parse JSON") }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isInstanceOf(WellknownRetrieverResult.Error::class.java)
// Ensure that the cache is deleted after the failure to parse it
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
}
private fun defaultResponse(
body: String = WELLKNOWN_CONTENT,
status: Int = 200,
) = Response.Builder()
.code(status)
.message("OK")
.protocol(okhttp3.Protocol.HTTP_1_1)
.request(Request.Builder().url(WELLKNOWN_URL).build())
.body(body.toByteArray().toResponseBody())
.build()
private fun createDefaultWellknownRetriever(
callFactory: Call.Factory = Call.Factory { _: Request -> mockCall(defaultResponse()) },
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
jsonProvider: JsonProvider = DefaultJsonProvider(),
) = DefaultWellknownRetriever(
retrofitFactory = RetrofitFactory(
callFactory = { callFactory },
json = { jsonProvider }
),
jsonProvider = jsonProvider,
elementWellknownStore = cacheStore,
)
companion object {
private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json"
private const val WELLKNOWN_CONTENT = """{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url",
"brand_color": "#FF0000",
"notification_sound": "a_notification_sound.flac",
"idp_app_scheme": "an_app_scheme",
"content_scanner_url": "https://content-scanner.example.com"
}"""
}
}
private fun mockCall(
response: Response? = null,
error: IOException? = null,
): Call = mockk<Call> {
if (error != null) {
every { enqueue(any()) } answers {
val callback = firstArg<okhttp3.Callback>()
callback.onFailure(this@mockk, error)
}
} else if (response != null) {
every { enqueue(any()) } answers {
val callback = firstArg<okhttp3.Callback>()
callback.onResponse(this@mockk, response)
}
} else {
error("Either response or error must be provided")
}
}
@@ -15,6 +15,9 @@ android {
}
dependencies {
implementation(libs.serialization.json)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.wellknown.api)
implementation(projects.libraries.wellknown.impl)
implementation(projects.tests.testutils)
}
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.wellknown.test
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.libraries.wellknown.impl.InternalElementWellKnown
import io.element.android.libraries.wellknown.impl.map
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.JsonDecodingException
import java.net.URL
class FakeElementWellknownStore(
initialData: Map<String, WellknownRetrieverResult<ElementWellKnown>> = emptyMap(),
private val jsonProvider: JsonProvider = DefaultJsonProvider(),
) : ElementWellknownStore {
private val cachedData = mutableMapOf<String, WellknownRetrieverResult<ElementWellKnown>>()
init {
initialData.forEach { (url, result) ->
cachedData[URL(url).host] = result
}
}
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
return cachedData[domain] ?: WellknownRetrieverResult.NotFound
}
@OptIn(ExperimentalSerializationApi::class)
override suspend fun update(domain: String, wellknown: String): Result<Unit> {
val json = jsonProvider()
val mapped = try {
json.decodeFromString<InternalElementWellKnown>(wellknown).map()
} catch (e: JsonDecodingException) {
return Result.failure(e)
}
cachedData[domain] = WellknownRetrieverResult.Success(mapped)
return Result.success(Unit)
}
override suspend fun delete(domain: String): Result<Unit> {
cachedData.remove(domain)
return Result.success(Unit)
}
}
@@ -19,6 +19,7 @@ fun anElementWellKnown(
notificationSound: String? = null,
identityProviderAppScheme: String? = null,
customRecoveryPassphrase: CustomRecoveryPassphrase? = null,
contentScannerUrl: String? = null,
) = ElementWellKnown(
registrationHelperUrl = registrationHelperUrl,
enforceElementPro = enforceElementPro,
@@ -27,6 +28,7 @@ fun anElementWellKnown(
notificationSound = notificationSound,
identityProviderAppScheme = identityProviderAppScheme,
customRecoveryPassphrase = customRecoveryPassphrase,
contentScannerUrl = contentScannerUrl,
)
fun aCustomRecoveryPassphrase(