mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +00:00
Merge branch 'develop' into fix/5794-account-data-api
This commit is contained in:
+2
@@ -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 }
|
||||
|
||||
+114
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+123
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,5 @@ dependencies {
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testCommonDependencies(libs)
|
||||
testCommonDependencies(libs, includeTestComposeView = true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+72
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-6
@@ -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.
|
||||
*/
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
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
|
||||
+15
@@ -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>
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* 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>
|
||||
}
|
||||
+2
@@ -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('/')}"
|
||||
|
||||
+1
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
|
||||
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
|
||||
+9
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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
|
||||
}
|
||||
|
||||
+51
-32
@@ -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()
|
||||
|
||||
+13
-14
@@ -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(
|
||||
|
||||
+36
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-7
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
|
||||
+3
-2
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-20
@@ -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)
|
||||
}
|
||||
+3
-2
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)
|
||||
)
|
||||
+1
-2
@@ -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
|
||||
|
||||
+29
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
+4
-3
@@ -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),
|
||||
|
||||
+84
@@ -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,
|
||||
)
|
||||
}
|
||||
+24
@@ -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(),
|
||||
|
||||
+10
@@ -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)
|
||||
}
|
||||
|
||||
+1
@@ -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()
|
||||
}
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+6
@@ -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?> {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.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()
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.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)
|
||||
}
|
||||
+9
-7
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-18
@@ -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>
|
||||
|
||||
@@ -12,3 +12,7 @@ plugins {
|
||||
android {
|
||||
namespace = "io.element.android.libraries.wellknown.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
||||
+4
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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,
|
||||
}
|
||||
-13
@@ -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>
|
||||
}
|
||||
+10
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-109
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -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
|
||||
|
||||
+106
-37
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
-376
@@ -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,
|
||||
}"""
|
||||
}
|
||||
}
|
||||
+14
@@ -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,
|
||||
|
||||
+148
-236
@@ -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)
|
||||
|
||||
-22
@@ -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()
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.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
|
||||
}
|
||||
Reference in New Issue
Block a user