Merge branch 'develop' into fix/5794-account-data-api

This commit is contained in:
Benoit Marty
2026-08-11 14:03:39 +02:00
committed by GitHub
650 changed files with 4781 additions and 3197 deletions
@@ -13,3 +13,5 @@ import java.nio.charset.Charset
fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name())
fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name())
fun String.takeIfNotBlank(): String? = ifBlank { null }
@@ -0,0 +1,114 @@
/*
* 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.designsystem.components.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.element.android.libraries.architecture.AsyncAction
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeMark
import kotlin.time.TimeSource
private val DefaultShowDelay = 300.milliseconds
private val DefaultMinDisplayDuration = 500.milliseconds
private val DefaultFailureDuration = 500.milliseconds
/**
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
*
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
* prevents the indicator from flashing for operations that complete almost instantly.
*
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
* never debounced) and auto-dismisses after [failureDuration].
*
* @param asyncAction the action whose progress drives the indicator.
* @param loading the composable to display while loading.
* @param failure the composable to display on failure.
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
* @param state the [AsyncIndicatorState] backing the host.
* @param showDelay how long the loading state must persist before the indicator is shown.
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
*/
@Composable
fun AsyncActionIndicator(
asyncAction: AsyncAction<*>,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
modifier: Modifier = Modifier,
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
showDelay: Duration = DefaultShowDelay,
minDisplayDuration: Duration = DefaultMinDisplayDuration,
failureDuration: Duration = DefaultFailureDuration,
) {
AsyncIndicatorHost(modifier = modifier, state = state)
AsyncActionIndicatorEffect(
asyncAction = asyncAction,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplayDuration,
failureDuration = failureDuration,
loading = loading,
failure = failure,
)
}
@Composable
internal fun AsyncActionIndicatorEffect(
asyncAction: AsyncAction<*>,
state: AsyncIndicatorState,
showDelay: Duration,
minDisplayDuration: Duration,
failureDuration: Duration,
loading: @Composable () -> Unit,
failure: @Composable (Throwable) -> Unit,
timeSource: TimeSource = TimeSource.Monotonic,
) {
// Referenced inside the restarting LaunchedEffect below, so keep them up to date.
val currentLoading by rememberUpdatedState(loading)
val currentFailure by rememberUpdatedState(failure)
// Mark set when the loader actually becomes visible, null while it is hidden.
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
LaunchedEffect(asyncAction) {
when (asyncAction) {
is AsyncAction.Loading -> {
// If the operation completes before this delay elapses, the effect is cancelled
// and the loader is never shown.
delay(showDelay)
state.enqueue(composable = currentLoading)
loaderShownAt = timeSource.markNow()
}
is AsyncAction.Failure -> {
// Always surface failures immediately, even if the loader was never shown.
val error = asyncAction.error
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) })
loaderShownAt = null
}
is AsyncAction.Success -> {
// Hold the loader for its minimum duration before clearing it.
loaderShownAt?.let { shownAt ->
val remaining = minDisplayDuration - shownAt.elapsedNow()
if (remaining > Duration.ZERO) delay(remaining)
}
state.clear()
loaderShownAt = null
}
else -> Unit
}
}
}
@@ -0,0 +1,123 @@
/*
* 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.designsystem.component.async
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TestTimeSource
import kotlin.time.TimeSource
@OptIn(ExperimentalCoroutinesApi::class)
class AsyncActionIndicatorTest {
private val showDelay = 300.milliseconds
private val minDisplay = 500.milliseconds
@Test
fun `loader is not shown before the show delay elapses`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
// Once the show delay has fully elapsed, the loader is shown.
advanceTimeBy(300.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader is never shown when the operation completes before the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Loading
advanceTimeBy(100.milliseconds)
runCurrent()
action.value = AsyncAction.Success(Unit)
advanceTimeBy(1000.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
indicatorFlow(action, state).test {
action.value = AsyncAction.Failure(Exception("failed"))
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `loader stays visible for the minimum duration once shown`() = runTest {
val state = AsyncIndicatorState()
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
val timeSource = TestTimeSource()
indicatorFlow(action, state, timeSource).test {
// Show the loader.
action.value = AsyncAction.Loading
advanceTimeBy(400.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// 200ms of display has already elapsed when the operation succeeds.
timeSource += 200.milliseconds
action.value = AsyncAction.Success(Unit)
runCurrent()
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isTrue()
// Past the minimum display duration -> cleared.
advanceTimeBy(200.milliseconds)
runCurrent()
assertThat(state.currentAnimationState.targetState).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
private fun indicatorFlow(
action: State<AsyncAction<*>>,
state: AsyncIndicatorState,
timeSource: TimeSource = TimeSource.Monotonic,
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
AsyncActionIndicatorEffect(
asyncAction = action.value,
state = state,
showDelay = showDelay,
minDisplayDuration = minDisplay,
failureDuration = minDisplay,
loading = {},
failure = {},
timeSource = timeSource,
)
state.currentAnimationState.targetState
}
}
+1 -1
View File
@@ -36,5 +36,5 @@ dependencies {
implementation(libs.matrix.emojibase.bindings)
testImplementation(projects.libraries.matrix.test)
testCommonDependencies(libs)
testCommonDependencies(libs, includeTestComposeView = true)
}
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
queryState = state.searchQuery,
resultState = state.searchResults,
active = state.isSearchActive,
@@ -0,0 +1,72 @@
/*
* 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.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.emoji.impl.picker
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEqualTo
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.hasScrollToIndexAction
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import org.junit.Test
import org.robolectric.annotation.Config
private val PICKER_HEIGHT = 600.dp
class EmojiPickerViewTest : RobolectricTest() {
@Config(qualifiers = "w400dp-h800dp")
@Test
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
setEmojiPickerView()
onNode(hasScrollToIndexAction())
.getBoundsInRoot()
.bottom
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
setContent {
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
EmojiPickerView(
state = aDefaultEmojiPickerState(
isSearchActive = true,
searchQuery = "smile",
searchResults = SearchBarResultState.Results(
persistentListOf(
Emoji(
hexcode = "0x00",
label = "grinning face",
tags = persistentListOf("grinning"),
shortcodes = persistentListOf("smile"),
unicode = "😀",
skins = null,
),
)
),
),
onSelectEmoji = {},
selectedEmojis = persistentSetOf(),
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -54,7 +54,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import java.util.Optional
interface MatrixClient {
interface MatrixClient : UrlContentFetcher {
val sessionId: SessionId
val deviceId: DeviceId
@@ -99,6 +99,12 @@ interface MatrixClient {
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
suspend fun clearUserStatus(): Result<Unit>
/** Whether the homeserver advertises support for user status (MSC4426). */
suspend fun isUserStatusSupported(): Result<Boolean>
/** Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. */
fun enableAutomaticCallStatus(enabled: Boolean)
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?>
@@ -167,11 +173,6 @@ interface MatrixClient {
*/
fun userIdServerName(): String
/**
* Execute generic GET requests through the SDKs internal HTTP client.
*/
suspend fun getUrl(url: String): Result<ByteArray>
/**
* Get a room preview for a given room ID or alias. This is especially useful for rooms that the user is not a member of, or hasn't joined yet.
*/
@@ -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
import java.lang.AutoCloseable
/**
* A Matrix client in an 'unauthenticated' state that can still be used to resolve URLs.
*
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
* with it, such as temporary files.
*/
interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable
@@ -0,0 +1,15 @@
/*
* 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
/**
* Factory to create a temporary [TemporaryMatrixClient] for a given home server URL.
*/
interface TemporaryMatrixClientFactory {
suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient>
}
@@ -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
/**
* An interface to fetch content from a URL using a simple GET request.
*/
fun interface UrlContentFetcher {
/**
* Execute generic GET requests through the SDKs internal HTTP client.
*/
suspend fun getUrl(url: String): Result<ByteArray>
}
@@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.BuildConfig
object OAuthConfig {
// This *can* be null if the build time config field is not set
@Suppress("UNNECESSARY_SAFE_CALL")
val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let {
// Concatenate the base URI and the path, ensuring there is exactly one '/' between them
"${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}"
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
displayedStatus = displayedStatus,
)
@@ -7,12 +7,19 @@
package io.element.android.libraries.matrix.api.scanner
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
/**
* 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.
* Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up.
*/
suspend fun getContentScannerUrl(homeserver: String): Result<String?>
suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?>
fun interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider
}
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
@Immutable
sealed interface ProfileDetails {
@@ -60,3 +61,20 @@ fun ProfileDetails.getAvatarUrl(): String? {
else -> null
}
}
fun ProfileDetails.toMatrixUser(userId: UserId): MatrixUser {
return when (this) {
is ProfileDetails.Ready -> MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
displayedStatus = displayedStatus,
)
else -> MatrixUser(
userId = userId,
displayName = null,
avatarUrl = null,
displayedStatus = null,
)
}
}
@@ -13,6 +13,9 @@ import kotlinx.parcelize.Parcelize
@Immutable
sealed interface DisplayedStatus : Parcelable {
/** Useful for adding extension methods. */
companion object
/** Status set manually by the user via m.status. */
@Parcelize
data class UserSet(val status: UserStatus) : DisplayedStatus
@@ -21,6 +24,6 @@ sealed interface DisplayedStatus : Parcelable {
@Parcelize
data class InCall(
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
val callJoinedTs: Long,
val callJoinedTs: Long?,
) : DisplayedStatus
}
@@ -49,7 +49,6 @@ 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
@@ -58,7 +57,6 @@ import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
@@ -85,7 +83,9 @@ import io.element.android.libraries.matrix.impl.search.RustMessageSearchService
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.user.into
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@@ -126,9 +126,11 @@ import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.UserProfile
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
@@ -271,8 +273,6 @@ class RustMatrixClient(
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
@Volatile private var localUserStatus: UserStatus? = null
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
@@ -283,6 +283,14 @@ class RustMatrixClient(
override val userProfile: StateFlow<MatrixUser> = _userProfile
private var ownProfileTaskHandle: TaskHandle? = null
private val ownProfileListener = object : ProfileListener {
override fun onUpdate(profile: UserProfile) {
_userProfile.tryEmit(profile.map())
}
}
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
// Fetch the initial value manually, the SDK won't return it automatically
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
@@ -303,8 +311,21 @@ class RustMatrixClient(
sessionCoroutineScope.launch {
// Start notification settings
notificationSettingsService.start()
// Setup user profile logic
setupUserProfile()
}
// Update the user profile in the session store if needed
// Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
}
private suspend fun setupUserProfile() {
val supported = isUserStatusSupported().getOrDefault(false)
if (supported) {
// No need to seed the data here, it's already stored by the sdk.
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
} else {
// Seed from the session store so cold-start UI has something to render before the network fetch.
sessionStore.getSession(sessionId.value)?.let { sessionData ->
_userProfile.emit(
MatrixUser(
@@ -314,12 +335,8 @@ class RustMatrixClient(
)
)
}
// Force a refresh of the profile
getUserProfile()
}
// Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
}
override fun userIdServerName(): String {
@@ -458,13 +475,8 @@ class RustMatrixClient(
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { matrixUser ->
_userProfile.emit(
matrixUser.copy(
rawStatus = localUserStatus,
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
)
)
// Also update our session storage
_userProfile.emit(matrixUser)
sessionStore.updateUserProfile(
sessionId = sessionId.value,
displayName = matrixUser.displayName,
@@ -495,25 +507,31 @@ class RustMatrixClient(
}
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)
runCatchingExceptions {
innerClient.setUserStatus(status.into())
}.onSuccess {
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
if (ownProfileTaskHandle == null) getUserProfile()
}
}
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = null
_userProfile.emit(
_userProfile.value.copy(
rawStatus = null,
displayedStatus = null,
)
)
Result.success(Unit)
runCatchingExceptions {
innerClient.clearUserStatus()
}.onSuccess {
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
if (ownProfileTaskHandle == null) getUserProfile()
}
}
override suspend fun isUserStatusSupported(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isUserStatusSupported()
}
}
override fun enableAutomaticCallStatus(enabled: Boolean) {
innerClient.enableAutomaticCallStatus(enabled)
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
@@ -621,6 +639,7 @@ class RustMatrixClient(
sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy()
ownProfileTaskHandle?.cancelAndDestroy()
sessionVerificationService.destroy()
sessionDelegate.clearCurrentClient()
@@ -17,7 +17,7 @@ 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.core.SessionId
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
@@ -70,7 +70,7 @@ class RustMatrixClientFactory(
private val clientBuilderProvider: ClientBuilderProvider,
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
private val workManagerScheduler: WorkManagerScheduler,
private val contentScannerUrlProvider: ContentScannerUrlProvider,
private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory,
) {
private val sessionDelegate = RustClientSessionDelegate(
sessionStore = sessionStore,
@@ -135,26 +135,25 @@ 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 contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null))
val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId()))
.getOrNull()
?.let { contentScannerUrl ->
val contentScanner = ContentScanner(contentScannerUrl)
client.setContentScanner(contentScanner)
RustContentScanner(
client = client,
rustScanner = contentScanner,
)
}
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
.withProfilesExtension()
.finish()
return RustMatrixClient(
@@ -0,0 +1,36 @@
/*
* 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
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.exception.mapClientException
import org.matrix.rustcomponents.sdk.Client
class RustTemporaryMatrixClient(
private val client: Client,
private val paths: SessionPaths?,
) : TemporaryMatrixClient {
override suspend fun getUrl(url: String): Result<ByteArray> = runCatchingExceptions {
client.getUrl(url)
}.mapFailure { it.mapClientException() }
override fun close() {
client.close()
paths?.let {
if (it.fileDirectory.exists()) {
it.fileDirectory.deleteRecursively()
}
if (it.cacheDirectory.exists()) {
it.cacheDirectory.deleteRecursively()
}
}
}
}
@@ -0,0 +1,36 @@
/*
* 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
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
@ContributesBinding(AppScope::class)
class RustTemporaryMatrixClientFactory(
private val sessionPathsFactory: SessionPathsFactory,
private val rustMatrixClientFactory: RustMatrixClientFactory,
) : TemporaryMatrixClientFactory {
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> {
return runCatchingExceptions {
val sessionPaths = sessionPathsFactory.create()
val client = rustMatrixClientFactory.getBaseClientBuilder(
sessionPaths = sessionPaths,
clientSecret = null,
slidingSyncType = ClientBuilderSlidingSync.Native,
isMessageSearchAvailable = false,
)
.homeserverUrl(homeServerUrl)
.build()
RustTemporaryMatrixClient(client, sessionPaths)
}
}
}
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.RustTemporaryMatrixClient
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep
@@ -133,9 +134,10 @@ class RustMatrixAuthenticationService(
}
currentClient = client
client.homeserverLoginDetails().map()
}.onFailure {
clear()
clear(destroyClient = true)
}.mapFailure { failure ->
Timber.e(failure, "Failed to set homeserver to $homeserver")
failure.mapAuthenticationException()
@@ -168,7 +170,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { failure ->
@@ -248,7 +250,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}
@@ -271,11 +273,13 @@ class RustMatrixAuthenticationService(
deviceId = null,
additionalScopes = emptyList(),
)
val getUrlResolver = RustTemporaryMatrixClient(client, sessionPaths)
val url = oAuthAuthorizationData.loginUrl()
.let {
enterpriseService.tweakMasUrl(
url = it,
homeserver = client.server() ?: client.homeserver(),
urlContentFetcher = getUrlResolver,
)
}
pendingOAuthAuthorizationData = oAuthAuthorizationData
@@ -335,7 +339,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { failure ->
@@ -398,7 +402,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure {
@@ -464,8 +468,10 @@ class RustMatrixAuthenticationService(
.build()
}
private fun clear() {
currentClient?.close()
private fun clear(destroyClient: Boolean) {
if (destroyClient) {
currentClient?.close()
}
currentClient = null
}
@@ -15,6 +15,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -108,4 +109,9 @@ object SessionMatrixModule {
fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? {
return matrixClient.contentScanner
}
@Provides
fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher {
return matrixClient
}
}
@@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListServ
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@@ -16,12 +16,15 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
import io.element.android.libraries.matrix.impl.room.tombstone.map
import io.element.android.libraries.matrix.impl.user.from
import io.element.android.libraries.matrix.impl.user.into
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.Membership
@@ -102,7 +105,9 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
fun RoomHero.map(): MatrixUser = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl
avatarUrl = avatarUrl,
displayedStatus = DisplayedStatus.from(status, call),
rawStatus = status?.into(),
)
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
@@ -11,7 +11,9 @@ package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.room.powerlevels.into
import io.element.android.libraries.matrix.impl.user.from
import uniffi.matrix_sdk.RoomMemberRole
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
@@ -30,8 +32,7 @@ object RoomMemberMapper {
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason,
isServiceMember = roomMember.isServiceMember,
// TODO map this value when available
displayedStatus = null,
displayedStatus = DisplayedStatus.from(roomMember.status, roomMember.call),
)
}
@@ -1,20 +0,0 @@
/*
* 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)
}
@@ -21,7 +21,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetail
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.core.RustSendHandle
import io.element.android.libraries.matrix.impl.user.from
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -75,8 +77,7 @@ fun RustProfileDetails.map(): ProfileDetails {
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
// TODO map this value when available
displayedStatus = null,
displayedStatus = DisplayedStatus.from(status, call),
)
}
}
@@ -6,9 +6,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.mapper
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.UserProfile
@@ -16,4 +17,6 @@ fun UserProfile.map() = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl,
rawStatus = status?.into(),
displayedStatus = DisplayedStatus.from(status, call)
)
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.usersearch
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.impl.mapper.map
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.SearchUsersResults
@@ -0,0 +1,29 @@
/*
* 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.user
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import org.matrix.rustcomponents.sdk.UserCall
import org.matrix.rustcomponents.sdk.UserStatus as RustUserStatus
fun UserStatus.into(): RustUserStatus {
return RustUserStatus(emoji = emoji, text = text)
}
fun RustUserStatus.into(): UserStatus {
return UserStatus(emoji = emoji, text = text)
}
fun DisplayedStatus.Companion.from(userStatus: RustUserStatus?, userCall: UserCall?): DisplayedStatus? {
return when {
userStatus != null -> DisplayedStatus.UserSet(userStatus.into())
userCall != null -> DisplayedStatus.InCall(userCall.callJoinedTs?.toLong())
else -> null
}
}
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.user.map
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
@@ -52,7 +52,7 @@ fun TestScope.createRustMatrixClientFactory(
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) },
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -67,5 +67,5 @@ fun TestScope.createRustMatrixClientFactory(
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProvider = contentScannerUrlProvider,
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
)
@@ -32,9 +32,9 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
@@ -75,7 +75,8 @@ class RustMatrixClientTest {
@Test
fun `retrieving the UserProfile updates the database`() = runTest {
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> }
val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> profilePersisted.complete(Unit) }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
@@ -100,7 +101,7 @@ class RustMatrixClientTest {
),
sessionStore = sessionStore,
)
advanceUntilIdle()
profilePersisted.await()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
@@ -0,0 +1,84 @@
/*
* 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
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
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.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
class RustTemporaryMatrixClientFactoryTest {
@Test
fun `create returns a TemporaryMatrixClient`() = runTest {
val sut = createRustTemporaryMatrixClientFactory()
val result = sut.create("https://matrix.org")
assertThat(result.isSuccess).isTrue()
}
@Test
fun `create can fail gracefully`() = runTest {
val sut = createRustTemporaryMatrixClientFactory(
rustMatrixClientFactory = createRustMatrixClientFactory(
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = { throw IllegalStateException("Failed to create client builder") }
)
)
)
val result = sut.create("https://matrix.org")
assertThat(result.isFailure).isTrue()
}
private fun TestScope.createRustTemporaryMatrixClientFactory(
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
rustMatrixClientFactory: RustMatrixClientFactory = createRustMatrixClientFactory(),
): RustTemporaryMatrixClientFactory {
return RustTemporaryMatrixClientFactory(
sessionPathsFactory = sessionPathsFactory,
rustMatrixClientFactory = rustMatrixClientFactory,
)
}
private fun TestScope.createRustMatrixClientFactory(
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore,
userAgentProvider = SimpleUserAgentProvider(),
proxyProvider = FakeProxyProvider(),
clock = FakeSystemClock(),
analyticsService = FakeAnalyticsService(),
featureFlagService = FakeFeatureFlagService(),
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
)
}
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvide
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -50,6 +51,29 @@ class RustMatrixAuthenticationServiceTest {
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
}
@Test
fun `setHomeserver can fail gracefully and clean up the temporary client`() = runTest {
val closeResult = lambdaRecorder<Unit> {}
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = {
FakeFfiClientBuilder(
buildResult = {
FakeFfiClient(
homeserverLoginDetailsResult = {
throw IllegalStateException("Failed to get homeserver login details")
},
closeResult = closeResult,
)
},
)
},
),
)
assertThat(sut.setHomeserver("matrix.org").isFailure).isTrue()
closeResult.assertions().isCalledOnce()
}
private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
@@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.PusherIdentifiers
import org.matrix.rustcomponents.sdk.PusherKind
import org.matrix.rustcomponents.sdk.RoomDirectorySearch
@@ -57,6 +58,8 @@ class FakeFfiClient(
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val accountDataResult: (String) -> String? = { lambdaError() },
private val setAccountDataResult: (String, String) -> Unit = { _, _ -> lambdaError() },
private val isUserStatusSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
@@ -94,6 +97,13 @@ class FakeFfiClient(
return FakeFfiTaskHandle()
}
override suspend fun isUserStatusSupported(): Boolean = isUserStatusSupportedResult()
override fun subscribeToOwnProfile(listener: ProfileListener): TaskHandle {
subscribeToOwnProfileResult(listener)
return FakeFfiTaskHandle()
}
override suspend fun getProfile(userId: String): UserProfile {
return getProfileResult(userId)
}
@@ -15,5 +15,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) {
override fun withOfflineMode(): SyncServiceBuilder = this
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
override fun withProfilesExtension(): SyncServiceBuilder = this
override suspend fun finish(): SyncService = FakeFfiSyncService()
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.mapper
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.test.A_USER_ID
import org.junit.Test
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
@@ -132,6 +132,7 @@ class FakeMatrixClient(
private val getMapStyleUrlResult: () -> Result<String?> = { lambdaError() },
private val getDatabaseSizesLambda: () -> Result<SdkStoreSizes> = { lambdaError() },
private val resetWellKnownConfigLambda: () -> Result<Unit> = { lambdaError() },
private val enableAutomaticCallStatusLambda: (Boolean) -> Unit = { },
override val contentScanner: ContentScanner? = null,
) : MatrixClient {
var setDisplayNameCalled: Boolean = false
@@ -146,6 +147,7 @@ class FakeMatrixClient(
private set
var setUserStatusResult: Result<Unit> = Result.success(Unit)
var clearUserStatusResult: Result<Unit> = Result.success(Unit)
var isUserStatusSupportedResult: Result<Boolean> = Result.success(true)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile
@@ -290,6 +292,10 @@ class FakeMatrixClient(
return clearUserStatusResult
}
override suspend fun isUserStatusSupported(): Result<Boolean> = isUserStatusSupportedResult
override fun enableAutomaticCallStatus(enabled: Boolean) = enableAutomaticCallStatusLambda(enabled)
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
@@ -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.test
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
class FakeTemporaryMatrixClient(
private val getUrlResult: (String) -> Result<ByteArray> = { lambdaError() },
private val closeLambda: () -> Unit = {},
) : TemporaryMatrixClient {
override suspend fun getUrl(url: String): Result<ByteArray> = getUrlResult(url)
override fun close() = closeLambda()
}
@@ -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.test
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
class FakeTemporaryMatrixClientFactory(
private val createResult: (String) -> Result<TemporaryMatrixClient> = { Result.success(FakeTemporaryMatrixClient()) },
) : TemporaryMatrixClientFactory {
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> = createResult(homeServerUrl)
}
@@ -27,7 +27,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
@Composable
internal fun ContentErrorView(
title: String,
message: String,
message: String?,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
@@ -51,12 +51,14 @@ internal fun ContentErrorView(
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
Text(
text = message,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
onTextLayout = onTextLayout,
)
if (message != null) {
Text(
text = message,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
onTextLayout = onTextLayout,
)
}
}
}
}
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -45,6 +44,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentErrorView
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
@@ -77,7 +77,7 @@ fun InReplyToView(
else -> ReplyToLoadingContent(modifier = modifier)
}
is InReplyToDetails.Error ->
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
ReplyToErrorContent(modifier = modifier)
is InReplyToDetails.Loading ->
ReplyToLoadingContent(modifier = modifier)
}
@@ -150,24 +150,15 @@ private fun ReplyToLoadingContent(
@Composable
private fun ReplyToErrorContent(
data: InReplyToDetails.Error,
maxLines: Int,
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
Text(
text = data.message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textCriticalPrimary,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
)
}
ContentErrorView(
title = stringResource(CommonStrings.error_message_not_found),
message = null,
modifier = modifier,
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
onTextLayout = null,
)
}
@Composable
@@ -3,5 +3,7 @@
<string name="screen_bottom_sheet_create_dm_confirmation_button_title">"Einladung senden"</string>
<string name="screen_bottom_sheet_create_dm_message">"Möchtest du einen Chat mit %1$s starten?"</string>
<string name="screen_bottom_sheet_create_dm_title">"Einladung senden?"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"Du hast derzeit keine Chats mit dieser Person. Bestätige die Einladung, bevor du fortfährst."</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"Mit diesem neuen Kontakt einen Chat starten?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) hat dich eingeladen"</string>
</resources>
@@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"檔案名稱"</string>
<string name="screen_media_details_no_more_files_to_show">"無可顯示的檔案"</string>
<string name="screen_media_details_no_more_media_to_show">"無可顯示的媒體"</string>
<string name="screen_media_details_title">"檔案資訊"</string>
<string name="screen_media_details_uploaded_by">"上傳者:"</string>
<string name="screen_media_details_uploaded_on">"上傳於"</string>
</resources>
@@ -30,6 +30,7 @@
</plurals>
<string name="notification_invite_body">"Kutse osalema vestluses"</string>
<string name="notification_invite_body_with_sender">"%1$s saatus sulle vestluskutse"</string>
<string name="notification_live_location_started_body">"Hakkas jagama oma asukohta reaalajas"</string>
<string name="notification_mentioned_you_body">"Mainis sind: %1$s"</string>
<string name="notification_new_messages">"Uued sõnumid"</string>
<plurals name="notification_new_messages_for_room">
@@ -21,6 +21,10 @@
<item quantity="one">"Imate%d novu poruku."</item>
<item quantity="other">"Imate %d novi poruka."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="one">"Dijeljeni privitak %1$d"</item>
<item quantity="other">"Dijeljeni prilozi %1$d"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Dolazni poziv"</string>
<string name="notification_incoming_call">"📹 Dolazni poziv"</string>
<string name="notification_inline_reply_failed">"** Slanje nije uspjelo – otvorite sobu"</string>
@@ -33,6 +37,7 @@
</plurals>
<string name="notification_invite_body">"Pozvao/la te na razgovor"</string>
<string name="notification_invite_body_with_sender">"%1$s pozvao/la te na razgovor"</string>
<string name="notification_live_location_started_body">"Počeli su dijeliti svoju lokaciju uživo"</string>
<string name="notification_mentioned_you_body">"Korisnik vas je spomenuo: %1$s"</string>
<string name="notification_new_messages">"Nove poruke"</string>
<plurals name="notification_new_messages_for_room">
@@ -30,6 +30,7 @@
</plurals>
<string name="notification_invite_body">"Meghívta, hogy csevegjen"</string>
<string name="notification_invite_body_with_sender">"%1$s meghívta egy csevegésre"</string>
<string name="notification_live_location_started_body">"Elkezdték megosztani az élő helyzetüket"</string>
<string name="notification_mentioned_you_body">"Megemlítette Önt: %1$s"</string>
<string name="notification_new_messages">"Új üzenetek"</string>
<plurals name="notification_new_messages_for_room">
@@ -22,6 +22,11 @@
<item quantity="few">"Masz %d nowe wiadomości."</item>
<item quantity="many">"Masz %d nowych wiadomości."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="one">"Udostępnił %1$d załącznik"</item>
<item quantity="few">"Udostępnił %1$d załączniki"</item>
<item quantity="many">"Udostępnił %1$d załączników"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Połączenie przychodzące"</string>
<string name="notification_incoming_call">"📹 Połączenie przychodzące"</string>
<string name="notification_inline_reply_failed">"** Nie udało się wysłać - proszę otworzyć pokój"</string>
@@ -34,6 +39,7 @@
</plurals>
<string name="notification_invite_body">"Zaprosił Cię do czatu"</string>
<string name="notification_invite_body_with_sender">"%1$s zaprosił Cię do czatu"</string>
<string name="notification_live_location_started_body">"Rozpoczął udostępnianie swojej lokalizacji"</string>
<string name="notification_mentioned_you_body">"Wspomniano o Tobie: %1$s"</string>
<string name="notification_new_messages">"Nowe wiadomości"</string>
<plurals name="notification_new_messages_for_room">
@@ -20,6 +20,11 @@
<item quantity="few">"Aveți %d mesaje noi."</item>
<item quantity="other">"Aveți %d mesaje noi."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="one">"%1$d atașament partajat"</item>
<item quantity="few">"%1$d atașamente partajate"</item>
<item quantity="other">"%1$d atașamente partajate"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Apel primit"</string>
<string name="notification_incoming_call">"Apel primit"</string>
<string name="notification_inline_reply_failed">"** Trimiterea eșuată - vă rugăm să deschideți camera"</string>
@@ -31,6 +36,7 @@
</plurals>
<string name="notification_invite_body">"V-a invitat la o discuție"</string>
<string name="notification_invite_body_with_sender">"%1$s v-a invitat să discutați"</string>
<string name="notification_live_location_started_body">"A început să își partajeze locația live"</string>
<string name="notification_mentioned_you_body">"%1$s v-a menționat"</string>
<string name="notification_new_messages">"Mesaje noi"</string>
<plurals name="notification_new_messages_for_room">
@@ -22,6 +22,11 @@
<item quantity="few">"У вас %d нових повідомлення."</item>
<item quantity="many">"У вас %d нових повідомлень."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="one">"Поширено %1$d вкладення"</item>
<item quantity="few">"Поширено %1$d вкладення"</item>
<item quantity="many">"Поширено %1$d вкладень"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Вхідний виклик"</string>
<string name="notification_incoming_call">"📹 Вхідний виклик"</string>
<string name="notification_inline_reply_failed">"** Не вдалося надіслати - відкрийте кімнату"</string>
@@ -16,6 +16,9 @@
<plurals name="notification_fallback_n_content">
<item quantity="other">"您有 %d 則新訊息。"</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="other">"已分享 %1$d 個附件"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 來電"</string>
<string name="notification_incoming_call">"📹 來電"</string>
<string name="notification_inline_reply_failed">"** 無法傳送,請開啟聊天室"</string>
@@ -26,6 +29,7 @@
</plurals>
<string name="notification_invite_body">"邀請您聊天"</string>
<string name="notification_invite_body_with_sender">"%1$s 邀請您加入聊天"</string>
<string name="notification_live_location_started_body">"已開始分享他們的即時位置"</string>
<string name="notification_mentioned_you_body">"提及您:%1$s"</string>
<string name="notification_new_messages">"新訊息"</string>
<plurals name="notification_new_messages_for_room">
@@ -16,6 +16,9 @@
<plurals name="notification_fallback_n_content">
<item quantity="other">"你有 %d 个新消息。"</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="other">"已共享 %1$d 个附件"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 来电"</string>
<string name="notification_incoming_call">"📹 来电"</string>
<string name="notification_inline_reply_failed">"** 无法发送——请打开房间"</string>
@@ -19,6 +19,10 @@
<item quantity="one">"You have %d new message."</item>
<item quantity="other">"You have %d new messages."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="one">"Shared %1$d attachment"</item>
<item quantity="other">"Shared %1$d attachments"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Incoming call"</string>
<string name="notification_incoming_call">"📹 Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
@@ -35,12 +35,12 @@
<string name="a11y_qr_code">"QR-Code"</string>
<string name="a11y_react_with">"Reagiere mit %1$s"</string>
<string name="a11y_react_with_other_emojis">"Mit anderen Emojis reagieren"</string>
<string name="a11y_read_receipts_multiple">"Gelesen von %1$s und %2$s"</string>
<string name="a11y_read_receipts_multiple">"Vorherige Nachricht gelesen von %1$s und %2$s"</string>
<plurals name="a11y_read_receipts_multiple_with_others">
<item quantity="one">"Gelesen von %1$s und %2$d weiteren"</item>
<item quantity="other">"Gelesen von %1$s und %2$d weiteren"</item>
<item quantity="one">"Vorherige Nachricht gelesen von %1$s und %2$d weiteren"</item>
<item quantity="other">"Vorherige Nachricht gelesen von %1$s und %2$d weiteren"</item>
</plurals>
<string name="a11y_read_receipts_single">"Gelesen von %1$s"</string>
<string name="a11y_read_receipts_single">"Vorherige Nachricht gelesen von %1$s"</string>
<string name="a11y_remove_reaction">"Reaktion mit %1$s entfernen"</string>
<string name="a11y_remove_reaction_with">"Entferne Reaktionen mit %1$s"</string>
<string name="a11y_room_avatar">"Avatar"</string>
@@ -49,6 +49,7 @@
<string name="a11y_session_verification_time_limited_action_required">"Zeitlich begrenzte Handlung erforderlich, du hast eine Minute Zeit zur Verifizierung"</string>
<string name="a11y_show_password">"Passwort anzeigen"</string>
<string name="a11y_start_call">"Anruf starten"</string>
<string name="a11y_start_video_call">"Starte einen Videoanruf"</string>
<string name="a11y_start_voice_call">"Sprachanruf starten"</string>
<string name="a11y_tombstoned_room">"Stillgelegter Chat"</string>
<string name="a11y_user_avatar">"Nutzer-Avatar"</string>
@@ -460,6 +461,13 @@ Möchtest du wirklich fortfahren?"</string>
<string name="screen_create_poll_options_section_title">"Optionen"</string>
<string name="screen_create_poll_remove_accessibility_label">"Entferne %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Einstellungen"</string>
<string name="screen_live_location_sheet_nobody_sharing">"Niemand teilt seinen Standort"</string>
<string name="screen_live_location_sheet_sharing_live_location">"Live-Standort teilen"</string>
<plurals name="screen_live_location_sheet_subtitle">
<item quantity="one">"%1$d Person"</item>
<item quantity="other">"%1$d Leute"</item>
</plurals>
<string name="screen_live_location_sheet_title">"Auf der Karte"</string>
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_pinned_timeline_empty_state_description">"Halte eine Nachricht gedrückt und wähle “%1$s”, um sie hier einzufügen."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Fixiere wichtige Nachrichten, so dass sie leicht gefunden werden können"</string>
@@ -484,6 +492,7 @@ Möchtest du wirklich fortfahren?"</string>
<string name="screen_room_event_pill">"Nachricht in %1$s"</string>
<string name="screen_room_grouped_state_events_expand">"Erweitern"</string>
<string name="screen_room_grouped_state_events_reduce">"Verkleinern"</string>
<string name="screen_room_live_location_banner">"Live-Standort teilen"</string>
<string name="screen_room_permalink_same_room_android">"Du siehst diesen Chat bereits!"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s von %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s fixierte Nachrichten"</string>
@@ -507,6 +507,14 @@ Kas sa oled kindel, et soovid jätkata?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_bug_report_a11y_screenshot">"Ekraanitõmmis"</string>
<string name="screen_change_server_navigation_title_login">"Logi sisse"</string>
<string name="screen_change_server_navigation_title_register">"Loo kasutajakonto"</string>
<string name="screen_change_server_textfield_footer_login">"Kui sinu kasutajakonto paikneb äriühingu või kogukonna serveris, siis muuda kontoteenuse pakkujat (näiteks: minufirmanimi.ee)."</string>
<string name="screen_change_server_textfield_footer_register">"Loo tasuta kasutajakonto matrix.org serverisse. Kui soovid luua kasutajakonto mõne teise teenusepakkuja juures, siis muuda seda vastavalt (näiteks: minukogukond.ee)."</string>
<string name="screen_change_server_textfield_header">"Teenusepakkuja"</string>
<string name="screen_change_server_textfield_placeholder">"domeeninäidis.ee"</string>
<string name="screen_change_server_title_login">"Sisesta oma kasutajakonto teenusepakkuja"</string>
<string name="screen_change_server_title_register">"Vali kasutajakonto teenusepakkuja"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Valikud"</string>
<string name="screen_create_poll_remove_accessibility_label">"Kustuta: %1$s"</string>
@@ -8,6 +8,7 @@
<string name="a11y_collapse_message_text_field">"Minimiziraj tekstno polje poruke"</string>
<string name="a11y_create_poll_votes_allowed_decrease">"Smanjenje dopuštenih glasova po osobi"</string>
<string name="a11y_create_poll_votes_allowed_increase">"Povećanje broja dopuštenih glasova po osobi"</string>
<string name="a11y_custom_emoji">"Prilagođeni Emoji"</string>
<string name="a11y_delete">"Izbriši"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d unesena znamenka"</item>
@@ -65,6 +66,7 @@
<string name="a11y_start_call">"Započni poziv"</string>
<string name="a11y_start_video_call">"Započni videopoziv"</string>
<string name="a11y_start_voice_call">"Započni glasovni poziv"</string>
<string name="a11y_status">"Stanje"</string>
<string name="a11y_thread_in_room">"Nit u %1$s"</string>
<string name="a11y_threads_in_room">"Teme u %1$s"</string>
<string name="a11y_tombstoned_room">"Soba označena za uklanjanje"</string>
@@ -8,6 +8,7 @@
<string name="a11y_collapse_message_text_field">"Üzenet szövegmezőjének minimalizálása"</string>
<string name="a11y_create_poll_votes_allowed_decrease">"Személyenként engedélyezett szavazatok számának csökkentése"</string>
<string name="a11y_create_poll_votes_allowed_increase">"Személyenként engedélyezett szavazatok számának növelése"</string>
<string name="a11y_custom_emoji">"Egyéni emoji"</string>
<string name="a11y_delete">"Törlés"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d megadott számjegy"</item>
@@ -23,6 +24,7 @@
<string name="a11y_join_call">"Csatlakozás a híváshoz"</string>
<string name="a11y_jump_to_bottom">"Ugrás az aljára"</string>
<string name="a11y_jump_to_unread">"Ugrás az olvasatlanhoz"</string>
<string name="a11y_jump_to_unread_messages">"Ugrás az első olvasatlan üzenetre"</string>
<string name="a11y_move_the_map_to_my_location">"Térkép áthelyezése a jelenlegi helyre"</string>
<string name="a11y_notifications_mentions_only">"Csak megemlítések"</string>
<string name="a11y_notifications_muted">"Némítva"</string>
@@ -63,6 +65,7 @@
<string name="a11y_start_call">"Hanghívás indítása"</string>
<string name="a11y_start_video_call">"Videohívás indítása"</string>
<string name="a11y_start_voice_call">"Hanghívás indítása"</string>
<string name="a11y_status">"Állapot"</string>
<string name="a11y_thread_in_room">"Üzenetszál itt: %1$s"</string>
<string name="a11y_threads_in_room">"Üzenetszálak itt: %1$s"</string>
<string name="a11y_tombstoned_room">"Elévült szoba"</string>
@@ -143,6 +146,7 @@
<string name="action_manage_account_and_devices">"Fiók és eszközök kezelése"</string>
<string name="action_manage_devices">"Eszközök kezelése"</string>
<string name="action_manage_rooms">"Szobák kezelése"</string>
<string name="action_mark_as_read">"Megjelölés olvasottként"</string>
<string name="action_message">"Üzenet"</string>
<string name="action_minimize">"Minimalizálás"</string>
<string name="action_next">"Következő"</string>
@@ -267,6 +271,14 @@ Ok: %1$s."</string>
<string name="common_forward_message">"Üzenet továbbítása"</string>
<string name="common_frequently_used">"Gyakran használt"</string>
<string name="common_gallery">"Galéria"</string>
<plurals name="common_gallery_reply_attachments">
<item quantity="one">"%1$d melléklet…"</item>
<item quantity="other">"%1$d melléklet…"</item>
</plurals>
<plurals name="common_gallery_reply_media_items">
<item quantity="one">"%1$d média…"</item>
<item quantity="other">"%1$d média…"</item>
</plurals>
<string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Csoportos hívás folyamatban"</string>
<string name="common_image">"Kép"</string>
@@ -304,6 +316,7 @@ Ok: %1$s."</string>
<string name="common_no_space_name">"Nincs térnév"</string>
<string name="common_not_encrypted">"Nincs titkosítva"</string>
<string name="common_offline">"Kapcsolat nélkül"</string>
<string name="common_on_a_call">"Hívásban"</string>
<string name="common_open_source_licenses">"Nyílt forráskódú licencek"</string>
<string name="common_or">"vagy"</string>
<string name="common_other_options">"További lehetőségek"</string>
@@ -505,6 +518,8 @@ Biztos, hogy folytatja?"</string>
<item quantity="other">"%1$d személy"</item>
</plurals>
<string name="screen_live_location_sheet_title">"A térképen"</string>
<string name="screen_local_network_opt_in_subtitle">"A Matrix-kiszolgáló helyi hálózaton található. A csatlakozáshoz engedélyre van szüksége ennek: %1$s , hogy elérje a hálózaton lévő eszközöket."</string>
<string name="screen_local_network_opt_in_title">"Hozzáférés engedélyezése a helyi hálózathoz"</string>
<string name="screen_media_picker_error_failed_selection">"Nem sikerült kiválasztani a médiát, próbálja újra."</string>
<string name="screen_pinned_timeline_empty_state_description">"Nyomjon hosszan az üzenetre, és válassza a „%1$s” lehetőséget, hogy itt szerepeljen."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Tűzze ki a fontos üzeneteket, hogy könnyen felfedezhetők legyenek"</string>
@@ -20,6 +20,16 @@
<string name="a11y_edit_room_address_hint">"Podgląd pełnego adresu %1$s"</string>
<string name="a11y_encryption_details">"Szczegóły szyfrowania"</string>
<string name="a11y_expand_message_text_field">"Powiększ pole tekstowe wiadomości"</string>
<string name="a11y_gallery_attachment">"Galeria, załącznik %1$d"</string>
<string name="a11y_gallery_failed_scan">"Galeria, nieudane skanowanie %1$d"</string>
<string name="a11y_gallery_image">"Galeria, obraz %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="one">"Galeria, %1$d dodatkowe media"</item>
<item quantity="few">"Galeria, %1$d dodatkowe media"</item>
<item quantity="many">"Galeria, %1$d dodatkowych mediów"</item>
</plurals>
<string name="a11y_gallery_scanning">"Galeria, skanowanie %1$d"</string>
<string name="a11y_gallery_video">"Galeria, wideo %1$d"</string>
<string name="a11y_hide_password">"Ukryj hasło"</string>
<string name="a11y_info">"Informacje"</string>
<string name="a11y_join_call">"Dołącz do połączenia"</string>
@@ -184,8 +194,8 @@
<string name="action_send_edited_message">"Wyślij edytowaną wiadomość"</string>
<string name="action_send_message">"Wyślij wiadomość"</string>
<string name="action_send_voice_message">"Wyślij wiadomość głosową"</string>
<string name="action_share">"Wyślij"</string>
<string name="action_share_link">"Wyślij link"</string>
<string name="action_share">"Udostępnij"</string>
<string name="action_share_link">"Udostępnij link"</string>
<string name="action_share_live_location">"Udostępnij lokalizację na żywo"</string>
<string name="action_show">"Pokaż"</string>
<string name="action_sign_in_again">"Zaloguj się ponownie"</string>
@@ -518,6 +528,14 @@ Czy na pewno chcesz kontynuować?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Wstrząśnij gniewnie, aby zgłosić błąd"</string>
<string name="screen_bug_report_a11y_screenshot">"Zrzut ekranu"</string>
<string name="screen_change_server_navigation_title_login">"Zaloguj"</string>
<string name="screen_change_server_navigation_title_register">"Utwórz konto"</string>
<string name="screen_change_server_textfield_footer_login">"Jeśli uzyskałeś konto od firmy lub społeczności, wprowadź dostawcę konta (na przykład: nazwafirmy.com)"</string>
<string name="screen_change_server_textfield_footer_register">"Załóż darmowe konto na matrix.org. Jeśli chcesz założyć konto u innego dostawcy wprowadź własny adres (na przykład: nazwaspołeczności.org)."</string>
<string name="screen_change_server_textfield_header">"Dostawca konta"</string>
<string name="screen_change_server_textfield_placeholder">"przykład.com"</string>
<string name="screen_change_server_title_login">"Wprowadź dostawcę swojego konta"</string>
<string name="screen_change_server_title_register">"Wybierz dostawcę konta"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Opcje"</string>
<string name="screen_create_poll_remove_accessibility_label">"Usuń %1$s"</string>
@@ -1,23 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_add_avatar">"Adăugați un avatar"</string>
<string name="a11y_add_reaction">"Adăugați o reacție: %1$s"</string>
<string name="a11y_address">"Adresă"</string>
<string name="a11y_avatar">"Imagine de profil"</string>
<string name="a11y_clear_search_field">"Ștergeți câmpul de căutare"</string>
<string name="a11y_collapse_message_text_field">"Micșorați câmpul mesajului"</string>
<string name="a11y_create_poll_votes_allowed_decrease">"Reduceți numărul de voturi permise per persoană"</string>
<string name="a11y_create_poll_votes_allowed_increase">"Măriți numărul de voturi permise per persoană"</string>
<string name="a11y_custom_emoji">"Emoji personalizat"</string>
<string name="a11y_delete">"Ștergere"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d cifră introdusă"</item>
<item quantity="few">"%1$d cifre introduse"</item>
<item quantity="other">"%1$d cifre introduse"</item>
</plurals>
<string name="a11y_duration">"Durată: %1$s"</string>
<string name="a11y_edit_avatar">"Editați avatarul"</string>
<string name="a11y_edit_room_address_hint">"Adresa completă va fi %1$s"</string>
<string name="a11y_encryption_details">"Detalii privind criptarea"</string>
<string name="a11y_expand_message_text_field">"Extindeți câmpul mesajului"</string>
<string name="a11y_gallery_attachment">"Galerie, atașament %1$d"</string>
<string name="a11y_gallery_failed_scan">"Galerie, scanare eșuată %1$d"</string>
<string name="a11y_gallery_image">"Galerie, imagine %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="one">"Galerie, încă %1$d fișier media"</item>
<item quantity="few">"Galerie, încă %1$d fișiere media"</item>
<item quantity="other">"Galerie, încă %1$d fișiere media"</item>
</plurals>
<string name="a11y_gallery_scanning">"Galerie, scanare %1$d"</string>
<string name="a11y_gallery_video">"Galerie, videoclip %1$d"</string>
<string name="a11y_hide_password">"Ascundeți parola"</string>
<string name="a11y_info">"Informații"</string>
<string name="a11y_join_call">"Alăturați-vă apelului"</string>
<string name="a11y_jump_to_bottom">"Mergeți în jos"</string>
<string name="a11y_jump_to_unread">"Săriți la necitite"</string>
<string name="a11y_jump_to_unread_messages">"Săriți la primul mesaj necitit"</string>
<string name="a11y_move_the_map_to_my_location">"Mutați harta la locația mea"</string>
<string name="a11y_notifications_mentions_only">"Doar mențiuni"</string>
<string name="a11y_notifications_muted">"Notificări dezactivate"</string>
@@ -28,12 +46,14 @@
<string name="a11y_page_n">"Pagina %1$d"</string>
<string name="a11y_pause">"Pauză"</string>
<string name="a11y_paused_voice_message">"Mesaj vocal, durată:%1$s, poziție curentă: %2$s"</string>
<string name="a11y_photo_preview">"Previzualizare foto"</string>
<string name="a11y_pin_field">"Câmp PIN"</string>
<string name="a11y_pinned_location">"Locație fixată"</string>
<string name="a11y_play">"Redați"</string>
<string name="a11y_playback_speed">"Viteză de redare"</string>
<string name="a11y_poll">"Sondaj"</string>
<string name="a11y_poll_end">"Sondaj încheiat"</string>
<string name="a11y_position">"Poziție: %1$s"</string>
<string name="a11y_qr_code">"Cod QR"</string>
<string name="a11y_react_with">"Reacționați cu %1$s"</string>
<string name="a11y_react_with_other_emojis">"Reacționați cu alte emoji-uri"</string>
@@ -44,20 +64,26 @@
<item quantity="other">"Citit de %1$s și incă %2$d"</item>
</plurals>
<string name="a11y_read_receipts_single">"Citit de %1$s"</string>
<string name="a11y_read_receipts_tap_to_show_all_android">"afișați toate confirmările de citire"</string>
<string name="a11y_remove_reaction">"Îndepărtați reacția cu %1$s"</string>
<string name="a11y_remove_reaction_with">"Îndepărtați reacția %1$s"</string>
<string name="a11y_room_avatar">"Avatarul camerei"</string>
<string name="a11y_send_files">"Trimiteți fișiere"</string>
<string name="a11y_sender_location">"Locația expeditorului"</string>
<string name="a11y_sent_by_sender_at_date">"Trimis de %1$s la %2$s"</string>
<string name="a11y_session_verification_time_limited_action_required">"Acțiune limitată în timp necesară, aveți un minut pentru a verifica"</string>
<string name="a11y_settings_with_required_action">"Setări, acțiune necesară"</string>
<string name="a11y_show_password">"Afișați parola"</string>
<string name="a11y_start_call">"Începeți un apel"</string>
<string name="a11y_start_video_call">"Inițiați un apel video"</string>
<string name="a11y_start_voice_call">"Inițiați un apel vocal"</string>
<string name="a11y_status">"Statuts"</string>
<string name="a11y_thread_in_room">"Fir în: %1$s"</string>
<string name="a11y_threads_in_room">"Fire în %1$s"</string>
<string name="a11y_tombstoned_room">"Cameră terminată"</string>
<string name="a11y_user_avatar">"Avatar utilizator"</string>
<string name="a11y_user_menu">"Meniu utilizator"</string>
<string name="a11y_video_preview">"Previzualizare video"</string>
<string name="a11y_view_avatar">"Vizualizați avatarul"</string>
<string name="a11y_view_details">"Vizualizați detalii"</string>
<string name="a11y_voice_message">"Mesaj vocal, durată: %1$s"</string>
@@ -94,6 +120,7 @@
<string name="action_decline_and_block">"Refuzați și blocați"</string>
<string name="action_delete">"Ștergere"</string>
<string name="action_delete_account">"Ștergeți contul"</string>
<string name="action_delete_file">"Ștergeți fișierul"</string>
<string name="action_delete_poll">"Ștergeți sondajul"</string>
<string name="action_deselect_all">"Deselectați tot"</string>
<string name="action_disable">"Dezactivați"</string>
@@ -131,6 +158,7 @@
<string name="action_manage_account_and_devices">"Gestionați contul și dispozitivele"</string>
<string name="action_manage_devices">"Gestionați dispozitivele"</string>
<string name="action_manage_rooms">"Gestionați camerele"</string>
<string name="action_mark_as_read">"Marcați ca citit"</string>
<string name="action_message">"Contactați"</string>
<string name="action_minimize">"Minimizați"</string>
<string name="action_next">"Următorul"</string>
@@ -185,9 +213,11 @@
<string name="action_translate">"Traduceți"</string>
<string name="action_try_again">"Încercați din nou"</string>
<string name="action_unpin">"Defixați"</string>
<string name="action_video_call">"Apel video"</string>
<string name="action_view">"Vizualizați"</string>
<string name="action_view_in_timeline">"Vedeți în lista de mesaje"</string>
<string name="action_view_source">"Vedeți sursă"</string>
<string name="action_voice_call">"Apel vocal"</string>
<string name="action_yes">"Da"</string>
<string name="action_yes_try_again">"Da, încercați din nou"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Serverul dvs. acceptă acum un protocol nou, mai rapid. Deconectați-vă și conectați-vă din nou pentru a face upgrade acum. Dacă faceți acest lucru acum, vă va ajuta să evitați o deconectare forțată atunci când vechiul protocol este eliminat ulterior."</string>
@@ -208,6 +238,7 @@
<string name="common_blocked_users">"Utilizatori blocați"</string>
<string name="common_bubbles">"Baloane"</string>
<string name="common_call_declined">"Apel respins"</string>
<string name="common_call_in_progress">"Apel în curs"</string>
<string name="common_call_started">"Apel inițiat"</string>
<string name="common_call_you_declined">"Ați respins un apel"</string>
<string name="common_chat_backup">"Backup conversații"</string>
@@ -251,7 +282,19 @@ Motiv:%1$s."</string>
<string name="common_file_saved_on_disk_android">"Fișier salvat în Descărcări"</string>
<string name="common_forward_message">"Redirecționați mesajul"</string>
<string name="common_frequently_used">"Utilizate frecvent"</string>
<string name="common_gallery">"Galerie"</string>
<plurals name="common_gallery_reply_attachments">
<item quantity="one">"%1$d atașament…"</item>
<item quantity="few">"%1$d atașamente…"</item>
<item quantity="other">"%1$d atașamente…"</item>
</plurals>
<plurals name="common_gallery_reply_media_items">
<item quantity="one">"%1$d element media…"</item>
<item quantity="few">"%1$d elemente media…"</item>
<item quantity="other">"%1$d elemente media…"</item>
</plurals>
<string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Apel de grup în curs"</string>
<string name="common_image">"Imagine"</string>
<string name="common_in_reply_to">"Ca răspuns la %1$s"</string>
<string name="common_install_apk_android">"Instalați APK"</string>
@@ -289,6 +332,7 @@ Motiv:%1$s."</string>
<string name="common_no_space_name">"Fără nume de spațiu"</string>
<string name="common_not_encrypted">"Necriptat"</string>
<string name="common_offline">"Deconectat"</string>
<string name="common_on_a_call">"Într-un apel"</string>
<string name="common_open_source_licenses">"Licențe open source"</string>
<string name="common_or">"sau"</string>
<string name="common_other_options">"Alte opțiuni"</string>
@@ -301,6 +345,7 @@ Motiv:%1$s."</string>
<string name="common_please_wait">"Va rugam asteptati…"</string>
<string name="common_poll_end_confirmation">"Sunteți sigur că doriți să încheiați acest sondaj?"</string>
<string name="common_poll_summary">"Sondajul %1$s"</string>
<string name="common_poll_summary_prefix">"Sondaj"</string>
<string name="common_poll_total_votes">"Total voturi: %1$s"</string>
<string name="common_poll_undisclosed_text">"Rezultatele vor fi afișate după încheierea sondajului"</string>
<plurals name="common_poll_votes_count">
@@ -400,6 +445,7 @@ Motiv:%1$s."</string>
<string name="common_unmute">"Activare sunet"</string>
<string name="common_unsupported_call">"Apel incompatibil"</string>
<string name="common_unsupported_event">"Eveniment neacceptat"</string>
<string name="common_user_started_a_call">"%1$s a inițiat un apel"</string>
<string name="common_username">"Utilizator"</string>
<string name="common_verification_cancelled">"Verificare anulată"</string>
<string name="common_verification_complete">"Verificare completă"</string>
@@ -421,6 +467,11 @@ Motiv:%1$s."</string>
<string name="common_waiting_live_location">"Se așteptă localizarea în timp real…"</string>
<string name="common_world_readable_history">"Oricine poate vedea istoricul"</string>
<string name="common_you">"Dumneavoastră"</string>
<string name="content_scanner_not_found">"Descărcarea nu este disponibilă."</string>
<string name="content_scanner_not_found_title">"Ceva nu a mers bine"</string>
<string name="content_scanner_scanning">"Se scanează…"</string>
<string name="content_scanner_unsafe_message">"Previzualizarea și descărcarea au fost dezactivate."</string>
<string name="content_scanner_unsafe_title">"Fișierul nu este sigur"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) a distribuit acest mesaj deoarece nu erați în cameră când a fost trimis."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"%1$s a distribuit acest mesaj deoarece nu erați în cameră când a fost trimis."</string>
<string name="crypto_history_visible">"Această cameră a fost configurată astfel încât noii membri să poată citi istoricul. %1$s"</string>
@@ -477,10 +528,19 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake pentru a raporta erori"</string>
<string name="screen_bug_report_a11y_screenshot">"Captură de ecran"</string>
<string name="screen_change_server_navigation_title_login">"Autentificare"</string>
<string name="screen_change_server_navigation_title_register">"Creați un cont"</string>
<string name="screen_change_server_textfield_footer_login">"Dacă contul este furnizat de compania dumneavoastră sau de o comunitate, schimbați furnizorul contului (exemplu: numecompanie.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Creați un cont gratuit pe matrix.org. Dacă doriți să creați un cont la un alt furnizor, schimbați-l în consecință (exemplu: numecomunitate.org)."</string>
<string name="screen_change_server_textfield_header">"Furnizor de cont"</string>
<string name="screen_change_server_textfield_placeholder">"exemplu.ro"</string>
<string name="screen_change_server_title_login">"Introduceți furnizorul contului dumneavoastră"</string>
<string name="screen_change_server_title_register">"Alegeți un furnizor de cont"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Opțiuni"</string>
<string name="screen_create_poll_remove_accessibility_label">"Ștergeți %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Setări"</string>
<string name="screen_create_poll_votes_allowed_per_person">"Voturi permise per persoană"</string>
<string name="screen_live_location_sheet_nobody_sharing">"Nimeni nu își partajează locația"</string>
<string name="screen_live_location_sheet_sharing_live_location">"Se partajează locația în timp real"</string>
<plurals name="screen_live_location_sheet_subtitle">
@@ -489,6 +549,8 @@ Sunteți sigur că doriți să continuați?"</string>
<item quantity="other">"%1$d persoane"</item>
</plurals>
<string name="screen_live_location_sheet_title">"Pe hartă"</string>
<string name="screen_local_network_opt_in_subtitle">"Homeserverul dumneavoastră se află în rețeaua locală. Pentru a se conecta, %1$s are nevoie de permisiunea de a accesa dispozitive din această rețea."</string>
<string name="screen_local_network_opt_in_title">"Permiteți accesul la rețeaua locală"</string>
<string name="screen_media_picker_error_failed_selection">"Selectarea fișierelor media a eșuat, încercați din nou."</string>
<string name="screen_pinned_timeline_empty_state_description">"Apăsați pe un mesaj și alegeți \"%1$s\" pentru a-l include aici."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Fixați mesajele importante, astfel încât să poată fi descoperite cu ușurință"</string>
@@ -535,6 +597,11 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="screen_space_list_title">"Spații"</string>
<string name="screen_static_location_sheet_timestamp_description">"Partajat %1$s"</string>
<string name="screen_static_location_sheet_title">"Pe hartă"</string>
<plurals name="screen_timeline_active_call_joined_count">
<item quantity="one">"%1$d s-a alăturat"</item>
<item quantity="few">"%1$d s-au alăturat"</item>
<item quantity="other">"%1$d s-au alăturat"</item>
</plurals>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Mesajul nu a fost trimis deoarece identitatea digitala verificată a lui %1$s s-a schimbat."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Mesajul nu a fost trimis deoarece %1$s nu a verificat toate dispozitivele."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Mesajul nu a fost trimis deoarece nu ați verificat unul sau mai multe dispozitive."</string>
@@ -546,4 +613,6 @@ Sunteți sigur că doriți să continuați?"</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"Nu aveți acces la acest mesaj"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Nu s-a putut decripta mesajul"</string>
<string name="timeline_decryption_failure_withheld_unverified">"Acest mesaj a fost blocat fie pentru că nu ați verificat dispozitivul, fie pentru că expeditorul trebuie să vă verifice identitatea digitală."</string>
<string name="vulkan_not_supported_dialog_content_android">"Dispozitivul dumneavoastră este prea vechi, este necesar un dispozitiv cu Android 8 sau o versiune mai nouă."</string>
<string name="vulkan_not_supported_dialog_title_android">"Afișarea hărților nu este suportată"</string>
</resources>
@@ -440,7 +440,7 @@ Anledning:%1$s."</string>
<string name="screen_share_open_apple_maps">"Öppna i Apple Maps"</string>
<string name="screen_share_open_google_maps">"Öppna i Google Maps"</string>
<string name="screen_share_open_osm_maps">"Öppna i OpenStreetMap"</string>
<string name="screen_share_this_location_action">"Dela fäst plats"</string>
<string name="screen_share_this_location_action">"Dela vald plats"</string>
<string name="screen_space_list_description">"Utrymmen som du har skapat eller gått med i."</string>
<string name="screen_space_list_details">"%1$s • %2$s"</string>
<string name="screen_space_list_title">"Utrymmen"</string>
@@ -20,6 +20,16 @@
<string name="a11y_edit_room_address_hint">"Повна адреса буде %1$s"</string>
<string name="a11y_encryption_details">"Подробиці шифрування"</string>
<string name="a11y_expand_message_text_field">"Розгорнути текстове поле повідомлення"</string>
<string name="a11y_gallery_attachment">"Галерея, вкладення %1$d"</string>
<string name="a11y_gallery_failed_scan">"Галерея, невдалося сканувати %1$d"</string>
<string name="a11y_gallery_image">"Галерея, зображення %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="one">"Галерея, ще %1$d медіа"</item>
<item quantity="few">"Галерея, ще %1$d медіа"</item>
<item quantity="many">"Галерея, ще %1$d медіа"</item>
</plurals>
<string name="a11y_gallery_scanning">"Галерея, сканування %1$d"</string>
<string name="a11y_gallery_video">"Галерея, відео %1$d"</string>
<string name="a11y_hide_password">"Cховати пароль"</string>
<string name="a11y_info">"Інформація"</string>
<string name="a11y_join_call">"Приєднатися до виклику"</string>
@@ -518,6 +528,14 @@
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Повідомляти про помилку потрусивши телефон"</string>
<string name="screen_bug_report_a11y_screenshot">"Знімок екрана"</string>
<string name="screen_change_server_navigation_title_login">"Увійти"</string>
<string name="screen_change_server_navigation_title_register">"Створити обліковий запис"</string>
<string name="screen_change_server_textfield_footer_login">"Якщо ваш обліковий запис надано вашою компанією або спільнотою, змініть постачальника облікового запису (наприклад: companyname.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Створіть обліковий запис на matrix.org безплатно. Якщо ви хочете створити обліковий запис у іншого постачальника, змініть його відповідно (наприклад: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Постачальник облікового запису"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title_login">"Вкажіть постачальника вашого облікового запису"</string>
<string name="screen_change_server_title_register">"Оберіть постачальника облікового запису"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Варіанти"</string>
<string name="screen_create_poll_remove_accessibility_label">"Вилучити %1$s"</string>
@@ -1,20 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_add_avatar">"新增大頭照"</string>
<string name="a11y_add_reaction">"新增反應:%1$s"</string>
<string name="a11y_address">"地址"</string>
<string name="a11y_avatar">"大頭貼"</string>
<string name="a11y_clear_search_field">"清除搜尋欄位"</string>
<string name="a11y_collapse_message_text_field">"最小化訊息文字欄位"</string>
<string name="a11y_create_poll_votes_allowed_decrease">"減少每人可投的票數"</string>
<string name="a11y_create_poll_votes_allowed_increase">"增加每人可投的票數上限"</string>
<string name="a11y_custom_emoji">"自訂表情符號"</string>
<string name="a11y_delete">"刪除"</string>
<plurals name="a11y_digits_entered">
<item quantity="other">"已輸入 %1$d 個位數"</item>
</plurals>
<string name="a11y_duration">"持續時間:%1$s"</string>
<string name="a11y_edit_avatar">"編輯大頭照"</string>
<string name="a11y_edit_room_address_hint">"完整地址為 %1$s"</string>
<string name="a11y_encryption_details">"加密詳細資訊"</string>
<string name="a11y_expand_message_text_field">"展開訊息文字欄位"</string>
<string name="a11y_hide_password">"隱藏密碼"</string>
<string name="a11y_info">"資訊"</string>
<string name="a11y_join_call">"加入通話"</string>
<string name="a11y_jump_to_bottom">"跳至底部"</string>
<string name="a11y_jump_to_unread">"跳至未讀訊息"</string>
<string name="a11y_jump_to_unread_messages">"跳到第一則未讀訊息"</string>
<string name="a11y_move_the_map_to_my_location">"將地圖移動到我的位置"</string>
<string name="a11y_notifications_mentions_only">"僅限提及"</string>
<string name="a11y_notifications_muted">"已關閉通知"</string>
@@ -25,33 +34,42 @@
<string name="a11y_page_n">"第 %1$d 頁"</string>
<string name="a11y_pause">"暫停"</string>
<string name="a11y_paused_voice_message">"語音訊息,時長:%1$s,目前位置:%2$s"</string>
<string name="a11y_photo_preview">"照片預覽"</string>
<string name="a11y_pin_field">"PIN 碼欄位"</string>
<string name="a11y_pinned_location">"固定位置"</string>
<string name="a11y_play">"播放"</string>
<string name="a11y_playback_speed">"播放速度"</string>
<string name="a11y_poll">"投票"</string>
<string name="a11y_poll_end">"投票已結束"</string>
<string name="a11y_position">"位置:%1$s"</string>
<string name="a11y_qr_code">"QR Code"</string>
<string name="a11y_react_with">"使用 %1$s 回應"</string>
<string name="a11y_react_with_other_emojis">"用其他表情符號回應"</string>
<string name="a11y_read_receipts_multiple">"%1$s 和 %2$s 已讀"</string>
<string name="a11y_read_receipts_multiple">"上一則訊息已被 %1$s 和 %2$s 閱讀"</string>
<plurals name="a11y_read_receipts_multiple_with_others">
<item quantity="other">"%1$s 與其他 %2$d 個人已讀"</item>
<item quantity="other">"前一則訊息已被 %1$s 及另外 %2$d 人閱讀"</item>
</plurals>
<string name="a11y_read_receipts_single">"%1$s 已讀"</string>
<string name="a11y_read_receipts_single">"上一則訊息由 %1$s 閱讀"</string>
<string name="a11y_read_receipts_tap_to_show_all_android">"顯示所有讀取回條"</string>
<string name="a11y_remove_reaction">"移除 %1$s 的反應"</string>
<string name="a11y_remove_reaction_with">"移除反應 %1$s"</string>
<string name="a11y_room_avatar">"聊天室大頭照"</string>
<string name="a11y_send_files">"傳送檔案"</string>
<string name="a11y_sender_location">"傳送者位置"</string>
<string name="a11y_sent_by_sender_at_date">"由 %1$s 於 %2$s 傳送"</string>
<string name="a11y_session_verification_time_limited_action_required">"需要限時動作,您有一分鐘可以驗證"</string>
<string name="a11y_settings_with_required_action">"設定,需採取行動"</string>
<string name="a11y_show_password">"顯示密碼"</string>
<string name="a11y_start_call">"開始通話"</string>
<string name="a11y_start_video_call">"開始視訊通話"</string>
<string name="a11y_start_voice_call">"開始語音通話"</string>
<string name="a11y_status">"狀態"</string>
<string name="a11y_thread_in_room">"%1$s 中的討論串"</string>
<string name="a11y_threads_in_room">"%1$s 中的討論串"</string>
<string name="a11y_tombstoned_room">"墓碑聊天室"</string>
<string name="a11y_user_avatar">"使用者大頭照"</string>
<string name="a11y_user_menu">"使用者選單"</string>
<string name="a11y_video_preview">"影片預覽"</string>
<string name="a11y_view_avatar">"檢視大頭照"</string>
<string name="a11y_view_details">"檢視詳細資訊"</string>
<string name="a11y_voice_message">"語音訊息,時長:%1$s"</string>
@@ -66,6 +84,7 @@
<string name="action_call">"通話"</string>
<string name="action_cancel">"取消"</string>
<string name="action_cancel_for_now">"暫時取消"</string>
<string name="action_choose_file">"選擇檔案"</string>
<string name="action_choose_photo">"選擇照片"</string>
<string name="action_clear">"清除"</string>
<string name="action_close">"關閉"</string>
@@ -85,12 +104,16 @@
<string name="action_deactivate_account">"停用帳號"</string>
<string name="action_decline">"拒絕"</string>
<string name="action_decline_and_block">"拒絕並封鎖"</string>
<string name="action_delete">"刪除"</string>
<string name="action_delete_account">"刪除帳號"</string>
<string name="action_delete_file">"刪除檔案"</string>
<string name="action_delete_poll">"刪除投票"</string>
<string name="action_deselect_all">"取消全選"</string>
<string name="action_disable">"停用"</string>
<string name="action_discard">"捨棄"</string>
<string name="action_dismiss">"關閉"</string>
<string name="action_done">"完成"</string>
<string name="action_download">"下載"</string>
<string name="action_edit">"編輯"</string>
<string name="action_edit_caption">"編輯標題"</string>
<string name="action_edit_poll">"編輯投票"</string>
@@ -121,6 +144,7 @@
<string name="action_manage_account_and_devices">"管理帳號與裝置"</string>
<string name="action_manage_devices">"管理裝置"</string>
<string name="action_manage_rooms">"管理聊天室"</string>
<string name="action_mark_as_read">"標記為已讀"</string>
<string name="action_message">"聊天"</string>
<string name="action_minimize">"最小化"</string>
<string name="action_next">"下一步"</string>
@@ -175,9 +199,11 @@
<string name="action_translate">"翻譯"</string>
<string name="action_try_again">"再試一次"</string>
<string name="action_unpin">"取消釘選"</string>
<string name="action_video_call">"視訊通話"</string>
<string name="action_view">"檢視"</string>
<string name="action_view_in_timeline">"在時間軸中檢視"</string>
<string name="action_view_source">"檢視來源"</string>
<string name="action_voice_call">"語音通話"</string>
<string name="action_yes">"是"</string>
<string name="action_yes_try_again">"是的,再試一次"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"您的伺服器現在支援新的、更快的協定。立即登出並重新登入以進行升級。現在這樣做將協助您避免在稍後移除舊協定時被強制登出。"</string>
@@ -197,7 +223,10 @@
<string name="common_beta">"測試版"</string>
<string name="common_blocked_users">"封鎖的使用者"</string>
<string name="common_bubbles">"泡泡"</string>
<string name="common_call_declined">"通話被拒"</string>
<string name="common_call_in_progress">"通話中"</string>
<string name="common_call_started">"開始通話"</string>
<string name="common_call_you_declined">"您拒絕了通話"</string>
<string name="common_chat_backup">"聊天室備份"</string>
<string name="common_copied_to_clipboard">"已複製到剪貼簿"</string>
<string name="common_copyright">"著作權"</string>
@@ -239,7 +268,15 @@
<string name="common_file_saved_on_disk_android">"檔案已儲存至 Downloads"</string>
<string name="common_forward_message">"轉寄訊息"</string>
<string name="common_frequently_used">"經常使用"</string>
<string name="common_gallery">"圖庫"</string>
<plurals name="common_gallery_reply_attachments">
<item quantity="other">"%1$d 個附件⋯⋯"</item>
</plurals>
<plurals name="common_gallery_reply_media_items">
<item quantity="other">"%1$d 個媒體項目⋯⋯"</item>
</plurals>
<string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"群組通話進行中"</string>
<string name="common_image">"圖片"</string>
<string name="common_in_reply_to">"回覆 %1$s"</string>
<string name="common_install_apk_android">"安裝 APK"</string>
@@ -273,6 +310,7 @@
<string name="common_no_space_name">"沒有空間名稱"</string>
<string name="common_not_encrypted">"未加密"</string>
<string name="common_offline">"離線"</string>
<string name="common_on_a_call">"正在通話"</string>
<string name="common_open_source_licenses">"開放原始碼授權條款"</string>
<string name="common_or">"或"</string>
<string name="common_other_options">"其他選項"</string>
@@ -285,6 +323,7 @@
<string name="common_please_wait">"請稍等…"</string>
<string name="common_poll_end_confirmation">"您確定要結束這項投票嗎?"</string>
<string name="common_poll_summary">"投票:%1$s"</string>
<string name="common_poll_summary_prefix">"投票"</string>
<string name="common_poll_total_votes">"總票數:%1$s"</string>
<string name="common_poll_undisclosed_text">"結果將在投票結束後公佈"</string>
<plurals name="common_poll_votes_count">
@@ -374,6 +413,7 @@
<string name="common_unmute">"開啟通知"</string>
<string name="common_unsupported_call">"不支援的通話"</string>
<string name="common_unsupported_event">"不支援的事件"</string>
<string name="common_user_started_a_call">"%1$s 已發起通話"</string>
<string name="common_username">"使用者名稱"</string>
<string name="common_verification_cancelled">"驗證已取消"</string>
<string name="common_verification_complete">"驗證完成"</string>
@@ -395,6 +435,11 @@
<string name="common_waiting_live_location">"正在等待即時位置……"</string>
<string name="common_world_readable_history">"任何人都可以檢視歷史"</string>
<string name="common_you">"您"</string>
<string name="content_scanner_not_found">"無法下載。"</string>
<string name="content_scanner_not_found_title">"發生錯誤"</string>
<string name="content_scanner_scanning">"正在掃描⋯⋯"</string>
<string name="content_scanner_unsafe_message">"預覽與下載功能已停用。"</string>
<string name="content_scanner_unsafe_title">"檔案不安全"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"因為您當時不在聊天室內,所以 %1$s (%2$s) 分享了此訊息。"</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"因為您當時不在聊天室裡面,因此 %1$s 分享了此訊息。"</string>
<string name="crypto_history_visible">"此聊天室被設定為方便新成員閱讀歷史紀錄。%1$s"</string>
@@ -445,6 +490,9 @@
<string name="error_unknown">"抱歉,發生錯誤"</string>
<string name="invite_friends_rich_title">"🔐️ 在 %1$s 上加入我"</string>
<string name="invite_friends_text">"嘿,來 %1$s 和我聊天:%2$s"</string>
<string name="live_location_sharing_foreground_service_channel_title_android">"即時位置分享"</string>
<string name="live_location_sharing_foreground_service_message_android">"正在分享位置"</string>
<string name="live_location_sharing_foreground_service_title_android">"%1$s 即時位置"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"憤怒搖晃以回報臭蟲"</string>
<string name="screen_bug_report_a11y_screenshot">"螢幕截圖"</string>
@@ -452,6 +500,15 @@
<string name="screen_create_poll_options_section_title">"選項"</string>
<string name="screen_create_poll_remove_accessibility_label">"移除 %1$s"</string>
<string name="screen_create_poll_settings_section_title">"設定"</string>
<string name="screen_create_poll_votes_allowed_per_person">"每人可投票數"</string>
<string name="screen_live_location_sheet_nobody_sharing">"沒有人在分享位置"</string>
<string name="screen_live_location_sheet_sharing_live_location">"分享即時位置"</string>
<plurals name="screen_live_location_sheet_subtitle">
<item quantity="other">"%1$d 人"</item>
</plurals>
<string name="screen_live_location_sheet_title">"在地圖上"</string>
<string name="screen_local_network_opt_in_subtitle">"您的家伺服器位於區域網路。要連線,%1$s 需要取得存取此網路上裝置的權限。"</string>
<string name="screen_local_network_opt_in_title">"允許存取區域網路"</string>
<string name="screen_media_picker_error_failed_selection">"選取媒體失敗,請再試一次。"</string>
<string name="screen_pinned_timeline_empty_state_description">"按一下訊息,然後選擇「%1$s」以加入至此。"</string>
<string name="screen_pinned_timeline_empty_state_headline">"釘選重要訊息,如此才能輕鬆發現"</string>
@@ -496,6 +553,9 @@
<string name="screen_space_list_title">"空間"</string>
<string name="screen_static_location_sheet_timestamp_description">"已分享 %1$s"</string>
<string name="screen_static_location_sheet_title">"在地圖上"</string>
<plurals name="screen_timeline_active_call_joined_count">
<item quantity="other">"%1$d 人已加入"</item>
</plurals>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"因為 %1$s 的驗證數位身份已重設,因此未傳送訊息。"</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"訊息未傳送,因為 %1$s 尚未驗證所有裝置。"</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"因為您尚未驗證一個或多個裝置,因此未傳送訊息"</string>
@@ -507,4 +567,6 @@
<string name="timeline_decryption_failure_historical_event_user_not_joined">"您無法存取此則訊息"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"無法解密訊息"</string>
<string name="timeline_decryption_failure_withheld_unverified">"此訊息被封鎖,原因可能是您尚未驗證裝置,或是寄件者需要驗證您的數位身分。"</string>
<string name="vulkan_not_supported_dialog_content_android">"您的裝置版本過舊,需要執行 Android 8 或更新版本的裝置。"</string>
<string name="vulkan_not_supported_dialog_title_android">"不支援顯示地圖"</string>
</resources>
@@ -18,6 +18,14 @@
<string name="a11y_edit_room_address_hint">"完整地址为 %1$s"</string>
<string name="a11y_encryption_details">"加密详情"</string>
<string name="a11y_expand_message_text_field">"展开消息文本框"</string>
<string name="a11y_gallery_attachment">"媒体组,第 %1$d 个附件"</string>
<string name="a11y_gallery_failed_scan">"媒体组,第 %1$d 个扫描失败"</string>
<string name="a11y_gallery_image">"媒体组,第 %1$d 个图像"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="other">"媒体组,其余 %1$d 个媒体"</item>
</plurals>
<string name="a11y_gallery_scanning">"媒体组,正在扫描第 %1$d 个"</string>
<string name="a11y_gallery_video">"媒体组,第 %1$d 个视频"</string>
<string name="a11y_hide_password">"隐藏密码"</string>
<string name="a11y_info">"信息"</string>
<string name="a11y_join_call">"加入通话"</string>
@@ -268,7 +276,7 @@
<string name="common_file_saved_on_disk_android">"文件已保存到“下载”"</string>
<string name="common_forward_message">"转发消息"</string>
<string name="common_frequently_used">"常用"</string>
<string name="common_gallery">"图库"</string>
<string name="common_gallery">"媒体组"</string>
<plurals name="common_gallery_reply_attachments">
<item quantity="other">"%1$d 个附件…"</item>
</plurals>
@@ -496,6 +504,14 @@
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"摇一摇以报告 bug"</string>
<string name="screen_bug_report_a11y_screenshot">"屏幕截图"</string>
<string name="screen_change_server_navigation_title_login">"登录"</string>
<string name="screen_change_server_navigation_title_register">"创建账户"</string>
<string name="screen_change_server_textfield_footer_login">"如果你的账户由你的公司或社区提供,则应更改账户提供者(例如:companyname.com)。"</string>
<string name="screen_change_server_textfield_footer_register">"在 matrix.org 创建免费账户。如果你想使用其它服务提供者创建账户,请相应地更改域名(例如:communityname.org)。"</string>
<string name="screen_change_server_textfield_header">"账户提供者"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title_login">"输入你的账户提供者"</string>
<string name="screen_change_server_title_register">"选择账户提供者"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s:%2$s"</string>
<string name="screen_create_poll_options_section_title">"选项"</string>
<string name="screen_create_poll_remove_accessibility_label">"移除 %1$s"</string>
@@ -19,6 +19,15 @@
<string name="a11y_edit_room_address_hint">"The full address will be %1$s"</string>
<string name="a11y_encryption_details">"Encryption details"</string>
<string name="a11y_expand_message_text_field">"Expand message text field"</string>
<string name="a11y_gallery_attachment">"Gallery, attachment %1$d"</string>
<string name="a11y_gallery_failed_scan">"Gallery, failed scan %1$d"</string>
<string name="a11y_gallery_image">"Gallery, image %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="one">"Gallery, %1$d more media"</item>
<item quantity="other">"Gallery, %1$d more medias"</item>
</plurals>
<string name="a11y_gallery_scanning">"Gallery, scanning %1$d"</string>
<string name="a11y_gallery_video">"Gallery, video %1$d"</string>
<string name="a11y_hide_password">"Hide password"</string>
<string name="a11y_info">"Info"</string>
<string name="a11y_join_call">"Join call"</string>
@@ -507,6 +516,14 @@ Are you sure you want to continue?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_bug_report_a11y_screenshot">"Screenshot"</string>
<string name="screen_change_server_navigation_title_login">"Sign in"</string>
<string name="screen_change_server_navigation_title_register">"Create account"</string>
<string name="screen_change_server_textfield_footer_login">"If your account is provided by your company or a community, then change the account provider (example: companyname.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Create an account on matrix.org for free. If you want to create an account with a different provider, then change it accordingly (example: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Account provider"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title_login">"Enter your account provider"</string>
<string name="screen_change_server_title_register">"Choose an account provider"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
+4
View File
@@ -12,3 +12,7 @@ plugins {
android {
namespace = "io.element.android.libraries.wellknown.api"
}
dependencies {
implementation(projects.libraries.matrix.api)
}
@@ -11,4 +11,8 @@ 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>
fun interface Factory {
fun create(prefix: String?): ElementWellknownStore
}
}
@@ -0,0 +1,23 @@
/*
* 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
/**
* The source from which the enterprise remote configuration is retrieved.
*/
enum class EnterpriseRemoteConfigSource {
/**
* Retrieve the enterprise remote configuration from the Element Server Suite configuration (ESS_CONFIG) endpoint.
*/
ESS_CONFIG,
/**
* Retrieve the enterprise remote configuration from the Element server's well-known endpoint.
*/
WELLKNOWN_ENDPOINT,
}
@@ -1,13 +0,0 @@
/*
* 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.
*/
package io.element.android.libraries.wellknown.api
interface SessionWellknownRetriever {
suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown>
}
@@ -8,6 +8,15 @@
package io.element.android.libraries.wellknown.api
import io.element.android.libraries.matrix.api.UrlContentFetcher
interface WellknownRetriever {
suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown>
suspend fun getElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource
): WellknownRetrieverResult<ElementWellKnown>
interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever
}
}
@@ -35,4 +35,11 @@ sealed interface WellknownRetrieverResult<out T> {
is Error -> null
NotFound -> null
}
fun upToDateDataOrNull(): T? = when (this) {
is Success<T> -> data
is Outdated<T> -> null
is Error -> null
NotFound -> null
}
}
@@ -1,109 +0,0 @@
/*
* 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.
*/
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
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.ElementWellKnownParser
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesBinding(SessionScope::class)
class DefaultSessionWellknownRetriever(
private val matrixClient: MatrixClient,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val elementWellknownStore: ElementWellknownStore,
private val elementWellKnownParser: ElementWellKnownParser,
private val enterpriseService: EnterpriseService,
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown()
if (overriddenElementWellKnown != null) {
return WellknownRetrieverResult.Success(overriddenElementWellKnown)
}
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
}
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()
}
}
}
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
return matrixClient
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
val parsed = elementWellKnownParser.parse(data).getOrThrow()
// Also store in cache, if valid
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()
}
private fun <T> Result<T>.toWellknownRetrieverResult(): WellknownRetrieverResult<T> = fold(
onSuccess = {
WellknownRetrieverResult.Success(it)
},
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.
it.toWellknownRetrieverResult()
}
)
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)
}
}
}
@@ -8,6 +8,9 @@
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
@@ -18,12 +21,19 @@ 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)
@AssistedInject
class DefaultWellKnownStore(
@Assisted
private val prefix: String?,
private val cacheStore: CacheStore,
private val elementWellKnownParser: ElementWellKnownParser,
private val systemClock: SystemClock,
) : ElementWellknownStore {
@AssistedFactory
@ContributesBinding(AppScope::class)
fun interface Factory : ElementWellknownStore.Factory {
override fun create(prefix: String?): DefaultWellKnownStore
}
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
return runCatchingExceptions {
val cachedData = cacheStore.getData(key(domain))
@@ -54,7 +64,11 @@ class DefaultWellKnownStore(
}
}
private fun key(domain: String): String = "https://$domain/.well-known/element/element.json"
private fun key(domain: String): String = if (prefix != null) {
"$prefix-$domain"
} else {
"https://$domain/.well-known/element/element.json"
}
companion object {
// 1 day
@@ -9,65 +9,134 @@
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.androidutils.text.takeIfNotBlank
import io.element.android.libraries.core.extensions.mapCatchingExceptions
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.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import retrofit2.HttpException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.net.HttpURLConnection
import java.net.URL
@ContributesBinding(AppScope::class)
@AssistedInject
class DefaultWellknownRetriever(
private val retrofitFactory: RetrofitFactory,
private val elementWellknownStore: ElementWellknownStore,
private val jsonProvider: JsonProvider,
private val elementWellknownStoreFactory: ElementWellknownStore.Factory,
private val enterpriseService: EnterpriseService,
private val elementWellKnownParser: ElementWellKnownParser,
@Assisted private val urlContentFetcher: UrlContentFetcher,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : WellknownRetriever {
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
@ContributesBinding(AppScope::class)
@AssistedFactory
fun interface Factory : WellknownRetriever.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): DefaultWellknownRetriever
}
override suspend fun getElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource
): WellknownRetrieverResult<ElementWellKnown> {
val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown()
if (overriddenElementWellKnown != null) {
return WellknownRetrieverResult.Success(overriddenElementWellKnown)
}
val domain = URL(baseUrl).host
return buildWellknownApi(baseUrl)
.map { wellknownApi ->
try {
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")
if ((e as? HttpException)?.code() == HttpURLConnection.HTTP_NOT_FOUND) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(e)
}
}
// The passed `host` function may be a full URL, in that case we're only interested in the actual host
val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() }
.getOrElse { return WellknownRetrieverResult.Error(IllegalArgumentException("host parameter is not valid", it)) }
?: host
// We instantiate different stores for different sources, so that we can cache them separately.
// The ESS_CONFIG source is cached with a prefix to differentiate it from the WELLKNOWN_ENDPOINT.
val prefix = when (source) {
EnterpriseRemoteConfigSource.ESS_CONFIG -> "ess_config"
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> null
}
val store = elementWellknownStoreFactory.create(prefix)
return when (val cacheData = store.get(checkedHost)) {
is WellknownRetrieverResult.Success -> {
Timber.d("Using cached well-known for domain $checkedHost")
cacheData
}
.fold(
onSuccess = { it },
onFailure = { WellknownRetrieverResult.Error(it as Exception) }
)
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 $checkedHost, returning existing value and fetching new one from network")
coroutineScope.launch { fetchElementWellKnown(checkedHost, source, store) }
cacheData
}
is WellknownRetrieverResult.NotFound -> {
// Try to fetch from the server
Timber.d("No cached well-known for domain $checkedHost, fetching from network")
fetchElementWellKnown(checkedHost, source, store)
}
is WellknownRetrieverResult.Error -> {
// Return the error
Timber.e(cacheData.exception, "Error retrieving well-known for domain $checkedHost")
cacheData.exception.toWellknownRetrieverResult()
}
}
}
private fun buildWellknownApi(accountProviderUrl: String): Result<WellknownAPI> {
return runCatchingExceptions {
retrofitFactory.create(accountProviderUrl.ensureProtocol())
.create(WellknownAPI::class.java)
}.onFailure { e ->
// If the base URL is not valid, we cannot retrieve the well-known data
Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl")
private suspend fun fetchElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource,
store: ElementWellknownStore
): WellknownRetrieverResult<ElementWellKnown> {
return urlContentFetcher
.getUrl(url(host, source))
.mapCatchingExceptions {
val data = String(it)
val parsed = elementWellKnownParser.parse(data).getOrThrow()
// Also store in cache, if valid
store.update(host, data)
.onFailure { exception ->
Timber.e(exception, "Failed to parse cached Element .well-known data for $host, deleting cache")
store.delete(host)
}
parsed
}
.toWellknownRetrieverResult(host)
}
private fun <T> Result<T>.toWellknownRetrieverResult(host: String): WellknownRetrieverResult<T> = fold(
onSuccess = {
WellknownRetrieverResult.Success(it)
},
onFailure = {
Timber.e(it, "Failed to retrieve Element .well-known from $host")
// This check on message value is not ideal but this is what we got from the SDK.
it.toWellknownRetrieverResult()
}
)
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)
}
}
private fun url(host: String, source: EnterpriseRemoteConfigSource): String {
val elementWellKnownUrl = "https://$host/.well-known/element/element.json"
return when (source) {
EnterpriseRemoteConfigSource.ESS_CONFIG -> enterpriseService.essConfigEndpointUrl(host) ?: elementWellKnownUrl
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> elementWellKnownUrl
}
}
}
@@ -0,0 +1,41 @@
/*
* 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.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetriever
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(SessionScope::class)
object SessionWellknownModule {
@Provides
fun provideWellKnownRetriever(
elementWellknownStoreFactory: ElementWellknownStore.Factory,
enterpriseService: EnterpriseService,
elementWellKnownParser: ElementWellKnownParser,
urlContentFetcher: UrlContentFetcher,
@SessionCoroutineScope coroutineScope: CoroutineScope,
): WellknownRetriever {
return DefaultWellknownRetriever(
elementWellknownStoreFactory = elementWellknownStoreFactory,
enterpriseService = enterpriseService,
elementWellKnownParser = elementWellKnownParser,
urlContentFetcher = urlContentFetcher,
coroutineScope = coroutineScope
)
}
}
@@ -1,376 +0,0 @@
/*
* 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.enterprise.test.FakeEnterpriseService
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.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.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultSessionWellknownRetrieverTest {
@Test
fun `get empty element wellknown`() = runTest {
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {
Result.success("{}".toByteArray())
}
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = getUrlLambda,
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = null,
enforceElementPro = null,
rageshakeUrl = null,
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
forceDisableE2EE = null,
)
)
)
getUrlLambda.assertions().isCalledOnce()
.with(value("https://user.domain.org/.well-known/element/element.json"))
}
@Test
fun `get element wellknown with full content`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
WELLKNOWN_CONTENT.toByteArray()
)
}
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
}
@Test
fun `get element wellknown with unknown key`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"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().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
forceDisableE2EE = null,
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": 8
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).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 = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with zero min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": 0
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with negative min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": -5
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown json error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"registration_helper_url" = "a_registration_url",
error
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown network error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(AN_EXCEPTION)
}
)
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 = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
)
)
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
}
@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 = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success("{}".toByteArray())
},
cacheStore = cacheStore,
elementWellKnownParser = { Result.failure(IllegalStateException("Failed to parse JSON")) }
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
// Ensure that the cache is deleted after the failure to parse it
assertThat(cacheStore.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 = cacheStore,
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Outdated(
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",
forceDisableE2EE = false,
)
)
)
// Next call returns the updated value
runCurrent()
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown()
)
)
}
@Test
fun `get element wellknown was overridden`() = runTest {
val getLambda = lambdaRecorder<String, Result<ByteArray>> { Result.failure(IllegalStateException("BOOM")) }
val wellKnown = anElementWellKnown()
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = getLambda,
enterpriseService = FakeEnterpriseService(
overrideWellKnownResult = { wellKnown }
)
)
// The overridden value is returned
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(wellKnown)
)
// And the endpoint is never hit
getLambda.assertions().isNeverCalled()
}
private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString<InternalElementWellKnown>(WELLKNOWN_CONTENT).map()
private fun TestScope.createDefaultSessionWellknownRetriever(
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
getUrlLambda: (String) -> Result<ByteArray>,
elementWellKnownParser: ElementWellKnownParser = DefaultElementWellKnownParser(DefaultJsonProvider()),
enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }),
) = DefaultSessionWellknownRetriever(
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { "user.domain.org" },
getUrlLambda = getUrlLambda,
),
sessionCoroutineScope = backgroundScope,
elementWellknownStore = cacheStore,
elementWellKnownParser = elementWellKnownParser,
enterpriseService = enterpriseService,
)
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",
"force_disable_e2ee": false,
}"""
}
}
@@ -148,11 +148,25 @@ class DefaultWellKnownStoreTest {
assertThat(result).isEqualTo(WellknownRetrieverResult.Success(wellKnown.map()))
}
@Test
fun `prefix is used`() = runTest {
val prefix = "test-prefix"
val clock = FakeSystemClock(epochMillisResult = A_FAKE_TIMESTAMP)
val cacheStore = InMemoryCacheStore(
initialData = mapOf("$prefix-$A_DOMAIN" to CacheData("{}", A_FAKE_TIMESTAMP))
)
val sut = createDefaultWellKnownStore(prefix = prefix, cacheStore = cacheStore, systemClock = clock)
assertThat(sut.get(A_DOMAIN)).isInstanceOf(WellknownRetrieverResult.Success::class.java)
}
private fun createDefaultWellKnownStore(
prefix: String? = null,
cacheStore: InMemoryCacheStore = InMemoryCacheStore(),
systemClock: FakeSystemClock = FakeSystemClock(),
elementWellKnownParser: ElementWellKnownParser = DefaultElementWellKnownParser(jsonProvider),
) = DefaultWellKnownStore(
prefix = prefix,
cacheStore = cacheStore,
systemClock = systemClock,
elementWellKnownParser = elementWellKnownParser,
@@ -14,275 +14,184 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
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.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import okhttp3.Call
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okio.IOException
import org.junit.Test
import java.net.PortUnreachableException
import java.net.URL
class DefaultWellknownRetrieverTest {
@Test
fun `get empty element wellknown`() = runTest {
val mockCall = mockCall(defaultResponse(body = "{}"))
fun `get element wellknown calls resolver and parser`() = runTest {
val parser = lambdaRecorder<String, Result<ElementWellKnown>> { Result.success(anElementWellKnown()) }
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall },
elementWellKnownParser = parser,
urlResolver = resolver,
)
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,
forceDisableE2EE = null,
)
)
)
verify(exactly = 1) { mockCall.enqueue(any()) }
sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)
resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL))
parser.assertions().isCalledOnce().with(value(WELLKNOWN_CONTENT))
}
@Test
fun `get element wellknown with full content`() = runTest {
fun `get element wellknown parser error`() = runTest {
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse()) }
elementWellKnownParser = { Result.failure(IllegalStateException("Invalid JSON")) },
)
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",
forceDisableE2EE = false,
)
)
)
}
@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,
forceDisableE2EE = 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)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.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)
val resolver = SpyUrlContentFetcher { Result.failure(IllegalStateException("Network error")) }
val sut = createDefaultWellknownRetriever(urlResolver = resolver)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.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)
val resolver = SpyUrlContentFetcher { Result.failure(ClientException.Generic("Http error: 404", null)) }
val sut = createDefaultWellknownRetriever(urlResolver = resolver)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.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"))
)
)
fun `get element wellknown will return an error if the provided host is not valid`() = runTest {
val sut = createDefaultWellknownRetriever()
assertThat(sut.getElementWellKnown("!not valid", EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown will return a cached value if present instead of fetching`() = runTest {
val host = URL(WELLKNOWN_URL).host.ensureProtocol()
val sut = createDefaultWellknownRetriever(
callFactory = { mockCall(defaultResponse(body = "invalid json")) },
cacheStore = cacheStore,
jsonProvider = JsonProvider { error("Failed to parse JSON") }
cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Success(expectedElementWellKnown))) }
)
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)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Success::class.java)
}
@Test
fun `get element wellknown will return an outdated value first then fetch the current one`() = runTest {
val host = URL(WELLKNOWN_URL).host.ensureProtocol()
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
urlResolver = resolver,
cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Outdated(expectedElementWellKnown))) }
)
// The first call returns the outdated value
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Outdated::class.java)
// The resolver is not called yet because the fetch is done in the background
resolver.assertions().isNeverCalled()
// We give it some time to run the background fetch
runCurrent()
// The resolver is called during the background fetch
resolver.assertions().isCalledOnce().with(value(WELLKNOWN_URL))
}
@Test
fun `get element wellknown will return an error if it happens when trying to retrieve the cached value`() = runTest {
val host = URL(WELLKNOWN_URL).host.ensureProtocol()
val sut = createDefaultWellknownRetriever(
cacheStoreFactory = { FakeElementWellknownStore(initialData = mapOf(host to WellknownRetrieverResult.Error(IllegalStateException("BOOM")))) }
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown will return an error if it fails to serialize the retrieved value into the cache`() = runTest {
val sut = createDefaultWellknownRetriever(
elementWellKnownParser = { Result.failure(IllegalStateException("BOOM")) },
)
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT))
.isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `WELLKNOWN_ENDPOINT source creates a store with no prefix`() = runTest {
val storeFactory = lambdaRecorder<String?, ElementWellknownStore> { FakeElementWellknownStore() }
val sut = createDefaultWellknownRetriever(cacheStoreFactory = storeFactory)
sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)
storeFactory.assertions().isCalledOnce().with(value(null))
}
@Test
fun `ESS_CONFIG source creates a store with ess prefix and checks the ESS config endpoint`() = runTest {
val essConfigUrl = "https://ess-config.example.com"
val storeFactory = lambdaRecorder<String?, ElementWellknownStore> { FakeElementWellknownStore() }
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
cacheStoreFactory = storeFactory,
enterpriseService = FakeEnterpriseService(
overrideWellKnownResult = { null },
essConfigEndpointUrlResult = { essConfigUrl },
),
urlResolver = resolver,
)
sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.ESS_CONFIG)
storeFactory.assertions().isCalledOnce().with(value("ess_config"))
resolver.assertions().isCalledOnce().with(value(essConfigUrl))
}
@Test
fun `get element wellknown was overridden`() = runTest {
val getLambda = lambdaRecorder<Request, Call> { mockCall() }
val wellKnown = anElementWellKnown()
val resolver = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) }
val sut = createDefaultWellknownRetriever(
callFactory = getLambda,
enterpriseService = FakeEnterpriseService(
overrideWellKnownResult = { wellKnown }
)
overrideWellKnownResult = { wellKnown },
),
urlResolver = resolver,
)
// The overridden value is returned
assertThat(sut.getElementWellKnown(WELLKNOWN_URL)).isEqualTo(
assertThat(sut.getElementWellKnown(WELLKNOWN_URL, EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT)).isEqualTo(
WellknownRetrieverResult.Success(wellKnown)
)
// And the endpoint is never hit
getLambda.assertions().isNeverCalled()
resolver.assertions().isNeverCalled()
}
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(),
private fun TestScope.createDefaultWellknownRetriever(
cacheStoreFactory: ElementWellknownStore.Factory = { FakeElementWellknownStore() },
elementWellKnownParser: ElementWellKnownParser = { Result.success(expectedElementWellKnown) },
enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }),
urlResolver: UrlContentFetcher = SpyUrlContentFetcher { Result.success(WELLKNOWN_CONTENT.toByteArray()) },
) = DefaultWellknownRetriever(
retrofitFactory = RetrofitFactory(
callFactory = { callFactory },
json = { jsonProvider }
),
jsonProvider = jsonProvider,
elementWellknownStore = cacheStore,
elementWellknownStoreFactory = cacheStoreFactory,
enterpriseService = enterpriseService,
elementWellKnownParser = elementWellKnownParser,
urlContentFetcher = urlResolver,
coroutineScope = backgroundScope,
)
companion object {
@@ -297,24 +206,27 @@ class DefaultWellknownRetrieverTest {
"content_scanner_url": "https://content-scanner.example.com",
"force_disable_e2ee": false
}"""
private val expectedElementWellKnown = ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
contentScannerUrl = "https://content-scanner.example.com",
customRecoveryPassphrase = null,
forceDisableE2EE = false,
)
}
}
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")
private class SpyUrlContentFetcher(lambda: (String) -> Result<ByteArray>) : UrlContentFetcher {
private val spy = lambdaRecorder<String, Result<ByteArray>> { lambda(it) }
override suspend fun getUrl(url: String): Result<ByteArray> {
return spy(url)
}
fun assertions() = spy.assertions()
}
@@ -17,6 +17,7 @@ android {
dependencies {
implementation(libs.serialization.json)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.wellknown.api)
implementation(projects.libraries.wellknown.impl)
implementation(projects.tests.testutils)
@@ -1,22 +0,0 @@
/*
* 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.
*/
package io.element.android.features.wellknown.test
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.simulateLongTask
class FakeSessionWellknownRetriever(
private val getElementWellKnownResult: () -> WellknownRetrieverResult<ElementWellKnown> = { WellknownRetrieverResult.NotFound },
) : SessionWellknownRetriever {
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> = simulateLongTask {
getElementWellKnownResult()
}
}
@@ -9,14 +9,16 @@
package io.element.android.features.wellknown.test
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.simulateLongTask
class FakeWellknownRetriever(
private val getElementWellKnownResult: (String) -> WellknownRetrieverResult<ElementWellKnown> = { WellknownRetrieverResult.NotFound },
private val getElementWellKnownResult: (String, EnterpriseRemoteConfigSource) -> WellknownRetrieverResult<ElementWellKnown> = { _, _ ->
WellknownRetrieverResult.NotFound
},
) : WellknownRetriever {
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> = simulateLongTask {
getElementWellKnownResult(baseUrl)
override suspend fun getElementWellKnown(host: String, source: EnterpriseRemoteConfigSource): WellknownRetrieverResult<ElementWellKnown> {
return getElementWellKnownResult(host, source)
}
}
@@ -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.features.wellknown.test
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.wellknown.api.WellknownRetriever
class FakeWellknownRetrieverFactory(
private val wellknownRetriever: WellknownRetriever = FakeWellknownRetriever(),
) : WellknownRetriever.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever = wellknownRetriever
}