mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Merge branch 'develop' into feature/fga/local_network_permission
# Conflicts: # libraries/androidutils/src/main/AndroidManifest.xml
This commit is contained in:
+54
@@ -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?,
|
||||
|
||||
+7
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
+7
@@ -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?>
|
||||
|
||||
+3
@@ -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
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.scanner
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
|
||||
/**
|
||||
* Component used to manually scan media content for potential security risks.
|
||||
*
|
||||
* While the Matrix SDK automatically scans media content we load if a content scanner instance is provided, this interface allows for scanning
|
||||
* some media sources in advance without actually loading their contents, which can be useful for pre-fetching scenarios.
|
||||
*/
|
||||
interface ContentScanner {
|
||||
/**
|
||||
* Manually scans the given [mediaSource] for potential security risks.
|
||||
*
|
||||
* @param mediaSource The media source to scan.
|
||||
* @return A [Result] containing a [Boolean] indicating whether the content is safe (true) or potentially unsafe (false).
|
||||
* If the scan fails, the [Result] will contain an exception.
|
||||
*/
|
||||
suspend fun scan(mediaSource: MediaSource): Result<Boolean>
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.scanner
|
||||
|
||||
/**
|
||||
* Provides the URL of the content scanner service for a given homeserver, if any is set up.
|
||||
*/
|
||||
fun interface ContentScannerUrlProvider {
|
||||
/**
|
||||
* Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up.
|
||||
*/
|
||||
suspend fun getContentScannerUrl(homeserver: String): Result<String?>
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+17
@@ -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
|
||||
+35
-1
@@ -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()
|
||||
|
||||
+23
@@ -17,11 +17,14 @@ import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.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'")
|
||||
}
|
||||
|
||||
+6
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.notificationsettings.Notification
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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(
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.scanner
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
|
||||
/**
|
||||
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
|
||||
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.scanner
|
||||
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner
|
||||
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
|
||||
|
||||
class RustContentScanner(
|
||||
private val client: Client,
|
||||
private val rustScanner: RustScanner,
|
||||
) : ContentScanner {
|
||||
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
|
||||
return runCatchingExceptions {
|
||||
rustScanner.scan(client, mediaSource.toRustMediaSource()).clean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MediaSource.toRustMediaSource(): RustMediaSource {
|
||||
val json = this.json
|
||||
return try {
|
||||
if (json != null) {
|
||||
RustMediaSource.fromJson(json)
|
||||
} else {
|
||||
RustMediaSource.fromUrl(safeUrl)
|
||||
}
|
||||
} catch (e: LinkageError) {
|
||||
// Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
|
||||
val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
|
||||
if (isTest) RustMediaSource(NoHandle) else throw e
|
||||
}
|
||||
}
|
||||
+3
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import 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,
|
||||
)
|
||||
|
||||
+2
@@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.test.A_DEVICE_ID
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_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(),
|
||||
)
|
||||
}
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.scanner
|
||||
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ContentScanner
|
||||
import org.matrix.rustcomponents.sdk.MediaSource
|
||||
import org.matrix.rustcomponents.sdk.NoHandle
|
||||
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
|
||||
|
||||
class FakeFfiContentScanner(
|
||||
private val scan: suspend (client: Client, mediaSource: MediaSource) -> MediaScanResponse = { _, _ ->
|
||||
MediaScanResponse(clean = true, info = "Just peachy")
|
||||
},
|
||||
) : ContentScanner(NoHandle) {
|
||||
override suspend fun scan(client: Client, mediaSource: MediaSource): MediaScanResponse {
|
||||
return scan.invoke(client, mediaSource)
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.scanner
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
|
||||
import io.element.android.libraries.matrix.test.media.aMediaSource
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import uniffi.matrix_sdk_contentscanner.MediaScanResponse
|
||||
|
||||
class RustContentScannerTest {
|
||||
@Test
|
||||
fun `test successful valid scan`() = runTest {
|
||||
val scanner = RustContentScanner(
|
||||
client = FakeFfiClient(),
|
||||
rustScanner = FakeFfiContentScanner(),
|
||||
)
|
||||
|
||||
scanner.scan(aMediaSource()).run {
|
||||
assertThat(isSuccess).isTrue()
|
||||
assertThat(getOrNull()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test successful invalid scan`() = runTest {
|
||||
val scanner = RustContentScanner(
|
||||
client = FakeFfiClient(),
|
||||
rustScanner = FakeFfiContentScanner(scan = { _, _ -> MediaScanResponse(clean = false, info = "Not clean") })
|
||||
)
|
||||
|
||||
scanner.scan(aMediaSource()).run {
|
||||
assertThat(isSuccess).isTrue()
|
||||
assertThat(getOrNull()).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test failed scan`() = runTest {
|
||||
val scanner = RustContentScanner(
|
||||
client = FakeFfiClient(),
|
||||
rustScanner = FakeFfiContentScanner(scan = { _, _ -> throw IllegalStateException("BOOM") })
|
||||
)
|
||||
|
||||
scanner.scan(aMediaSource()).run {
|
||||
assertThat(isFailure).isTrue()
|
||||
assertThat(getOrNull()).isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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?> {
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.test.scanner
|
||||
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
|
||||
class FakeContentScanner(
|
||||
private val scan: suspend (mediaSource: MediaSource) -> Result<Boolean> = { Result.success(true) },
|
||||
) : ContentScanner {
|
||||
override suspend fun scan(mediaSource: MediaSource): Result<Boolean> {
|
||||
return scan.invoke(mediaSource)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+15
-3
@@ -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 {
|
||||
|
||||
+3
@@ -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())
|
||||
|
||||
+7
-2
@@ -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
|
||||
}
|
||||
|
||||
+25
-5
@@ -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 {
|
||||
|
||||
+67
@@ -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.
+48
@@ -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(
|
||||
|
||||
+11
@@ -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>
|
||||
+4
@@ -16,6 +16,7 @@ import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.network.interceptors.DynamicHttpLoggingInterceptor
|
||||
import io.element.android.libraries.network.interceptors.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 {
|
||||
|
||||
+3
-3
@@ -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()
|
||||
}
|
||||
|
||||
+2
-4
@@ -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>
|
||||
+1
@@ -16,4 +16,5 @@ data class ElementWellKnown(
|
||||
val notificationSound: String?,
|
||||
val identityProviderAppScheme: String?,
|
||||
val customRecoveryPassphrase: CustomRecoveryPassphrase?,
|
||||
val contentScannerUrl: String?,
|
||||
)
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.wellknown.api
|
||||
|
||||
interface ElementWellknownStore {
|
||||
suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown>
|
||||
suspend fun update(domain: String, wellknown: String): Result<Unit>
|
||||
suspend fun delete(domain: String): Result<Unit>
|
||||
}
|
||||
+6
@@ -14,6 +14,11 @@ sealed interface WellknownRetrieverResult<out T> {
|
||||
*/
|
||||
data class Success<out T>(val data: T) : WellknownRetrieverResult<T>
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.wellknown.test
|
||||
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.libraries.wellknown.impl.InternalElementWellKnown
|
||||
import io.element.android.libraries.wellknown.impl.map
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.JsonDecodingException
|
||||
import java.net.URL
|
||||
|
||||
class FakeElementWellknownStore(
|
||||
initialData: Map<String, WellknownRetrieverResult<ElementWellKnown>> = emptyMap(),
|
||||
private val jsonProvider: JsonProvider = DefaultJsonProvider(),
|
||||
) : ElementWellknownStore {
|
||||
private val cachedData = mutableMapOf<String, WellknownRetrieverResult<ElementWellKnown>>()
|
||||
|
||||
init {
|
||||
initialData.forEach { (url, result) ->
|
||||
cachedData[URL(url).host] = result
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
return cachedData[domain] ?: WellknownRetrieverResult.NotFound
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
override suspend fun update(domain: String, wellknown: String): Result<Unit> {
|
||||
val json = jsonProvider()
|
||||
val mapped = try {
|
||||
json.decodeFromString<InternalElementWellKnown>(wellknown).map()
|
||||
} catch (e: JsonDecodingException) {
|
||||
return Result.failure(e)
|
||||
}
|
||||
cachedData[domain] = WellknownRetrieverResult.Success(mapped)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun delete(domain: String): Result<Unit> {
|
||||
cachedData.remove(domain)
|
||||
return Result.success(Unit)
|
||||
}
|
||||
}
|
||||
+2
@@ -19,6 +19,7 @@ fun anElementWellKnown(
|
||||
notificationSound: String? = null,
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user