Merge branch 'develop' into fix/2614-linkify-ipv6-urls

This commit is contained in:
Benoit Marty
2026-08-12 13:48:36 +02:00
committed by GitHub
739 changed files with 6362 additions and 3368 deletions
@@ -50,6 +50,11 @@ object LinkifyHelper {
val start = spannable.getSpanStart(urlSpan)
val end = spannable.getSpanEnd(urlSpan)
if (urlSpan !in oldURLSpans && spannable.isEmailInsideFediverseHandle(urlSpan, start)) {
spannable.removeSpan(urlSpan)
continue
}
// Try to avoid including trailing punctuation in the link.
// Since this might fail in some edge cases, we catch the exception and just use the original end index.
val newEnd = runCatchingExceptions {
@@ -108,6 +113,10 @@ object LinkifyHelper {
return host.startsWith('[') && host.endsWith(']')
}
private fun Spannable.isEmailInsideFediverseHandle(urlSpan: URLSpan, start: Int): Boolean {
return urlSpan.url.startsWith("mailto:") && start > 0 && this[start - 1] == '@'
}
private fun adjustLinkifiedUrlSpanEndIndex(spannable: Spannable, start: Int, end: Int): Int {
var end = end
@@ -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 }
@@ -141,6 +141,45 @@ class LinkifierHelperTest : RobolectricTest() {
assertThat(urlSpans.first().url).isEqualTo("tel:1234")
}
@Test
fun `linkification ignores fediverse handle`() {
val text = "Follow me at @name@server.tld"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans).isEmpty()
}
@Test
fun `linkification ignores fediverse handle at the start of the text`() {
val text = "@name@server.tld posted this"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans).isEmpty()
}
@Test
fun `linkification finds email next to a fediverse handle`() {
val text = "@name@server.tld and john@doe.com"
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com")
}
@Test
fun `linkification keeps an existing mailto span preceded by an at sign`() {
val text = buildSpannedString {
append("Mail me @")
inSpans(URLSpan("mailto:john@doe.com")) {
append("john@doe.com")
}
}
val result = LinkifyHelper.linkify(text)
val urlSpans = result.toSpannable().getSpans<URLSpan>()
assertThat(urlSpans.size).isEqualTo(1)
assertThat(urlSpans.first().url).isEqualTo("mailto:john@doe.com")
}
@Test
fun `linkification handles trailing dot`() {
val text = "A url https://matrix.org."
@@ -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
}
}
}
@@ -200,11 +200,15 @@ internal fun WaveformPlaybackViewPreview() = ElementPreview {
}
}
private fun ImmutableList<Float>.normalisedData(maxSamplesCount: Int): ImmutableList<Float> {
internal fun ImmutableList<Float>.normalisedData(maxSamplesCount: Int): ImmutableList<Float> {
if (maxSamplesCount <= 0) {
return persistentListOf()
}
if (isEmpty()) {
return List(maxSamplesCount) { 0f }.toImmutableList()
}
// Filter the data to keep only the expected number of samples
val result = if (this.size > maxSamplesCount) {
(0..<maxSamplesCount)
@@ -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
}
}
@@ -0,0 +1,40 @@
/*
* 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.designsystem.components.media
import com.google.common.truth.Truth.assertThat
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class WaveformNormalisationTest {
@Test
fun `an empty waveform is padded to the requested number of samples`() {
val result = persistentListOf<Float>().normalisedData(30)
assertThat(result).hasSize(30)
assertThat(result.toSet()).containsExactly(0f)
}
@Test
fun `an empty waveform stays empty when there is no room to display it`() {
assertThat(persistentListOf<Float>().normalisedData(0)).isEmpty()
assertThat(persistentListOf<Float>().normalisedData(-1)).isEmpty()
}
@Test
fun `a waveform shorter than the display count is kept as it is`() {
val waveform = persistentListOf(0.1f, 0.2f, 0.3f)
assertThat(waveform.normalisedData(30)).containsExactly(0.1f, 0.2f, 0.3f).inOrder()
}
@Test
fun `a waveform longer than the display count is downsampled`() {
val waveform = persistentListOf(0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f)
assertThat(waveform.normalisedData(3)).hasSize(3)
}
}
+1 -1
View File
@@ -36,5 +36,5 @@ dependencies {
implementation(libs.matrix.emojibase.bindings)
testImplementation(projects.libraries.matrix.test)
testCommonDependencies(libs)
testCommonDependencies(libs, includeTestComposeView = true)
}
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
Column(modifier) {
SearchBar(
modifier = Modifier.padding(bottom = 10.dp),
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
queryState = state.searchQuery,
resultState = state.searchResults,
active = state.isSearchActive,
@@ -0,0 +1,72 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.libraries.emoji.impl.picker
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsEqualTo
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.hasScrollToIndexAction
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
import org.junit.Test
import org.robolectric.annotation.Config
private val PICKER_HEIGHT = 600.dp
class EmojiPickerViewTest : RobolectricTest() {
@Config(qualifiers = "w400dp-h800dp")
@Test
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
setEmojiPickerView()
onNode(hasScrollToIndexAction())
.getBoundsInRoot()
.bottom
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
setContent {
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
EmojiPickerView(
state = aDefaultEmojiPickerState(
isSearchActive = true,
searchQuery = "smile",
searchResults = SearchBarResultState.Results(
persistentListOf(
Emoji(
hexcode = "0x00",
label = "grinning face",
tags = persistentListOf("grinning"),
shortcodes = persistentListOf("smile"),
unicode = "😀",
skins = null,
),
)
),
),
onSelectEmoji = {},
selectedEmojis = persistentSetOf(),
modifier = Modifier.fillMaxSize(),
)
}
}
}
@@ -135,7 +135,7 @@ class DefaultRoomLatestEventFormatter(
val message = when (val messageType: MessageType = type) {
// Doesn't need a prefix
is EmoteMessageType -> {
return "* $senderDisambiguatedDisplayName ${messageType.body}"
return "* ${senderDisambiguatedDisplayName.bidiIsolate()} ${messageType.body}"
}
is TextMessageType -> {
messageType.toPlainText(permalinkParser)
@@ -189,7 +189,7 @@ class DefaultRoomLatestEventFormatter(
if (isOutgoing) {
sp.getString(CommonStrings.common_you)
} else {
senderDisambiguatedDisplayName
senderDisambiguatedDisplayName.bidiIsolate()
}
)
}
@@ -14,6 +14,12 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
private const val FIRST_STRONG_ISOLATE = 0x2068
private const val POP_DIRECTIONAL_ISOLATE = 0x2069
/** Wraps the string between Unicode isolate characters so its own direction cannot reorder the text around it. */
internal fun String.bidiIsolate(): String = Char(FIRST_STRONG_ISOLATE) + this + Char(POP_DIRECTIONAL_ISOLATE)
internal fun CharSequence.prefixWith(prefix: String): AnnotatedString {
return buildAnnotatedString {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
@@ -91,7 +91,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
assertThat(result).isEqualTo(expected)
} else {
assertThat(result).isInstanceOf(AnnotatedString::class.java)
assertThat(result.toString()).isEqualTo("$senderName: $expected")
assertThat(result.toString()).isEqualTo("⁨$senderName⁩: $expected")
}
}
}
@@ -103,13 +103,13 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
val info = ImageInfo(null, null, null, null, null, null, null)
val message = createLatestEvent(false, null, aStickerContent(body, info, aMediaSource(url = "url")))
val result = formatter.format(message, false)
val expectedBody = someoneElseId.value + ": Sticker: a sticker body"
val expectedBody = "⁨" + someoneElseId.value + "⁩: Sticker: a sticker body"
// Check we have formatting
assertThat(result is AnnotatedString).isTrue()
// And there is a bold span for the 'Sticker' part
val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold }
assertThat(boldSpanStyle).isNotNull()
val spanStart = someoneElseId.value.length + 2
val spanStart = someoneElseId.value.length + 4
assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 7)
assertThat(result.toString()).isEqualTo(expectedBody)
}
@@ -130,7 +130,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
assertThat(result).isEqualTo(expected)
} else {
assertThat(result).isInstanceOf(AnnotatedString::class.java)
assertThat(result.toString()).isEqualTo("$senderName: $expected")
assertThat(result.toString()).isEqualTo("⁨$senderName⁩: $expected")
}
}
}
@@ -152,12 +152,29 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
assertWithMessage("$type was not properly handled").that(result).isEqualTo(expected)
} else {
assertWithMessage("$type does not create an AnnotatedString").that(result).isInstanceOf(AnnotatedString::class.java)
assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("$senderName: $expected")
assertWithMessage("$type was not properly handled").that(result.toString()).isEqualTo("⁨$senderName⁩: $expected")
}
}
}
}
@Test
@Config(qualifiers = "en")
fun `a right to left sender name is isolated so it cannot reorder the preview`() {
val senderName = "مرحبا"
val body = "Hello"
val message = createLatestEvent(
sentByYou = false,
senderDisplayName = senderName,
content = MessageContent(body, null, false, null, TextMessageType(body, null)),
)
val result = formatter.format(message, false)
assertThat(result.toString()).isEqualTo("⁨$senderName⁩: $body")
val boldSpanStyle = (result as AnnotatedString).spanStyles.first { it.item.fontWeight == FontWeight.Bold }
assertThat(boldSpanStyle.start).isEqualTo(0)
assertThat(boldSpanStyle.end).isEqualTo(senderName.length + 2)
}
// region Message contents
@Test
@@ -166,7 +183,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
testMessageContents(
sentByYou = false,
senderName = "Alice",
expectedPrefix = "Alice",
expectedPrefix = "⁨Alice⁩",
)
}
@@ -233,7 +250,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is StickerMessageType -> "Sticker: Shared body"
is FileMessageType -> "File: Shared body"
is LocationMessageType -> "Shared location"
is EmoteMessageType -> "* $senderName ${type.body}"
is EmoteMessageType -> "* ⁨$senderName⁩ ${type.body}"
is TextMessageType,
is NoticeMessageType,
is OtherMessageType -> body
@@ -275,7 +292,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
is TextMessageType,
is NoticeMessageType,
is OtherMessageType -> "$expectedPrefix: $body"
is EmoteMessageType -> "* $senderName ${type.body}"
is EmoteMessageType -> "* ⁨$senderName⁩ ${type.body}"
}
val shouldCreateAnnotatedString = when (type) {
is VideoMessageType -> true
@@ -944,7 +961,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
assertThat(formatter.format(mineContentEvent, false).toString()).isEqualTo("You: Poll: Do you like polls?")
val contentEvent = createLatestEvent(sentByYou = false, senderDisplayName = "Bob", content = pollContent)
assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("Bob: Poll: Do you like polls?")
assertThat(formatter.format(contentEvent, false).toString()).isEqualTo("⁨Bob⁩: Poll: Do you like polls?")
val result = formatter.format(contentEvent, false)
// Check we have formatting
@@ -952,7 +969,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
// And there is a bold span for the 'Poll' part
val boldSpanStyle = (result as AnnotatedString).spanStyles.lastOrNull { it.item.fontWeight == FontWeight.Bold }
assertThat(boldSpanStyle).isNotNull()
val spanStart = "Bob".length + 2
val spanStart = "Bob".length + 4
assertThat(boldSpanStyle!!.start..boldSpanStyle.end).isEqualTo(spanStart..spanStart + 4)
}
@@ -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.
*/
@@ -210,6 +211,16 @@ interface MatrixClient {
*/
suspend fun addRecentEmoji(emoji: String): Result<Unit>
/**
* Returns the raw JSON content of the global account data event of type [eventType], or null if it is not set.
*/
suspend fun getAccountData(eventType: String): Result<String?>
/**
* Sets the global account data event of type [eventType] to the raw JSON [content].
*/
suspend fun setAccountData(eventType: String, content: String): Result<Unit>
/**
* Marks the room with the provided [roomId] as read, sending a fully read receipt for [eventId].
*
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api
import java.lang.AutoCloseable
/**
* A Matrix client in an 'unauthenticated' state that can still be used to resolve URLs.
*
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
* with it, such as temporary files.
*/
interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable
@@ -0,0 +1,15 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api
/**
* Factory to create a temporary [TemporaryMatrixClient] for a given home server URL.
*/
interface TemporaryMatrixClientFactory {
suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient>
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api
/**
* An interface to fetch content from a URL using a simple GET request.
*/
fun interface UrlContentFetcher {
/**
* Execute generic GET requests through the SDKs internal HTTP client.
*/
suspend fun getUrl(url: String): Result<ByteArray>
}
@@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.BuildConfig
object OAuthConfig {
// This *can* be null if the build time config field is not set
@Suppress("UNNECESSARY_SAFE_CALL")
val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let {
// Concatenate the base URI and the path, ensuring there is exactly one '/' between them
"${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}"
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
userId = userId,
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
displayedStatus = displayedStatus,
)
@@ -7,12 +7,19 @@
package io.element.android.libraries.matrix.api.scanner
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
/**
* Provides the URL of the content scanner service for a given homeserver, if any is set up.
*/
fun interface ContentScannerUrlProvider {
/**
* Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up.
* Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up.
*/
suspend fun getContentScannerUrl(homeserver: String): Result<String?>
suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?>
fun interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider
}
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
@Immutable
sealed interface ProfileDetails {
@@ -60,3 +61,20 @@ fun ProfileDetails.getAvatarUrl(): String? {
else -> null
}
}
fun ProfileDetails.toMatrixUser(userId: UserId): MatrixUser {
return when (this) {
is ProfileDetails.Ready -> MatrixUser(
userId = userId,
displayName = displayName,
avatarUrl = avatarUrl,
displayedStatus = displayedStatus,
)
else -> MatrixUser(
userId = userId,
displayName = null,
avatarUrl = null,
displayedStatus = null,
)
}
}
@@ -13,6 +13,9 @@ import kotlinx.parcelize.Parcelize
@Immutable
sealed interface DisplayedStatus : Parcelable {
/** Useful for adding extension methods. */
companion object
/** Status set manually by the user via m.status. */
@Parcelize
data class UserSet(val status: UserStatus) : DisplayedStatus
@@ -21,6 +24,6 @@ sealed interface DisplayedStatus : Parcelable {
@Parcelize
data class InCall(
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
val callJoinedTs: Long,
val callJoinedTs: Long?,
) : DisplayedStatus
}
@@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.api.spaces.SpaceService
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -58,7 +57,6 @@ import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
@@ -85,7 +83,9 @@ import io.element.android.libraries.matrix.impl.search.RustMessageSearchService
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.user.into
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@@ -126,9 +126,11 @@ import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.Membership
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.PowerLevels
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.RoomInfoListener
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.UserProfile
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import java.io.File
@@ -271,8 +273,6 @@ class RustMatrixClient(
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
@Volatile private var localUserStatus: UserStatus? = null
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
@@ -283,6 +283,14 @@ class RustMatrixClient(
override val userProfile: StateFlow<MatrixUser> = _userProfile
private var ownProfileTaskHandle: TaskHandle? = null
private val ownProfileListener = object : ProfileListener {
override fun onUpdate(profile: UserProfile) {
_userProfile.tryEmit(profile.map())
}
}
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
// Fetch the initial value manually, the SDK won't return it automatically
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
@@ -303,8 +311,21 @@ class RustMatrixClient(
sessionCoroutineScope.launch {
// Start notification settings
notificationSettingsService.start()
// Setup user profile logic
setupUserProfile()
}
// Update the user profile in the session store if needed
// Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
}
private suspend fun setupUserProfile() {
val supported = isUserStatusSupported().getOrDefault(false)
if (supported) {
// No need to seed the data here, it's already stored by the sdk.
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
} else {
// Seed from the session store so cold-start UI has something to render before the network fetch.
sessionStore.getSession(sessionId.value)?.let { sessionData ->
_userProfile.emit(
MatrixUser(
@@ -314,12 +335,8 @@ class RustMatrixClient(
)
)
}
// Force a refresh of the profile
getUserProfile()
}
// Schedule regular database vacuuming to ensure DB performance remains optimal
scheduleDatabaseVacuum()
}
override fun userIdServerName(): String {
@@ -458,13 +475,8 @@ class RustMatrixClient(
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { matrixUser ->
_userProfile.emit(
matrixUser.copy(
rawStatus = localUserStatus,
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
)
)
// Also update our session storage
_userProfile.emit(matrixUser)
sessionStore.updateUserProfile(
sessionId = sessionId.value,
displayName = matrixUser.displayName,
@@ -495,25 +507,31 @@ class RustMatrixClient(
}
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = status
_userProfile.emit(
_userProfile.value.copy(
rawStatus = status,
displayedStatus = DisplayedStatus.UserSet(status),
)
)
Result.success(Unit)
runCatchingExceptions {
innerClient.setUserStatus(status.into())
}.onSuccess {
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
if (ownProfileTaskHandle == null) getUserProfile()
}
}
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
localUserStatus = null
_userProfile.emit(
_userProfile.value.copy(
rawStatus = null,
displayedStatus = null,
)
)
Result.success(Unit)
runCatchingExceptions {
innerClient.clearUserStatus()
}.onSuccess {
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
if (ownProfileTaskHandle == null) getUserProfile()
}
}
override suspend fun isUserStatusSupported(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isUserStatusSupported()
}
}
override fun enableAutomaticCallStatus(enabled: Boolean) {
innerClient.enableAutomaticCallStatus(enabled)
}
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
@@ -621,6 +639,7 @@ class RustMatrixClient(
sessionCoroutineScope.cancel()
clientDelegateTaskHandle?.cancelAndDestroy()
ownProfileTaskHandle?.cancelAndDestroy()
sessionVerificationService.destroy()
sessionDelegate.clearCurrentClient()
@@ -806,6 +825,18 @@ class RustMatrixClient(
}
}
override suspend fun getAccountData(eventType: String): Result<String?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.accountData(eventType)
}
}
override suspend fun setAccountData(eventType: String, content: String): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.setAccountData(eventType, content)
}
}
override suspend fun canLinkNewDevice(): Result<Boolean> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.isLoginWithQrCodeSupported()
@@ -17,7 +17,7 @@ import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
@@ -70,7 +70,7 @@ class RustMatrixClientFactory(
private val clientBuilderProvider: ClientBuilderProvider,
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
private val workManagerScheduler: WorkManagerScheduler,
private val contentScannerUrlProvider: ContentScannerUrlProvider,
private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory,
) {
private val sessionDelegate = RustClientSessionDelegate(
sessionStore = sessionStore,
@@ -135,26 +135,25 @@ class RustMatrixClientFactory(
client.setUtdDelegate(UtdTracker(analyticsService))
val domainName = UserId(client.userId()).domainName
// If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client.
// This allows the SDK to use the content scanner for automatic media scanning.
// If no content scanner URL is available, the contentScanner will be null.
val contentScanner = domainName?.let {
contentScannerUrlProvider.getContentScannerUrl(domainName)
.getOrNull()
?.let { contentScannerUrl ->
val contentScanner = ContentScanner(contentScannerUrl)
client.setContentScanner(contentScanner)
RustContentScanner(
client = client,
rustScanner = contentScanner,
)
}
val contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null))
val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId()))
.getOrNull()
?.let { contentScannerUrl ->
val contentScanner = ContentScanner(contentScannerUrl)
client.setContentScanner(contentScanner)
RustContentScanner(
client = client,
rustScanner = contentScanner,
)
}
val syncService = client.syncService()
.withSharePos(true)
.withOfflineMode()
.withProfilesExtension()
.finish()
return RustMatrixClient(
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.exception.mapClientException
import org.matrix.rustcomponents.sdk.Client
class RustTemporaryMatrixClient(
private val client: Client,
private val paths: SessionPaths?,
) : TemporaryMatrixClient {
override suspend fun getUrl(url: String): Result<ByteArray> = runCatchingExceptions {
client.getUrl(url)
}.mapFailure { it.mapClientException() }
override fun close() {
client.close()
paths?.let {
if (it.fileDirectory.exists()) {
it.fileDirectory.deleteRecursively()
}
if (it.cacheDirectory.exists()) {
it.cacheDirectory.deleteRecursively()
}
}
}
}
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
@ContributesBinding(AppScope::class)
class RustTemporaryMatrixClientFactory(
private val sessionPathsFactory: SessionPathsFactory,
private val rustMatrixClientFactory: RustMatrixClientFactory,
) : TemporaryMatrixClientFactory {
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> {
return runCatchingExceptions {
val sessionPaths = sessionPathsFactory.create()
val client = rustMatrixClientFactory.getBaseClientBuilder(
sessionPaths = sessionPaths,
clientSecret = null,
slidingSyncType = ClientBuilderSlidingSync.Native,
isMessageSearchAvailable = false,
)
.homeserverUrl(homeServerUrl)
.build()
RustTemporaryMatrixClient(client, sessionPaths)
}
}
}
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
import io.element.android.libraries.matrix.impl.RustTemporaryMatrixClient
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep
@@ -133,9 +134,10 @@ class RustMatrixAuthenticationService(
}
currentClient = client
client.homeserverLoginDetails().map()
}.onFailure {
clear()
clear(destroyClient = true)
}.mapFailure { failure ->
Timber.e(failure, "Failed to set homeserver to $homeserver")
failure.mapAuthenticationException()
@@ -168,7 +170,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { failure ->
@@ -248,7 +250,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}
@@ -271,11 +273,13 @@ class RustMatrixAuthenticationService(
deviceId = null,
additionalScopes = emptyList(),
)
val getUrlResolver = RustTemporaryMatrixClient(client, sessionPaths)
val url = oAuthAuthorizationData.loginUrl()
.let {
enterpriseService.tweakMasUrl(
url = it,
homeserver = client.server() ?: client.homeserver(),
urlContentFetcher = getUrlResolver,
)
}
pendingOAuthAuthorizationData = oAuthAuthorizationData
@@ -335,7 +339,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure { failure ->
@@ -398,7 +402,7 @@ class RustMatrixAuthenticationService(
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
currentClient = null
clear(destroyClient = false)
SessionId(sessionData.userId)
}.mapFailure {
@@ -464,8 +468,10 @@ class RustMatrixAuthenticationService(
.build()
}
private fun clear() {
currentClient?.close()
private fun clear(destroyClient: Boolean) {
if (destroyClient) {
currentClient?.close()
}
currentClient = null
}
@@ -15,6 +15,7 @@ import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@@ -108,4 +109,9 @@ object SessionMatrixModule {
fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? {
return matrixClient.contentScanner
}
@Provides
fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher {
return matrixClient
}
}
@@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListServ
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@@ -16,12 +16,15 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomInfo
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.room.history.map
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
import io.element.android.libraries.matrix.impl.room.tombstone.map
import io.element.android.libraries.matrix.impl.user.from
import io.element.android.libraries.matrix.impl.user.into
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableMap
import org.matrix.rustcomponents.sdk.Membership
@@ -102,7 +105,9 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
fun RoomHero.map(): MatrixUser = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl
avatarUrl = avatarUrl,
displayedStatus = DisplayedStatus.from(status, call),
rawStatus = status?.into(),
)
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
@@ -11,7 +11,9 @@ package io.element.android.libraries.matrix.impl.room.member
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.room.powerlevels.into
import io.element.android.libraries.matrix.impl.user.from
import uniffi.matrix_sdk.RoomMemberRole
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
@@ -30,8 +32,7 @@ object RoomMemberMapper {
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
membershipChangeReason = roomMember.membershipChangeReason,
isServiceMember = roomMember.isServiceMember,
// TODO map this value when available
displayedStatus = null,
displayedStatus = DisplayedStatus.from(roomMember.status, roomMember.call),
)
}
@@ -1,20 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
}
@@ -21,7 +21,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetail
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.impl.core.RustSendHandle
import io.element.android.libraries.matrix.impl.user.from
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -75,8 +77,7 @@ fun RustProfileDetails.map(): ProfileDetails {
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
// TODO map this value when available
displayedStatus = null,
displayedStatus = DisplayedStatus.from(status, call),
)
}
}
@@ -6,9 +6,10 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.mapper
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import org.matrix.rustcomponents.sdk.UserProfile
@@ -16,4 +17,6 @@ fun UserProfile.map() = MatrixUser(
userId = UserId(userId),
displayName = displayName,
avatarUrl = avatarUrl,
rawStatus = status?.into(),
displayedStatus = DisplayedStatus.from(status, call)
)
@@ -6,10 +6,9 @@
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.usersearch
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.impl.mapper.map
import kotlinx.collections.immutable.toImmutableList
import org.matrix.rustcomponents.sdk.SearchUsersResults
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.user
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
import org.matrix.rustcomponents.sdk.UserCall
import org.matrix.rustcomponents.sdk.UserStatus as RustUserStatus
fun UserStatus.into(): RustUserStatus {
return RustUserStatus(emoji = emoji, text = text)
}
fun RustUserStatus.into(): UserStatus {
return UserStatus(emoji = emoji, text = text)
}
fun DisplayedStatus.Companion.from(userStatus: RustUserStatus?, userCall: UserCall?): DisplayedStatus? {
return when {
userStatus != null -> DisplayedStatus.UserSet(userStatus.into())
userCall != null -> DisplayedStatus.InCall(userCall.callJoinedTs?.toLong())
else -> null
}
}
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.FlowId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
import io.element.android.libraries.matrix.api.verification.VerificationRequest
import io.element.android.libraries.matrix.impl.mapper.map
import io.element.android.libraries.matrix.impl.user.map
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
@@ -52,7 +52,7 @@ fun TestScope.createRustMatrixClientFactory(
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) },
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
@@ -67,5 +67,5 @@ fun TestScope.createRustMatrixClientFactory(
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProvider = contentScannerUrlProvider,
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
)
@@ -32,9 +32,9 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
@@ -44,6 +44,9 @@ import org.matrix.rustcomponents.sdk.StoreSizes
import org.matrix.rustcomponents.sdk.UserProfile
import java.io.File
private const val AN_ACCOUNT_DATA_EVENT_TYPE = "org.example.custom"
private const val AN_ACCOUNT_DATA_CONTENT = """{"key":"value"}"""
class RustMatrixClientTest {
@Test
fun `ensure that sessionId and deviceId can be retrieved from the client`() = runTest {
@@ -72,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(
@@ -97,7 +101,7 @@ class RustMatrixClientTest {
),
sessionStore = sessionStore,
)
advanceUntilIdle()
profilePersisted.await()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
@@ -138,6 +142,30 @@ class RustMatrixClientTest {
assertThat(createParameters?.historyVisibilityOverride).isEqualTo(RoomHistoryVisibility.Invited)
}
@Test
fun `getAccountData returns the raw content provided by the client`() = runTest {
val accountDataResult = lambdaRecorder<String, String?> { AN_ACCOUNT_DATA_CONTENT }
val client = createRustMatrixClient(
client = FakeFfiClient(accountDataResult = accountDataResult)
)
assertThat(client.getAccountData(AN_ACCOUNT_DATA_EVENT_TYPE).getOrThrow()).isEqualTo(AN_ACCOUNT_DATA_CONTENT)
accountDataResult.assertions().isCalledOnce().with(value(AN_ACCOUNT_DATA_EVENT_TYPE))
client.destroy()
}
@Test
fun `setAccountData forwards the event type and the raw content to the client`() = runTest {
val setAccountDataResult = lambdaRecorder<String, String, Unit> { _, _ -> }
val client = createRustMatrixClient(
client = FakeFfiClient(setAccountDataResult = setAccountDataResult)
)
assertThat(client.setAccountData(AN_ACCOUNT_DATA_EVENT_TYPE, AN_ACCOUNT_DATA_CONTENT).isSuccess).isTrue()
setAccountDataResult.assertions().isCalledOnce().with(value(AN_ACCOUNT_DATA_EVENT_TYPE), value(AN_ACCOUNT_DATA_CONTENT))
client.destroy()
}
private fun TestScope.createRustMatrixClient(
client: Client = FakeFfiClient(),
sessionStore: SessionStore = InMemorySessionStore(
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
class RustTemporaryMatrixClientFactoryTest {
@Test
fun `create returns a TemporaryMatrixClient`() = runTest {
val sut = createRustTemporaryMatrixClientFactory()
val result = sut.create("https://matrix.org")
assertThat(result.isSuccess).isTrue()
}
@Test
fun `create can fail gracefully`() = runTest {
val sut = createRustTemporaryMatrixClientFactory(
rustMatrixClientFactory = createRustMatrixClientFactory(
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = { throw IllegalStateException("Failed to create client builder") }
)
)
)
val result = sut.create("https://matrix.org")
assertThat(result.isFailure).isTrue()
}
private fun TestScope.createRustTemporaryMatrixClientFactory(
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
rustMatrixClientFactory: RustMatrixClientFactory = createRustMatrixClientFactory(),
): RustTemporaryMatrixClientFactory {
return RustTemporaryMatrixClientFactory(
sessionPathsFactory = sessionPathsFactory,
rustMatrixClientFactory = rustMatrixClientFactory,
)
}
private fun TestScope.createRustMatrixClientFactory(
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
) = RustMatrixClientFactory(
cacheDirectory = cacheDirectory,
appCoroutineScope = backgroundScope,
coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore,
userAgentProvider = SimpleUserAgentProvider(),
proxyProvider = FakeProxyProvider(),
clock = FakeSystemClock(),
analyticsService = FakeAnalyticsService(),
featureFlagService = FakeFeatureFlagService(),
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
clientBuilderProvider = clientBuilderProvider,
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
workManagerScheduler = workManagerScheduler,
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
)
}
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvide
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
@@ -50,6 +51,29 @@ class RustMatrixAuthenticationServiceTest {
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
}
@Test
fun `setHomeserver can fail gracefully and clean up the temporary client`() = runTest {
val closeResult = lambdaRecorder<Unit> {}
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = {
FakeFfiClientBuilder(
buildResult = {
FakeFfiClient(
homeserverLoginDetailsResult = {
throw IllegalStateException("Failed to get homeserver login details")
},
closeResult = closeResult,
)
},
)
},
),
)
assertThat(sut.setHomeserver("matrix.org").isFailure).isTrue()
closeResult.assertions().isCalledOnce()
}
private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
@@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
import org.matrix.rustcomponents.sdk.NotificationSettings
import org.matrix.rustcomponents.sdk.ProfileListener
import org.matrix.rustcomponents.sdk.PusherIdentifiers
import org.matrix.rustcomponents.sdk.PusherKind
import org.matrix.rustcomponents.sdk.RoomDirectorySearch
@@ -55,6 +56,10 @@ class FakeFfiClient(
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
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
@@ -92,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)
}
@@ -114,5 +126,13 @@ class FakeFfiClient(
return homeserverCapabilities
}
override suspend fun accountData(eventType: String): String? = simulateLongTask {
accountDataResult(eventType)
}
override suspend fun setAccountData(eventType: String, content: String) = simulateLongTask {
setAccountDataResult(eventType, content)
}
override fun close() = closeResult()
}
@@ -15,5 +15,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) {
override fun withOfflineMode(): SyncServiceBuilder = this
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
override fun withProfilesExtension(): SyncServiceBuilder = this
override suspend fun finish(): SyncService = FakeFfiSyncService()
}
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.mapper
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.map
import io.element.android.libraries.matrix.test.A_USER_ID
import org.junit.Test
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
import io.element.android.libraries.matrix.test.A_USER_ID
import kotlinx.collections.immutable.toImmutableList
import org.junit.Test
@@ -124,12 +124,15 @@ class FakeMatrixClient(
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
private val addRecentEmojiLambda: (String) -> Result<Unit> = { Result.success(Unit) },
private val getAccountDataLambda: (String) -> Result<String?> = { lambdaError() },
private val setAccountDataLambda: (String, String) -> Result<Unit> = { _, _ -> lambdaError() },
private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result<Unit> = { _, _ -> lambdaError() },
private val markAllRoomsAsReadResult: () -> Result<Unit> = { Result.success(Unit) },
private val performDatabaseVacuumLambda: () -> Result<Unit> = { lambdaError() },
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
@@ -144,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
@@ -288,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?> {
@@ -407,6 +415,14 @@ class FakeMatrixClient(
return getRecentEmojisLambda()
}
override suspend fun getAccountData(eventType: String): Result<String?> {
return getAccountDataLambda(eventType)
}
override suspend fun setAccountData(eventType: String, content: String): Result<Unit> {
return setAccountDataLambda(eventType, content)
}
override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result<Unit> {
return markRoomAsFullyReadResult(roomId, eventId)
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.tests.testutils.lambda.lambdaError
class FakeTemporaryMatrixClient(
private val getUrlResult: (String) -> Result<ByteArray> = { lambdaError() },
private val closeLambda: () -> Unit = {},
) : TemporaryMatrixClient {
override suspend fun getUrl(url: String): Result<ByteArray> = getUrlResult(url)
override fun close() = closeLambda()
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
class FakeTemporaryMatrixClientFactory(
private val createResult: (String) -> Result<TemporaryMatrixClient> = { Result.success(FakeTemporaryMatrixClient()) },
) : TemporaryMatrixClientFactory {
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> = createResult(homeServerUrl)
}
@@ -31,6 +31,7 @@ fun aSelectRoomInfo(
canonicalAlias: RoomAlias? = null,
avatarUrl: String? = null,
heroes: ImmutableList<MatrixUser> = persistentListOf(),
isDm: Boolean = false,
isTombstoned: Boolean = false,
) = SelectRoomInfo(
roomId = roomId,
@@ -38,5 +39,6 @@ fun aSelectRoomInfo(
canonicalAlias = canonicalAlias,
avatarUrl = avatarUrl,
heroes = heroes,
isDm = isDm,
isTombstoned = isTombstoned,
)
@@ -27,7 +27,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
@Composable
internal fun ContentErrorView(
title: String,
message: String,
message: String?,
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues.Zero,
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
@@ -51,12 +51,14 @@ internal fun ContentErrorView(
color = ElementTheme.colors.textCriticalPrimary,
style = ElementTheme.typography.fontBodyMdMedium,
)
Text(
text = message,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
onTextLayout = onTextLayout,
)
if (message != null) {
Text(
text = message,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
onTextLayout = onTextLayout,
)
}
}
}
}
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -45,6 +44,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentErrorView
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
@@ -77,7 +77,7 @@ fun InReplyToView(
else -> ReplyToLoadingContent(modifier = modifier)
}
is InReplyToDetails.Error ->
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
ReplyToErrorContent(modifier = modifier)
is InReplyToDetails.Loading ->
ReplyToLoadingContent(modifier = modifier)
}
@@ -150,24 +150,15 @@ private fun ReplyToLoadingContent(
@Composable
private fun ReplyToErrorContent(
data: InReplyToDetails.Error,
maxLines: Int,
modifier: Modifier = Modifier,
) {
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
Row(
modifier
.background(ElementTheme.colors.bgCanvasDefault)
.padding(paddings)
) {
Text(
text = data.message,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textCriticalPrimary,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis,
)
}
ContentErrorView(
title = stringResource(CommonStrings.error_message_not_found),
message = null,
modifier = modifier,
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
onTextLayout = null,
)
}
@Composable
@@ -23,6 +23,7 @@ data class SelectRoomInfo(
val canonicalAlias: RoomAlias?,
val avatarUrl: String?,
val heroes: ImmutableList<MatrixUser>,
val isDm: Boolean,
val isTombstoned: Boolean,
) {
fun getAvatarData(size: AvatarSize) = AvatarData(
@@ -41,5 +42,6 @@ fun RoomInfo.toSelectRoomInfo() = SelectRoomInfo(
avatarUrl = avatarUrl,
heroes = heroes,
canonicalAlias = canonicalAlias,
isDm = isDm,
isTombstoned = successorRoom != null,
)
@@ -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>
@@ -9,6 +9,7 @@
package io.element.android.libraries.mediaplayer.impl
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
@@ -49,7 +50,7 @@ object SimplePlayerModule {
@Provides
fun simplePlayerProvider(
@ApplicationContext context: Context,
): SimplePlayer = DefaultSimplePlayer(ExoPlayer.Builder(context).build())
): SimplePlayer = DefaultSimplePlayer(ExoPlayer.Builder(context).setWakeMode(C.WAKE_MODE_LOCAL).build())
}
/**
@@ -34,11 +34,14 @@ internal object VideoCompressorConfigFactory {
val newFrameRate = min(originalFrameRate, DEFAULT_FRAME_RATE)
// If we need to resize the video, we also want to recalculate the bitrate
val newBitrate = resizer.calculateOptimalBitrate(Size(width, height), newFrameRate)
val optimalBitrate = resizer.calculateOptimalBitrate(Size(width, height), newFrameRate).toInt()
val sourceBitrate = metadata?.bitrate?.takeIf { it > 0 }
val newBitrate = sourceBitrate?.let { min(optimalBitrate.toLong(), it).toInt() } ?: optimalBitrate
return VideoCompressorConfig(
videoCompressorHelper = resizer,
newBitRate = newBitrate.toInt(),
newBitRate = newBitrate,
newFrameRate = newFrameRate,
)
}
@@ -33,6 +33,46 @@ class VideoCompressorConfigFactoryTest : RobolectricTest() {
assertThat(videoCompressorConfig.newBitRate).isNotEqualTo(VideoEncoderSettings.NO_VALUE)
}
@Test
fun `the bitrate is clamped to the source bitrate when the source is already low`() {
val metadata = VideoFileMetadata(width = 720, height = 720, bitrate = 615_000, frameRate = 25, rotation = 0)
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
preset = VideoCompressionPreset.STANDARD,
)
assertThat(videoCompressorConfig.newBitRate).isEqualTo(615_000)
}
@Test
fun `the bitrate is not raised for a high bitrate source`() {
val metadata = VideoFileMetadata(width = 1920, height = 1080, bitrate = 20_000_000, frameRate = 30, rotation = 0)
val videoCompressorConfig = VideoCompressorConfigFactory.create(
metadata = metadata,
preset = VideoCompressionPreset.STANDARD,
)
assertThat(videoCompressorConfig.newBitRate).isLessThan(20_000_000)
assertThat(videoCompressorConfig.newBitRate).isGreaterThan(0)
}
@Test
fun `an unknown bitrate falls back to the calculated optimal`() {
val unknown = VideoCompressorConfigFactory.create(
metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = -1, frameRate = 30, rotation = 0),
preset = VideoCompressionPreset.STANDARD,
)
val zero = VideoCompressorConfigFactory.create(
metadata = VideoFileMetadata(width = 1280, height = 720, bitrate = 0, frameRate = 30, rotation = 0),
preset = VideoCompressionPreset.STANDARD,
)
assertThat(unknown.newBitRate).isGreaterThan(0)
assertThat(zero.newBitRate).isEqualTo(unknown.newBitRate)
}
@Test
fun `if the video should be compressed and is larger than 720p it will be resized`() {
// Given
@@ -83,7 +83,9 @@ class TimelineMediaGalleryDataSource(
},
{
Timber.e(it, "Failed to get media timeline for room ${room.roomId}")
groupedMediaItemsFlow.emit(AsyncData.Failure(it))
if (cache == null) {
groupedMediaItemsFlow.emit(AsyncData.Failure(it))
}
},
)
}.flatMapLatest { timeline ->
@@ -82,7 +82,6 @@ import io.element.android.libraries.audio.api.AudioFocus
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -209,7 +208,6 @@ fun MediaViewerView(
when (val dataForPage = state.listData[page]) {
is MediaViewerPageData.Failure -> {
MediaViewerErrorPage(
throwable = dataForPage.throwable,
onDismiss = onBackClick,
)
}
@@ -414,6 +412,7 @@ private fun MediaViewerPage(
if (showProgress) {
LinearProgressIndicator(
modifier = Modifier
.padding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top).asPaddingValues())
.fillMaxWidth()
.height(2.dp)
)
@@ -506,7 +505,6 @@ private fun MediaViewerLoadingPage(
@Composable
private fun MediaViewerErrorPage(
throwable: Throwable,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -520,9 +518,10 @@ private fun MediaViewerErrorPage(
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
AsyncFailure(
throwable = throwable,
onRetry = null
IconTitleSubtitleMolecule(
iconStyle = BigIcon.Style.AlertSolid,
title = stringResource(CommonStrings.common_something_went_wrong),
subTitle = stringResource(CommonStrings.common_something_went_wrong_message),
)
}
}
@@ -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>
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.NoopEventC
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems
import io.element.android.libraries.mediaviewer.impl.model.MediaItem
import io.element.android.libraries.mediaviewer.impl.model.aMediaItemImage
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
@@ -171,6 +172,32 @@ class TimelineMediaGalleryDataSourceTest {
}
}
@Test
fun `test - failing to load a focused timeline keeps the item that was opened`() = runTest {
val room = FakeJoinedRoom(
createTimelineResult = { Result.failure(AN_EXCEPTION) },
roomCoroutineScope = backgroundScope,
)
val initialMediaItem = aMediaItemImage()
val sut = createTimelineMediaGalleryDataSource(
room = room,
mediaTimeline = FocusedMediaTimeline(
room = room,
eventId = AN_EVENT_ID,
onlyPinnedEvents = false,
initialMediaItem = initialMediaItem,
),
)
sut.start(backgroundScope)
sut.groupedMediaItemsFlow().test {
val item = awaitItem()
assertThat(item.isSuccess()).isTrue()
assertThat(item.dataOrNull()?.imageAndVideoItems).contains(initialMediaItem)
expectNoEvents()
}
assertThat(sut.getLastData().isFailure()).isFalse()
}
@Test
fun `test - when timeline emits new data, the flow emits the data`() = runTest {
val timelineItems = MutableStateFlow<List<MatrixTimelineItem>>(emptyList())
@@ -269,10 +296,11 @@ internal fun TestScope.createTimelineMediaGalleryDataSource(
room: JoinedRoom = FakeJoinedRoom(
liveTimeline = FakeTimeline(),
),
mediaTimeline: MediaTimeline? = null,
): TimelineMediaGalleryDataSource {
return TimelineMediaGalleryDataSource(
room = room,
mediaTimeline = LiveMediaTimeline(room),
mediaTimeline = mediaTimeline ?: LiveMediaTimeline(room),
timelineMediaItemsFactory = createTimelineMediaItemsFactory(),
mediaItemsPostProcessor = MediaItemsPostProcessor(),
)
@@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.media.isPreviewEnabled
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
@@ -56,6 +57,7 @@ import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.services.toolbox.api.strings.StringProvider
import timber.log.Timber
@@ -94,6 +96,7 @@ class DefaultNotifiableEventResolver(
private val callNotificationEventResolver: CallNotificationEventResolver,
private val fallbackNotificationFactory: FallbackNotificationFactory,
private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
) : NotifiableEventResolver {
override suspend fun resolveEvents(
sessionId: SessionId,
@@ -117,10 +120,15 @@ class DefaultNotifiableEventResolver(
return Result.failure(exception ?: NotificationResolverException.UnknownError("Unknown error while fetching notifications"))
}
val otherSessionUserIds = sessionStore.getAllSessions()
.map { UserId(it.userId) }
.minus(sessionId)
.toSet()
// The null check is done above
val notificationDataMap = notificationsResult.getOrNull()!!.mapValues { (_, notificationData) ->
notificationData.flatMap { data ->
data.asNotifiableEvent(client, sessionId)
data.asNotifiableEvent(client, sessionId, otherSessionUserIds)
}
}
@@ -136,10 +144,23 @@ class DefaultNotifiableEventResolver(
)
}
private fun NotificationContent.senderIdOrNull(): UserId? = when (this) {
is NotificationContent.MessageLike.RoomMessage -> senderId
is NotificationContent.MessageLike.Poll -> senderId
is NotificationContent.MessageLike.CallInvite -> senderId
is NotificationContent.MessageLike.RtcNotification -> senderId
is NotificationContent.Invite -> senderId
else -> null
}
private suspend fun NotificationData.asNotifiableEvent(
client: MatrixClient,
userId: SessionId,
otherSessionUserIds: Set<UserId>,
): Result<ResolvedPushEvent> = runCatchingExceptions {
if (content.senderIdOrNull() in otherSessionUserIds) {
throw NotificationResolverException.EventFilteredOut
}
when (val content = this.content) {
is NotificationContent.MessageLike.RoomMessage -> {
val showMediaPreview = client.mediaPreviewService.getMediaPreviewValue().isPreviewEnabled(roomJoinRule)
@@ -284,8 +305,8 @@ class DefaultNotifiableEventResolver(
// Note: this case will be handled below
val redactedEventId = content.redactedEventId
if (redactedEventId == null) {
Timber.tag(loggerTag.value).d("redactedEventId is null.")
throw NotificationResolverException.UnknownError("redactedEventId is null")
Timber.tag(loggerTag.value).w("Ignoring redaction notification with no redactedEventId.")
throw NotificationResolverException.EventFilteredOut
} else {
ResolvedPushEvent.Redaction(
sessionId = userId,
@@ -299,6 +320,29 @@ class DefaultNotifiableEventResolver(
Timber.tag(loggerTag.value).d("Ignoring notification for sticker")
throw NotificationResolverException.EventFilteredOut
}
is NotificationContent.StateEvent.RoomMemberContent -> {
// MSC4506: the homeserver pushes knocks to users who can act on them.
if (content.membershipState == RoomMembershipState.KNOCK) {
val notifiableMessageEvent = buildNotifiableMessageEvent(
sessionId = userId,
senderId = content.userId,
roomId = roomId,
eventId = eventId,
noisy = isNoisy,
timestamp = this.timestamp,
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.userId),
body = stringProvider.getString(R.string.notification_knock_request_body),
roomName = roomDisplayName,
roomIsDm = isDm,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
)
ResolvedPushEvent.Event(notifiableMessageEvent)
} else {
Timber.tag(loggerTag.value).d("Ignoring notification for membership ${content.membershipState}")
throw NotificationResolverException.EventFilteredOut
}
}
NotificationContent.MessageLike.Beacon -> {
Timber.tag(loggerTag.value).d("Ignoring notification for beacon")
throw NotificationResolverException.EventFilteredOut
@@ -321,7 +365,6 @@ class DefaultNotifiableEventResolver(
)
ResolvedPushEvent.Event(notifiableEventMessage)
}
is NotificationContent.StateEvent.RoomMemberContent,
NotificationContent.StateEvent.PolicyRuleRoom,
NotificationContent.StateEvent.PolicyRuleServer,
NotificationContent.StateEvent.PolicyRuleUser,
@@ -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>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright (c) 2026 Element Creations Ltd.
~
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<resources>
<!-- Body of the notification shown to users who can act on a knock (MSC4506 knock push rule). -->
<string name="notification_knock_request_body">"Requested to join"</string>
</resources>
@@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.test.A_REDACTION_REASON
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_SPACE_NAME
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID_2
@@ -56,6 +57,9 @@ import io.element.android.libraries.push.impl.notifications.model.FallbackNotifi
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
@@ -648,6 +652,28 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() {
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve RoomMemberContent knock`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to Result.success(aNotificationData(
content = NotificationContent.StateEvent.RoomMemberContent(
userId = A_USER_ID_2,
membershipState = RoomMembershipState.KNOCK
)
))
)
)
)
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
val expectedResult = ResolvedPushEvent.Event(
aNotifiableMessageEvent(body = "Requested to join")
)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve RoomMemberContent other`() = runTest {
val sut = createDefaultNotifiableEventResolver(
@@ -818,7 +844,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() {
}
@Test
fun `resolve RoomRedaction with null redactedEventId should return null`() = runTest {
fun `resolve RoomRedaction with null redactedEventId is filtered out`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
@@ -834,6 +860,112 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() {
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.getOrNull()).isNull()
assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut)
}
@Test
fun `resolve RoomMessage sent by another session on the same device is filtered out`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_SESSION_ID_2,
messageType = TextMessageType("Hello world", null),
)
)
)
)
),
sessionStore = InMemorySessionStore(
listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
)
),
)
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut)
}
@Test
fun `resolve RtcNotification sent by another session on the same device is filtered out`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RtcNotification(
senderId = A_SESSION_ID_2,
type = RtcNotificationType.RING,
callIntent = CallIntent.AUDIO,
expirationTimestampMillis = 0,
)
)
)
)
),
sessionStore = InMemorySessionStore(
listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
)
),
)
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.exceptionOrNull()).isEqualTo(NotificationResolverException.EventFilteredOut)
}
@Test
fun `resolve RoomMessage sent by a user who is not logged in is not filtered out`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_SESSION_ID_2,
messageType = TextMessageType("Hello world", null),
)
)
)
)
),
sessionStore = InMemorySessionStore(listOf(aSessionData(sessionId = A_SESSION_ID.value))),
)
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.isSuccess).isTrue()
}
@Test
fun `resolve RoomMessage sent by the receiving session itself is not filtered out`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomMessage(
senderId = A_SESSION_ID,
messageType = TextMessageType("Hello world", null),
)
)
)
)
),
sessionStore = InMemorySessionStore(
listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
)
),
)
val request = aPushRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase")
val result = sut.resolveEvents(A_SESSION_ID, listOf(request))
assertThat(result.getEvent(request)?.isSuccess).isTrue()
}
@Test
@@ -893,6 +1025,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() {
notificationService: FakeNotificationService? = FakeNotificationService(),
notificationResult: Result<Map<EventId, Result<NotificationData>>> = Result.success(emptyMap()),
callNotificationEventResolver: FakeCallNotificationEventResolver = FakeCallNotificationEventResolver(),
sessionStore: SessionStore = InMemorySessionStore(),
): DefaultNotifiableEventResolver {
val context = RuntimeEnvironment.getApplication() as Context
notificationService?.givenGetNotificationsResult(notificationResult)
@@ -917,6 +1050,7 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() {
clock = FakeSystemClock(),
),
featureFlagService = FakeFeatureFlagService(),
sessionStore = sessionStore,
)
}
}
@@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomAlias
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.libraries.matrix.ui.model.SelectRoomInfo
import io.element.android.libraries.roomselect.api.RoomSelectMode
@@ -79,5 +80,13 @@ internal fun aRoomSelectRoomList() = persistentListOf(
),
aSelectRoomInfo(
roomId = RoomId("!room3:domain"),
name = "Alice",
heroes = persistentListOf(
aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
),
isDm = true,
),
aSelectRoomInfo(
roomId = RoomId("!room4:domain"),
),
)
@@ -15,11 +15,16 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -144,7 +149,13 @@ fun RoomSelectView(
.consumeWindowInsets(paddingValues)
) {
SearchBar(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.padding(
WindowInsets.safeDrawing
.only(WindowInsetsSides.Horizontal)
.asPaddingValues()
),
placeHolderTitle = stringResource(CommonStrings.action_search),
queryState = state.searchQuery,
active = state.isSearchActive,
@@ -184,7 +195,10 @@ fun RoomSelectView(
showVerticalSpace = true,
)
if (state.resultState is SearchBarResultState.Results) {
LazyColumn(state = lazyListState) {
LazyColumn(
state = lazyListState,
contentPadding = lazyColumnContentPadding,
) {
items(state.resultState.results, key = { it.roomId.value }) { roomSummary ->
Column {
RoomSummaryView(
@@ -260,10 +274,11 @@ private fun RoomSummaryView(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Alias
roomInfo.canonicalAlias?.let { alias ->
val otherUserId = roomInfo.heroes.singleOrNull()?.userId?.takeIf { roomInfo.isDm }
val subtitle = roomInfo.canonicalAlias?.value ?: otherUserId?.value
if (subtitle != null) {
Text(
text = alias.value,
text = subtitle,
color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
@@ -0,0 +1,89 @@
/*
* 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.roomselect.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aSelectRoomInfo
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
import org.robolectric.annotation.Config
class RoomSelectViewTest : RobolectricTest() {
@Config(qualifiers = "h1024dp")
@Test
fun `a room with a canonical alias renders the alias`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
),
)
onNodeWithText("Room with alias").assertIsDisplayed()
onNodeWithText("#alias:example.org").assertIsDisplayed()
}
@Config(qualifiers = "h1024dp")
@Test
fun `a direct message without alias renders the matrix id of the other user`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
),
)
onNodeWithText("Alice").assertIsDisplayed()
onNodeWithText("@alice:example.org").assertIsDisplayed()
}
@Config(qualifiers = "h1024dp")
@Test
fun `a room which is not a direct message does not render the matrix id of its hero`() = runAndroidComposeUiTest {
setRoomSelectView(
aRoomSelectState(
resultState = SearchBarResultState.Results(
persistentListOf(
aSelectRoomInfo(
roomId = RoomId("!room:domain"),
name = "Room with a single hero",
heroes = persistentListOf(
aMatrixUser(id = "@alice:example.org", displayName = "Alice"),
),
),
)
),
),
)
onNodeWithText("Room with a single hero").assertIsDisplayed()
onNodeWithText("@alice:example.org").assertDoesNotExist()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setRoomSelectView(
state: RoomSelectState,
onDismiss: () -> Unit = EnsureNeverCalled(),
onSubmit: (List<RoomId>) -> Unit = EnsureNeverCalledWithParam(),
) {
setContent {
RoomSelectView(
state = state,
onDismiss = onDismiss,
onSubmit = onSubmit,
)
}
}
@@ -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>
@@ -71,14 +71,17 @@ class DefaultVoiceRecorder(
override val state: StateFlow<VoiceRecorderState> = _state
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
override suspend fun startRecord() {
override suspend fun startRecord() = lock.withLock {
if (recordingJob != null) {
Timber.w("Voice recorder is already recording, ignoring this start")
return@withLock
}
Timber.i("Voice recorder started recording")
outputFile = fileManager.createFile()
.also(encoder::init)
lock.withLock {
levels.clear()
}
levels.clear()
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
@@ -39,6 +39,7 @@ import kotlin.time.TestTimeSource
class DefaultVoiceRecorderTest {
private val fakeFileSystem = FakeFileSystem()
private val timeSource = TestTimeSource()
private val audioReaderFactory = FakeAudioReaderFactory(audio = AUDIO)
@Test
fun `it emits the initial state`() = runTest {
@@ -109,6 +110,32 @@ class DefaultVoiceRecorderTest {
}
}
@Test
fun `when startRecord is called twice, the second call is ignored`() = runTest {
val voiceRecorder = createDefaultVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
voiceRecorder.startRecord()
assertThat(audioReaderFactory.createdCount).isEqualTo(1)
skipItems(1)
timeSource += 5.seconds
skipItems(2)
voiceRecorder.stopRecord()
assertThat(awaitItem()).isEqualTo(
VoiceRecorderState.Finished(
file = File(FILE_PATH),
mimeType = MimeTypes.Ogg,
waveform = List(100) { 1f },
duration = 5.seconds,
)
)
expectNoEvents()
}
}
@Test
fun `when cancelled, it deletes the file`() = runTest {
val voiceRecorder = createDefaultVoiceRecorder()
@@ -128,9 +155,7 @@ class DefaultVoiceRecorderTest {
return DefaultVoiceRecorder(
dispatchers = testCoroutineDispatchers(),
timeSource = timeSource,
audioReaderFactory = FakeAudioReaderFactory(
audio = AUDIO,
),
audioReaderFactory = audioReaderFactory,
encoder = FakeEncoder(fakeFileSystem),
config = AudioConfig(
format = audioFormat,
@@ -16,7 +16,11 @@ import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
class FakeAudioReaderFactory(
private val audio: List<Audio>
) : AudioReader.Factory {
var createdCount: Int = 0
private set
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader {
createdCount++
return FakeAudioReader(dispatchers, audio)
}
}
+4
View File
@@ -12,3 +12,7 @@ plugins {
android {
namespace = "io.element.android.libraries.wellknown.api"
}
dependencies {
implementation(projects.libraries.matrix.api)
}
@@ -11,4 +11,8 @@ interface ElementWellknownStore {
suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown>
suspend fun update(domain: String, wellknown: String): Result<Unit>
suspend fun delete(domain: String): Result<Unit>
fun interface Factory {
fun create(prefix: String?): ElementWellknownStore
}
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.api
/**
* The source from which the enterprise remote configuration is retrieved.
*/
enum class EnterpriseRemoteConfigSource {
/**
* Retrieve the enterprise remote configuration from the Element Server Suite configuration (ESS_CONFIG) endpoint.
*/
ESS_CONFIG,
/**
* Retrieve the enterprise remote configuration from the Element server's well-known endpoint.
*/
WELLKNOWN_ENDPOINT,
}
@@ -1,13 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.api
interface SessionWellknownRetriever {
suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown>
}
@@ -8,6 +8,15 @@
package io.element.android.libraries.wellknown.api
import io.element.android.libraries.matrix.api.UrlContentFetcher
interface WellknownRetriever {
suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown>
suspend fun getElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource
): WellknownRetrieverResult<ElementWellKnown>
interface Factory {
fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever
}
}
@@ -35,4 +35,11 @@ sealed interface WellknownRetrieverResult<out T> {
is Error -> null
NotFound -> null
}
fun upToDateDataOrNull(): T? = when (this) {
is Success<T> -> data
is Outdated<T> -> null
is Error -> null
NotFound -> null
}
}
@@ -1,109 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
@ContributesBinding(SessionScope::class)
class DefaultSessionWellknownRetriever(
private val matrixClient: MatrixClient,
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val elementWellknownStore: ElementWellknownStore,
private val elementWellKnownParser: ElementWellKnownParser,
private val enterpriseService: EnterpriseService,
) : SessionWellknownRetriever {
private val domain by lazy { matrixClient.userIdServerName() }
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown()
if (overriddenElementWellKnown != null) {
return WellknownRetrieverResult.Success(overriddenElementWellKnown)
}
val cacheData = elementWellknownStore.get(domain)
return when (cacheData) {
is WellknownRetrieverResult.Success -> {
Timber.d("Using cached well-known for domain $domain")
cacheData
}
is WellknownRetrieverResult.Outdated -> {
// Return the outdated data but refresh in the background
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
Timber.d("Outdated cached well-known for domain $domain, returning existing value and fetching new one from network")
sessionCoroutineScope.launch {
val url = "https://$domain/.well-known/element/element.json"
fetchElementWellKnown(url)
}
cacheData
}
is WellknownRetrieverResult.NotFound -> {
// Try to fetch from the server
Timber.d("No cached well-known for domain $domain, fetching from network")
val url = "https://$domain/.well-known/element/element.json"
fetchElementWellKnown(url)
}
is WellknownRetrieverResult.Error -> {
// Return the error
Timber.e(cacheData.exception, "Error retrieving well-known for domain $domain")
cacheData.exception.toWellknownRetrieverResult()
}
}
}
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
return matrixClient
.getUrl(url)
.mapCatchingExceptions {
val data = String(it)
val parsed = elementWellKnownParser.parse(data).getOrThrow()
// Also store in cache, if valid
elementWellknownStore.update(domain, data)
.onFailure { exception ->
Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache")
elementWellknownStore.delete(domain)
}
parsed
}
.toWellknownRetrieverResult()
}
private fun <T> Result<T>.toWellknownRetrieverResult(): WellknownRetrieverResult<T> = fold(
onSuccess = {
WellknownRetrieverResult.Success(it)
},
onFailure = {
Timber.e(it, "Failed to retrieve Element .well-known from $domain")
// This check on message value is not ideal but this is what we got from the SDK.
it.toWellknownRetrieverResult()
}
)
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(this as Exception)
}
}
}
@@ -8,6 +8,9 @@
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.cachestore.api.CacheData
import io.element.android.libraries.cachestore.api.CacheStore
@@ -18,12 +21,19 @@ import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.api.systemclock.SystemClock
@ContributesBinding(AppScope::class)
@AssistedInject
class DefaultWellKnownStore(
@Assisted
private val prefix: String?,
private val cacheStore: CacheStore,
private val elementWellKnownParser: ElementWellKnownParser,
private val systemClock: SystemClock,
) : ElementWellknownStore {
@AssistedFactory
@ContributesBinding(AppScope::class)
fun interface Factory : ElementWellknownStore.Factory {
override fun create(prefix: String?): DefaultWellKnownStore
}
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
return runCatchingExceptions {
val cachedData = cacheStore.getData(key(domain))
@@ -54,7 +64,11 @@ class DefaultWellKnownStore(
}
}
private fun key(domain: String): String = "https://$domain/.well-known/element/element.json"
private fun key(domain: String): String = if (prefix != null) {
"$prefix-$domain"
} else {
"https://$domain/.well-known/element/element.json"
}
companion object {
// 1 day
@@ -9,65 +9,134 @@
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.androidutils.text.takeIfNotBlank
import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.network.RetrofitFactory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import retrofit2.HttpException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.net.HttpURLConnection
import java.net.URL
@ContributesBinding(AppScope::class)
@AssistedInject
class DefaultWellknownRetriever(
private val retrofitFactory: RetrofitFactory,
private val elementWellknownStore: ElementWellknownStore,
private val jsonProvider: JsonProvider,
private val elementWellknownStoreFactory: ElementWellknownStore.Factory,
private val enterpriseService: EnterpriseService,
private val elementWellKnownParser: ElementWellKnownParser,
@Assisted private val urlContentFetcher: UrlContentFetcher,
@AppCoroutineScope private val coroutineScope: CoroutineScope,
) : WellknownRetriever {
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
@ContributesBinding(AppScope::class)
@AssistedFactory
fun interface Factory : WellknownRetriever.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): DefaultWellknownRetriever
}
override suspend fun getElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource
): WellknownRetrieverResult<ElementWellKnown> {
val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown()
if (overriddenElementWellKnown != null) {
return WellknownRetrieverResult.Success(overriddenElementWellKnown)
}
val domain = URL(baseUrl).host
return buildWellknownApi(baseUrl)
.map { wellknownApi ->
try {
val json = jsonProvider()
val result = wellknownApi.getElementWellKnown()
elementWellknownStore.update(domain, json.encodeToString(result))
WellknownRetrieverResult.Success(result.map())
} catch (e: Exception) {
// Is it a 404?
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
if ((e as? HttpException)?.code() == HttpURLConnection.HTTP_NOT_FOUND) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(e)
}
}
// The passed `host` function may be a full URL, in that case we're only interested in the actual host
val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() }
.getOrElse { return WellknownRetrieverResult.Error(IllegalArgumentException("host parameter is not valid", it)) }
?: host
// We instantiate different stores for different sources, so that we can cache them separately.
// The ESS_CONFIG source is cached with a prefix to differentiate it from the WELLKNOWN_ENDPOINT.
val prefix = when (source) {
EnterpriseRemoteConfigSource.ESS_CONFIG -> "ess_config"
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> null
}
val store = elementWellknownStoreFactory.create(prefix)
return when (val cacheData = store.get(checkedHost)) {
is WellknownRetrieverResult.Success -> {
Timber.d("Using cached well-known for domain $checkedHost")
cacheData
}
.fold(
onSuccess = { it },
onFailure = { WellknownRetrieverResult.Error(it as Exception) }
)
is WellknownRetrieverResult.Outdated -> {
// Return the outdated data but refresh in the background
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
Timber.d("Outdated cached well-known for domain $checkedHost, returning existing value and fetching new one from network")
coroutineScope.launch { fetchElementWellKnown(checkedHost, source, store) }
cacheData
}
is WellknownRetrieverResult.NotFound -> {
// Try to fetch from the server
Timber.d("No cached well-known for domain $checkedHost, fetching from network")
fetchElementWellKnown(checkedHost, source, store)
}
is WellknownRetrieverResult.Error -> {
// Return the error
Timber.e(cacheData.exception, "Error retrieving well-known for domain $checkedHost")
cacheData.exception.toWellknownRetrieverResult()
}
}
}
private fun buildWellknownApi(accountProviderUrl: String): Result<WellknownAPI> {
return runCatchingExceptions {
retrofitFactory.create(accountProviderUrl.ensureProtocol())
.create(WellknownAPI::class.java)
}.onFailure { e ->
// If the base URL is not valid, we cannot retrieve the well-known data
Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl")
private suspend fun fetchElementWellKnown(
host: String,
source: EnterpriseRemoteConfigSource,
store: ElementWellknownStore
): WellknownRetrieverResult<ElementWellKnown> {
return urlContentFetcher
.getUrl(url(host, source))
.mapCatchingExceptions {
val data = String(it)
val parsed = elementWellKnownParser.parse(data).getOrThrow()
// Also store in cache, if valid
store.update(host, data)
.onFailure { exception ->
Timber.e(exception, "Failed to parse cached Element .well-known data for $host, deleting cache")
store.delete(host)
}
parsed
}
.toWellknownRetrieverResult(host)
}
private fun <T> Result<T>.toWellknownRetrieverResult(host: String): WellknownRetrieverResult<T> = fold(
onSuccess = {
WellknownRetrieverResult.Success(it)
},
onFailure = {
Timber.e(it, "Failed to retrieve Element .well-known from $host")
// This check on message value is not ideal but this is what we got from the SDK.
it.toWellknownRetrieverResult()
}
)
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
WellknownRetrieverResult.NotFound
} else {
WellknownRetrieverResult.Error(this as Exception)
}
}
private fun url(host: String, source: EnterpriseRemoteConfigSource): String {
val elementWellKnownUrl = "https://$host/.well-known/element/element.json"
return when (source) {
EnterpriseRemoteConfigSource.ESS_CONFIG -> enterpriseService.essConfigEndpointUrl(host) ?: elementWellKnownUrl
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> elementWellKnownUrl
}
}
}
@@ -0,0 +1,41 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.wellknown.impl
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.ElementWellknownStore
import io.element.android.libraries.wellknown.api.WellknownRetriever
import kotlinx.coroutines.CoroutineScope
@BindingContainer
@ContributesTo(SessionScope::class)
object SessionWellknownModule {
@Provides
fun provideWellKnownRetriever(
elementWellknownStoreFactory: ElementWellknownStore.Factory,
enterpriseService: EnterpriseService,
elementWellKnownParser: ElementWellKnownParser,
urlContentFetcher: UrlContentFetcher,
@SessionCoroutineScope coroutineScope: CoroutineScope,
): WellknownRetriever {
return DefaultWellknownRetriever(
elementWellknownStoreFactory = elementWellknownStoreFactory,
enterpriseService = enterpriseService,
elementWellKnownParser = elementWellKnownParser,
urlContentFetcher = urlContentFetcher,
coroutineScope = coroutineScope
)
}
}
@@ -1,376 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.wellknown.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.wellknown.test.FakeElementWellknownStore
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
class DefaultSessionWellknownRetrieverTest {
@Test
fun `get empty element wellknown`() = runTest {
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {
Result.success("{}".toByteArray())
}
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = getUrlLambda,
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = null,
enforceElementPro = null,
rageshakeUrl = null,
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
customRecoveryPassphrase = null,
contentScannerUrl = null,
forceDisableE2EE = null,
)
)
)
getUrlLambda.assertions().isCalledOnce()
.with(value("https://user.domain.org/.well-known/element/element.json"))
}
@Test
fun `get element wellknown with full content`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
WELLKNOWN_CONTENT.toByteArray()
)
}
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
}
@Test
fun `get element wellknown with unknown key`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url",
// Note the trailing comma, and the comment!
"other": true,
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = null,
notificationSound = null,
identityProviderAppScheme = null,
contentScannerUrl = null,
customRecoveryPassphrase = null,
forceDisableE2EE = null,
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": 8
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8)
)
)
)
}
@Test
fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with zero min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": 0
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown with negative min character count floors to 1`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"custom_recovery_passphrase": {
"min_character_count": -5
}
}""".trimIndent().toByteArray()
)
},
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown(
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
)
)
)
}
@Test
fun `get element wellknown json error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success(
"""{
"registration_helper_url" = "a_registration_url",
error
}""".trimIndent().toByteArray()
)
}
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown network error`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(AN_EXCEPTION)
}
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
}
@Test
fun `get element wellknown 404 http error counts as not found`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.failure(ClientException.Generic("Not Found: 404 status code", "The file could not be found"))
}
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
}
@Test
fun `get element wellknown hitting cache`() = runTest {
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = { lambdaError() },
cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
)
)
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
}
@Test
fun `get element wellknown hitting cache containing invalid json`() = runTest {
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
)
)
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success("{}".toByteArray())
},
cacheStore = cacheStore,
elementWellKnownParser = { Result.failure(IllegalStateException("Failed to parse JSON")) }
)
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
// Ensure that the cache is deleted after the failure to parse it
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
}
@Test
fun `get element wellknown hitting outdated cache`() = runTest {
val cacheStore = FakeElementWellknownStore(
initialData = mapOf(
WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()),
)
)
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = {
Result.success("{}".toByteArray())
},
cacheStore = cacheStore,
)
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Outdated(
ElementWellKnown(
registrationHelperUrl = "a_registration_url",
enforceElementPro = true,
rageshakeUrl = "a_rageshake_url",
brandColor = "#FF0000",
notificationSound = "a_notification_sound.flac",
identityProviderAppScheme = "an_app_scheme",
customRecoveryPassphrase = null,
contentScannerUrl = "https://content-scanner.example.com",
forceDisableE2EE = false,
)
)
)
// Next call returns the updated value
runCurrent()
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(
anElementWellKnown()
)
)
}
@Test
fun `get element wellknown was overridden`() = runTest {
val getLambda = lambdaRecorder<String, Result<ByteArray>> { Result.failure(IllegalStateException("BOOM")) }
val wellKnown = anElementWellKnown()
val sut = createDefaultSessionWellknownRetriever(
getUrlLambda = getLambda,
enterpriseService = FakeEnterpriseService(
overrideWellKnownResult = { wellKnown }
)
)
// The overridden value is returned
assertThat(sut.getElementWellKnown()).isEqualTo(
WellknownRetrieverResult.Success(wellKnown)
)
// And the endpoint is never hit
getLambda.assertions().isNeverCalled()
}
private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString<InternalElementWellKnown>(WELLKNOWN_CONTENT).map()
private fun TestScope.createDefaultSessionWellknownRetriever(
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
getUrlLambda: (String) -> Result<ByteArray>,
elementWellKnownParser: ElementWellKnownParser = DefaultElementWellKnownParser(DefaultJsonProvider()),
enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }),
) = DefaultSessionWellknownRetriever(
matrixClient = FakeMatrixClient(
userIdServerNameLambda = { "user.domain.org" },
getUrlLambda = getUrlLambda,
),
sessionCoroutineScope = backgroundScope,
elementWellknownStore = cacheStore,
elementWellKnownParser = elementWellKnownParser,
enterpriseService = enterpriseService,
)
companion object {
private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json"
private const val WELLKNOWN_CONTENT = """{
"registration_helper_url": "a_registration_url",
"enforce_element_pro": true,
"rageshake_url": "a_rageshake_url",
"brand_color": "#FF0000",
"notification_sound": "a_notification_sound.flac",
"idp_app_scheme": "an_app_scheme",
"content_scanner_url": "https://content-scanner.example.com",
"force_disable_e2ee": false,
}"""
}
}

Some files were not shown because too many files have changed in this diff Show More