mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 20:38:40 +00:00
Merge branch 'develop' into fix/2614-linkify-ipv6-urls
This commit is contained in:
+9
@@ -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
|
||||
|
||||
|
||||
+2
@@ -13,3 +13,5 @@ import java.nio.charset.Charset
|
||||
|
||||
fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name())
|
||||
fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name())
|
||||
|
||||
fun String.takeIfNotBlank(): String? = ifBlank { null }
|
||||
|
||||
+39
@@ -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."
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.components.async
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TimeMark
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
private val DefaultShowDelay = 300.milliseconds
|
||||
private val DefaultMinDisplayDuration = 500.milliseconds
|
||||
private val DefaultFailureDuration = 500.milliseconds
|
||||
|
||||
/**
|
||||
* An [AsyncIndicatorHost] driven by an [AsyncAction], with anti-flicker timing.
|
||||
*
|
||||
* While [asyncAction] is [AsyncAction.Loading], [loading] is displayed, but only if the operation
|
||||
* runs longer than [showDelay]; once shown it stays visible for at least [minDisplayDuration]. This
|
||||
* prevents the indicator from flashing for operations that complete almost instantly.
|
||||
*
|
||||
* When [asyncAction] is [AsyncAction.Failure], [failure] is displayed immediately (failures are
|
||||
* never debounced) and auto-dismisses after [failureDuration].
|
||||
*
|
||||
* @param asyncAction the action whose progress drives the indicator.
|
||||
* @param loading the composable to display while loading.
|
||||
* @param failure the composable to display on failure.
|
||||
* @param modifier the modifier to apply to the underlying [AsyncIndicatorHost].
|
||||
* @param state the [AsyncIndicatorState] backing the host.
|
||||
* @param showDelay how long the loading state must persist before the indicator is shown.
|
||||
* @param minDisplayDuration the minimum duration the loading indicator stays visible once shown.
|
||||
* @param failureDuration how long the failure indicator stays visible before auto-dismissing.
|
||||
*/
|
||||
@Composable
|
||||
fun AsyncActionIndicator(
|
||||
asyncAction: AsyncAction<*>,
|
||||
loading: @Composable () -> Unit,
|
||||
failure: @Composable (Throwable) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
state: AsyncIndicatorState = rememberAsyncIndicatorState(),
|
||||
showDelay: Duration = DefaultShowDelay,
|
||||
minDisplayDuration: Duration = DefaultMinDisplayDuration,
|
||||
failureDuration: Duration = DefaultFailureDuration,
|
||||
) {
|
||||
AsyncIndicatorHost(modifier = modifier, state = state)
|
||||
AsyncActionIndicatorEffect(
|
||||
asyncAction = asyncAction,
|
||||
state = state,
|
||||
showDelay = showDelay,
|
||||
minDisplayDuration = minDisplayDuration,
|
||||
failureDuration = failureDuration,
|
||||
loading = loading,
|
||||
failure = failure,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AsyncActionIndicatorEffect(
|
||||
asyncAction: AsyncAction<*>,
|
||||
state: AsyncIndicatorState,
|
||||
showDelay: Duration,
|
||||
minDisplayDuration: Duration,
|
||||
failureDuration: Duration,
|
||||
loading: @Composable () -> Unit,
|
||||
failure: @Composable (Throwable) -> Unit,
|
||||
timeSource: TimeSource = TimeSource.Monotonic,
|
||||
) {
|
||||
// Referenced inside the restarting LaunchedEffect below, so keep them up to date.
|
||||
val currentLoading by rememberUpdatedState(loading)
|
||||
val currentFailure by rememberUpdatedState(failure)
|
||||
// Mark set when the loader actually becomes visible, null while it is hidden.
|
||||
var loaderShownAt by remember { mutableStateOf<TimeMark?>(null) }
|
||||
LaunchedEffect(asyncAction) {
|
||||
when (asyncAction) {
|
||||
is AsyncAction.Loading -> {
|
||||
// If the operation completes before this delay elapses, the effect is cancelled
|
||||
// and the loader is never shown.
|
||||
delay(showDelay)
|
||||
state.enqueue(composable = currentLoading)
|
||||
loaderShownAt = timeSource.markNow()
|
||||
}
|
||||
is AsyncAction.Failure -> {
|
||||
// Always surface failures immediately, even if the loader was never shown.
|
||||
val error = asyncAction.error
|
||||
state.enqueue(durationMs = failureDuration.inWholeMilliseconds, composable = { currentFailure(error) })
|
||||
loaderShownAt = null
|
||||
}
|
||||
is AsyncAction.Success -> {
|
||||
// Hold the loader for its minimum duration before clearing it.
|
||||
loaderShownAt?.let { shownAt ->
|
||||
val remaining = minDisplayDuration - shownAt.elapsedNow()
|
||||
if (remaining > Duration.ZERO) delay(remaining)
|
||||
}
|
||||
state.clear()
|
||||
loaderShownAt = null
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.designsystem.component.async
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicatorEffect
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.TestTimeSource
|
||||
import kotlin.time.TimeSource
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AsyncActionIndicatorTest {
|
||||
private val showDelay = 300.milliseconds
|
||||
private val minDisplay = 500.milliseconds
|
||||
|
||||
@Test
|
||||
fun `loader is not shown before the show delay elapses`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(100.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
// Once the show delay has fully elapsed, the loader is shown.
|
||||
advanceTimeBy(300.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loader is never shown when the operation completes before the show delay`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(100.milliseconds)
|
||||
runCurrent()
|
||||
action.value = AsyncAction.Success(Unit)
|
||||
advanceTimeBy(1000.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure is shown immediately, without waiting for the show delay`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
indicatorFlow(action, state).test {
|
||||
action.value = AsyncAction.Failure(Exception("failed"))
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loader stays visible for the minimum duration once shown`() = runTest {
|
||||
val state = AsyncIndicatorState()
|
||||
val action = mutableStateOf<AsyncAction<*>>(AsyncAction.Uninitialized)
|
||||
val timeSource = TestTimeSource()
|
||||
indicatorFlow(action, state, timeSource).test {
|
||||
// Show the loader.
|
||||
action.value = AsyncAction.Loading
|
||||
advanceTimeBy(400.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
// 200ms of display has already elapsed when the operation succeeds.
|
||||
timeSource += 200.milliseconds
|
||||
action.value = AsyncAction.Success(Unit)
|
||||
runCurrent()
|
||||
// Still within the minimum display duration (200ms shown, 300ms remaining) -> stays visible.
|
||||
advanceTimeBy(200.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isTrue()
|
||||
// Past the minimum display duration -> cleared.
|
||||
advanceTimeBy(200.milliseconds)
|
||||
runCurrent()
|
||||
assertThat(state.currentAnimationState.targetState).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
private fun indicatorFlow(
|
||||
action: State<AsyncAction<*>>,
|
||||
state: AsyncIndicatorState,
|
||||
timeSource: TimeSource = TimeSource.Monotonic,
|
||||
): Flow<Boolean> = moleculeFlow(RecompositionMode.Immediate) {
|
||||
AsyncActionIndicatorEffect(
|
||||
asyncAction = action.value,
|
||||
state = state,
|
||||
showDelay = showDelay,
|
||||
minDisplayDuration = minDisplay,
|
||||
failureDuration = minDisplay,
|
||||
loading = {},
|
||||
failure = {},
|
||||
timeSource = timeSource,
|
||||
)
|
||||
state.currentAnimationState.targetState
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -36,5 +36,5 @@ dependencies {
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testCommonDependencies(libs)
|
||||
testCommonDependencies(libs, includeTestComposeView = true)
|
||||
}
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ internal fun EmojiPickerView(
|
||||
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
modifier = Modifier.padding(bottom = if (state.isSearchActive) 0.dp else 10.dp),
|
||||
queryState = state.searchQuery,
|
||||
resultState = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.assertIsEqualTo
|
||||
import androidx.compose.ui.test.getBoundsInRoot
|
||||
import androidx.compose.ui.test.hasScrollToIndexAction
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import org.junit.Test
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
private val PICKER_HEIGHT = 600.dp
|
||||
|
||||
class EmojiPickerViewTest : RobolectricTest() {
|
||||
@Config(qualifiers = "w400dp-h800dp")
|
||||
@Test
|
||||
fun `the search results reach the bottom of the picker`() = runAndroidComposeUiTest<ComponentActivity> {
|
||||
setEmojiPickerView()
|
||||
onNode(hasScrollToIndexAction())
|
||||
.getBoundsInRoot()
|
||||
.bottom
|
||||
.assertIsEqualTo(PICKER_HEIGHT, "emoji grid bottom")
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setEmojiPickerView() {
|
||||
setContent {
|
||||
Box(Modifier.size(400.dp, PICKER_HEIGHT)) {
|
||||
EmojiPickerView(
|
||||
state = aDefaultEmojiPickerState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "smile",
|
||||
searchResults = SearchBarResultState.Results(
|
||||
persistentListOf(
|
||||
Emoji(
|
||||
hexcode = "0x00",
|
||||
label = "grinning face",
|
||||
tags = persistentListOf("grinning"),
|
||||
shortcodes = persistentListOf("smile"),
|
||||
unicode = "😀",
|
||||
skins = null,
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
onSelectEmoji = {},
|
||||
selectedEmojis = persistentSetOf(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
+6
@@ -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)) {
|
||||
|
||||
+27
-10
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+17
-6
@@ -54,7 +54,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.Optional
|
||||
|
||||
interface MatrixClient {
|
||||
interface MatrixClient : UrlContentFetcher {
|
||||
val sessionId: SessionId
|
||||
val deviceId: DeviceId
|
||||
|
||||
@@ -99,6 +99,12 @@ interface MatrixClient {
|
||||
|
||||
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
|
||||
suspend fun clearUserStatus(): Result<Unit>
|
||||
|
||||
/** Whether the homeserver advertises support for user status (MSC4426). */
|
||||
suspend fun isUserStatusSupported(): Result<Boolean>
|
||||
|
||||
/** Enable or disable automatically setting the user's status to "in a call" (m.call) while in a call. */
|
||||
fun enableAutomaticCallStatus(enabled: Boolean)
|
||||
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
|
||||
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
||||
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
||||
@@ -167,11 +173,6 @@ interface MatrixClient {
|
||||
*/
|
||||
fun userIdServerName(): String
|
||||
|
||||
/**
|
||||
* Execute generic GET requests through the SDKs internal HTTP client.
|
||||
*/
|
||||
suspend fun getUrl(url: String): Result<ByteArray>
|
||||
|
||||
/**
|
||||
* Get a room preview for a given room ID or alias. This is especially useful for rooms that the user is not a member of, or hasn't joined yet.
|
||||
*/
|
||||
@@ -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].
|
||||
*
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
import java.lang.AutoCloseable
|
||||
|
||||
/**
|
||||
* A Matrix client in an 'unauthenticated' state that can still be used to resolve URLs.
|
||||
*
|
||||
* Note: this client implements an [AutoCloseable] interface, so using [AutoCloseable.close] or [AutoCloseable.use] will clean up any resources associated
|
||||
* with it, such as temporary files.
|
||||
*/
|
||||
interface TemporaryMatrixClient : UrlContentFetcher, AutoCloseable
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* Factory to create a temporary [TemporaryMatrixClient] for a given home server URL.
|
||||
*/
|
||||
interface TemporaryMatrixClientFactory {
|
||||
suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient>
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api
|
||||
|
||||
/**
|
||||
* An interface to fetch content from a URL using a simple GET request.
|
||||
*/
|
||||
fun interface UrlContentFetcher {
|
||||
/**
|
||||
* Execute generic GET requests through the SDKs internal HTTP client.
|
||||
*/
|
||||
suspend fun getUrl(url: String): Result<ByteArray>
|
||||
}
|
||||
+2
@@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.api.auth
|
||||
import io.element.android.libraries.matrix.api.BuildConfig
|
||||
|
||||
object OAuthConfig {
|
||||
// This *can* be null if the build time config field is not set
|
||||
@Suppress("UNNECESSARY_SAFE_CALL")
|
||||
val CLIENT_URI = BuildConfig.OAUTH_CLIENT_URI_PATH?.let {
|
||||
// Concatenate the base URI and the path, ensuring there is exactly one '/' between them
|
||||
"${BuildConfig.CLIENT_URI.trimEnd('/')}/${it.trimStart('/')}"
|
||||
|
||||
+1
@@ -104,4 +104,5 @@ fun RoomMember.toMatrixUser() = MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName.takeUnless { membership == RoomMembershipState.BAN },
|
||||
avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN },
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
|
||||
+9
-2
@@ -7,12 +7,19 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.scanner
|
||||
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
/**
|
||||
* Provides the URL of the content scanner service for a given homeserver, if any is set up.
|
||||
*/
|
||||
fun interface ContentScannerUrlProvider {
|
||||
/**
|
||||
* Returns the URL of the content scanner service for the given [homeserver], or `null` if no content scanner is set up.
|
||||
* Returns the URL of the content scanner service for the given [sessionId], or `null` if no content scanner is set up.
|
||||
*/
|
||||
suspend fun getContentScannerUrl(homeserver: String): Result<String?>
|
||||
suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?>
|
||||
|
||||
fun interface Factory {
|
||||
fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider
|
||||
}
|
||||
}
|
||||
|
||||
+18
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
@Immutable
|
||||
sealed interface ProfileDetails {
|
||||
@@ -60,3 +61,20 @@ fun ProfileDetails.getAvatarUrl(): String? {
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun ProfileDetails.toMatrixUser(userId: UserId): MatrixUser {
|
||||
return when (this) {
|
||||
is ProfileDetails.Ready -> MatrixUser(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
else -> MatrixUser(
|
||||
userId = userId,
|
||||
displayName = null,
|
||||
avatarUrl = null,
|
||||
displayedStatus = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -13,6 +13,9 @@ import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Immutable
|
||||
sealed interface DisplayedStatus : Parcelable {
|
||||
/** Useful for adding extension methods. */
|
||||
companion object
|
||||
|
||||
/** Status set manually by the user via m.status. */
|
||||
@Parcelize
|
||||
data class UserSet(val status: UserStatus) : DisplayedStatus
|
||||
@@ -21,6 +24,6 @@ sealed interface DisplayedStatus : Parcelable {
|
||||
@Parcelize
|
||||
data class InCall(
|
||||
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
|
||||
val callJoinedTs: Long,
|
||||
val callJoinedTs: Long?,
|
||||
) : DisplayedStatus
|
||||
}
|
||||
|
||||
+63
-32
@@ -49,7 +49,6 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
@@ -58,7 +57,6 @@ import io.element.android.libraries.matrix.impl.exception.mapClientException
|
||||
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
|
||||
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler
|
||||
import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser
|
||||
import io.element.android.libraries.matrix.impl.mapper.map
|
||||
import io.element.android.libraries.matrix.impl.media.RustMediaLoader
|
||||
import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService
|
||||
import io.element.android.libraries.matrix.impl.notification.RustNotificationService
|
||||
@@ -85,7 +83,9 @@ import io.element.android.libraries.matrix.impl.search.RustMessageSearchService
|
||||
import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
|
||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||
import io.element.android.libraries.matrix.impl.sync.map
|
||||
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
|
||||
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
|
||||
import io.element.android.libraries.matrix.impl.user.into
|
||||
import io.element.android.libraries.matrix.impl.user.map
|
||||
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
|
||||
@@ -126,9 +126,11 @@ import org.matrix.rustcomponents.sdk.IgnoredUsersListener
|
||||
import org.matrix.rustcomponents.sdk.Membership
|
||||
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
|
||||
import org.matrix.rustcomponents.sdk.PowerLevels
|
||||
import org.matrix.rustcomponents.sdk.ProfileListener
|
||||
import org.matrix.rustcomponents.sdk.RoomInfoListener
|
||||
import org.matrix.rustcomponents.sdk.SendQueueRoomErrorListener
|
||||
import org.matrix.rustcomponents.sdk.TaskHandle
|
||||
import org.matrix.rustcomponents.sdk.UserProfile
|
||||
import org.matrix.rustcomponents.sdk.use
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
@@ -271,8 +273,6 @@ class RustMatrixClient(
|
||||
|
||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||
|
||||
@Volatile private var localUserStatus: UserStatus? = null
|
||||
|
||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||
MatrixUser(
|
||||
userId = sessionId,
|
||||
@@ -283,6 +283,14 @@ class RustMatrixClient(
|
||||
|
||||
override val userProfile: StateFlow<MatrixUser> = _userProfile
|
||||
|
||||
private var ownProfileTaskHandle: TaskHandle? = null
|
||||
|
||||
private val ownProfileListener = object : ProfileListener {
|
||||
override fun onUpdate(profile: UserProfile) {
|
||||
_userProfile.tryEmit(profile.map())
|
||||
}
|
||||
}
|
||||
|
||||
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
|
||||
// Fetch the initial value manually, the SDK won't return it automatically
|
||||
channel.trySend(innerClient.ignoredUsers().map(::UserId).toImmutableList())
|
||||
@@ -303,8 +311,21 @@ class RustMatrixClient(
|
||||
sessionCoroutineScope.launch {
|
||||
// Start notification settings
|
||||
notificationSettingsService.start()
|
||||
// Setup user profile logic
|
||||
setupUserProfile()
|
||||
}
|
||||
|
||||
// Update the user profile in the session store if needed
|
||||
// Schedule regular database vacuuming to ensure DB performance remains optimal
|
||||
scheduleDatabaseVacuum()
|
||||
}
|
||||
|
||||
private suspend fun setupUserProfile() {
|
||||
val supported = isUserStatusSupported().getOrDefault(false)
|
||||
if (supported) {
|
||||
// No need to seed the data here, it's already stored by the sdk.
|
||||
ownProfileTaskHandle = innerClient.subscribeToOwnProfile(ownProfileListener)
|
||||
} else {
|
||||
// Seed from the session store so cold-start UI has something to render before the network fetch.
|
||||
sessionStore.getSession(sessionId.value)?.let { sessionData ->
|
||||
_userProfile.emit(
|
||||
MatrixUser(
|
||||
@@ -314,12 +335,8 @@ class RustMatrixClient(
|
||||
)
|
||||
)
|
||||
}
|
||||
// Force a refresh of the profile
|
||||
getUserProfile()
|
||||
}
|
||||
|
||||
// Schedule regular database vacuuming to ensure DB performance remains optimal
|
||||
scheduleDatabaseVacuum()
|
||||
}
|
||||
|
||||
override fun userIdServerName(): String {
|
||||
@@ -458,13 +475,8 @@ class RustMatrixClient(
|
||||
|
||||
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
||||
.onSuccess { matrixUser ->
|
||||
_userProfile.emit(
|
||||
matrixUser.copy(
|
||||
rawStatus = localUserStatus,
|
||||
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
|
||||
)
|
||||
)
|
||||
// Also update our session storage
|
||||
_userProfile.emit(matrixUser)
|
||||
sessionStore.updateUserProfile(
|
||||
sessionId = sessionId.value,
|
||||
displayName = matrixUser.displayName,
|
||||
@@ -495,25 +507,31 @@ class RustMatrixClient(
|
||||
}
|
||||
|
||||
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
|
||||
localUserStatus = status
|
||||
_userProfile.emit(
|
||||
_userProfile.value.copy(
|
||||
rawStatus = status,
|
||||
displayedStatus = DisplayedStatus.UserSet(status),
|
||||
)
|
||||
)
|
||||
Result.success(Unit)
|
||||
runCatchingExceptions {
|
||||
innerClient.setUserStatus(status.into())
|
||||
}.onSuccess {
|
||||
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
|
||||
if (ownProfileTaskHandle == null) getUserProfile()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
|
||||
localUserStatus = null
|
||||
_userProfile.emit(
|
||||
_userProfile.value.copy(
|
||||
rawStatus = null,
|
||||
displayedStatus = null,
|
||||
)
|
||||
)
|
||||
Result.success(Unit)
|
||||
runCatchingExceptions {
|
||||
innerClient.clearUserStatus()
|
||||
}.onSuccess {
|
||||
// The subscription pushes the update on supported servers; only refresh manually as a fallback.
|
||||
if (ownProfileTaskHandle == null) getUserProfile()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun isUserStatusSupported(): Result<Boolean> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerClient.isUserStatusSupported()
|
||||
}
|
||||
}
|
||||
|
||||
override fun enableAutomaticCallStatus(enabled: Boolean) {
|
||||
innerClient.enableAutomaticCallStatus(enabled)
|
||||
}
|
||||
|
||||
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
|
||||
@@ -621,6 +639,7 @@ class RustMatrixClient(
|
||||
|
||||
sessionCoroutineScope.cancel()
|
||||
clientDelegateTaskHandle?.cancelAndDestroy()
|
||||
ownProfileTaskHandle?.cancelAndDestroy()
|
||||
sessionVerificationService.destroy()
|
||||
|
||||
sessionDelegate.clearCurrentClient()
|
||||
@@ -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()
|
||||
|
||||
+13
-14
@@ -17,7 +17,7 @@ import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
|
||||
@@ -70,7 +70,7 @@ class RustMatrixClientFactory(
|
||||
private val clientBuilderProvider: ClientBuilderProvider,
|
||||
private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider,
|
||||
private val workManagerScheduler: WorkManagerScheduler,
|
||||
private val contentScannerUrlProvider: ContentScannerUrlProvider,
|
||||
private val contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory,
|
||||
) {
|
||||
private val sessionDelegate = RustClientSessionDelegate(
|
||||
sessionStore = sessionStore,
|
||||
@@ -135,26 +135,25 @@ class RustMatrixClientFactory(
|
||||
|
||||
client.setUtdDelegate(UtdTracker(analyticsService))
|
||||
|
||||
val domainName = UserId(client.userId()).domainName
|
||||
// If a content scanner URL is available for the homeserver, create a RustContentScanner and set it on the client.
|
||||
// This allows the SDK to use the content scanner for automatic media scanning.
|
||||
// If no content scanner URL is available, the contentScanner will be null.
|
||||
val contentScanner = domainName?.let {
|
||||
contentScannerUrlProvider.getContentScannerUrl(domainName)
|
||||
.getOrNull()
|
||||
?.let { contentScannerUrl ->
|
||||
val contentScanner = ContentScanner(contentScannerUrl)
|
||||
client.setContentScanner(contentScanner)
|
||||
RustContentScanner(
|
||||
client = client,
|
||||
rustScanner = contentScanner,
|
||||
)
|
||||
}
|
||||
val contentScannerUrlProvider = contentScannerUrlProviderFactory.create(RustTemporaryMatrixClient(client, null))
|
||||
val contentScanner = contentScannerUrlProvider.getContentScannerUrl(SessionId(client.userId()))
|
||||
.getOrNull()
|
||||
?.let { contentScannerUrl ->
|
||||
val contentScanner = ContentScanner(contentScannerUrl)
|
||||
client.setContentScanner(contentScanner)
|
||||
RustContentScanner(
|
||||
client = client,
|
||||
rustScanner = contentScanner,
|
||||
)
|
||||
}
|
||||
|
||||
val syncService = client.syncService()
|
||||
.withSharePos(true)
|
||||
.withOfflineMode()
|
||||
.withProfilesExtension()
|
||||
.finish()
|
||||
|
||||
return RustMatrixClient(
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
|
||||
import io.element.android.libraries.matrix.api.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
|
||||
class RustTemporaryMatrixClient(
|
||||
private val client: Client,
|
||||
private val paths: SessionPaths?,
|
||||
) : TemporaryMatrixClient {
|
||||
override suspend fun getUrl(url: String): Result<ByteArray> = runCatchingExceptions {
|
||||
client.getUrl(url)
|
||||
}.mapFailure { it.mapClientException() }
|
||||
|
||||
override fun close() {
|
||||
client.close()
|
||||
paths?.let {
|
||||
if (it.fileDirectory.exists()) {
|
||||
it.fileDirectory.deleteRecursively()
|
||||
}
|
||||
if (it.cacheDirectory.exists()) {
|
||||
it.cacheDirectory.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
|
||||
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class RustTemporaryMatrixClientFactory(
|
||||
private val sessionPathsFactory: SessionPathsFactory,
|
||||
private val rustMatrixClientFactory: RustMatrixClientFactory,
|
||||
) : TemporaryMatrixClientFactory {
|
||||
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> {
|
||||
return runCatchingExceptions {
|
||||
val sessionPaths = sessionPathsFactory.create()
|
||||
val client = rustMatrixClientFactory.getBaseClientBuilder(
|
||||
sessionPaths = sessionPaths,
|
||||
clientSecret = null,
|
||||
slidingSyncType = ClientBuilderSlidingSync.Native,
|
||||
isMessageSearchAvailable = false,
|
||||
)
|
||||
.homeserverUrl(homeServerUrl)
|
||||
.build()
|
||||
RustTemporaryMatrixClient(client, sessionPaths)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-7
@@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
|
||||
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
|
||||
import io.element.android.libraries.matrix.impl.RustTemporaryMatrixClient
|
||||
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
|
||||
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
|
||||
import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep
|
||||
@@ -133,9 +134,10 @@ class RustMatrixAuthenticationService(
|
||||
}
|
||||
|
||||
currentClient = client
|
||||
|
||||
client.homeserverLoginDetails().map()
|
||||
}.onFailure {
|
||||
clear()
|
||||
clear(destroyClient = true)
|
||||
}.mapFailure { failure ->
|
||||
Timber.e(failure, "Failed to set homeserver to $homeserver")
|
||||
failure.mapAuthenticationException()
|
||||
@@ -168,7 +170,7 @@ class RustMatrixAuthenticationService(
|
||||
sessionStore.addSession(sessionData)
|
||||
|
||||
// Clean up the strong reference held here since it's no longer necessary
|
||||
currentClient = null
|
||||
clear(destroyClient = false)
|
||||
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure { failure ->
|
||||
@@ -248,7 +250,7 @@ class RustMatrixAuthenticationService(
|
||||
sessionStore.addSession(sessionData)
|
||||
|
||||
// Clean up the strong reference held here since it's no longer necessary
|
||||
currentClient = null
|
||||
clear(destroyClient = false)
|
||||
|
||||
SessionId(sessionData.userId)
|
||||
}
|
||||
@@ -271,11 +273,13 @@ class RustMatrixAuthenticationService(
|
||||
deviceId = null,
|
||||
additionalScopes = emptyList(),
|
||||
)
|
||||
val getUrlResolver = RustTemporaryMatrixClient(client, sessionPaths)
|
||||
val url = oAuthAuthorizationData.loginUrl()
|
||||
.let {
|
||||
enterpriseService.tweakMasUrl(
|
||||
url = it,
|
||||
homeserver = client.server() ?: client.homeserver(),
|
||||
urlContentFetcher = getUrlResolver,
|
||||
)
|
||||
}
|
||||
pendingOAuthAuthorizationData = oAuthAuthorizationData
|
||||
@@ -335,7 +339,7 @@ class RustMatrixAuthenticationService(
|
||||
sessionStore.addSession(sessionData)
|
||||
|
||||
// Clean up the strong reference held here since it's no longer necessary
|
||||
currentClient = null
|
||||
clear(destroyClient = false)
|
||||
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure { failure ->
|
||||
@@ -398,7 +402,7 @@ class RustMatrixAuthenticationService(
|
||||
sessionStore.addSession(sessionData)
|
||||
|
||||
// Clean up the strong reference held here since it's no longer necessary
|
||||
currentClient = null
|
||||
clear(destroyClient = false)
|
||||
|
||||
SessionId(sessionData.userId)
|
||||
}.mapFailure {
|
||||
@@ -464,8 +468,10 @@ class RustMatrixAuthenticationService(
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun clear() {
|
||||
currentClient?.close()
|
||||
private fun clear(destroyClient: Boolean) {
|
||||
if (destroyClient) {
|
||||
currentClient?.close()
|
||||
}
|
||||
currentClient = null
|
||||
}
|
||||
|
||||
|
||||
+6
@@ -15,6 +15,7 @@ import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
@@ -108,4 +109,9 @@ object SessionMatrixModule {
|
||||
fun providesContentScanner(matrixClient: MatrixClient): ContentScanner? {
|
||||
return matrixClient.contentScanner
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun providesGetUrlResolverClient(matrixClient: MatrixClient): UrlContentFetcher {
|
||||
return matrixClient
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListServ
|
||||
import io.element.android.libraries.matrix.impl.roomdirectory.map
|
||||
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.user.map
|
||||
import io.element.android.libraries.matrix.impl.util.MessageEventContent
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
|
||||
|
||||
+6
-1
@@ -16,12 +16,15 @@ import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
|
||||
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.impl.room.history.map
|
||||
import io.element.android.libraries.matrix.impl.room.join.map
|
||||
import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper
|
||||
import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper
|
||||
import io.element.android.libraries.matrix.impl.room.tombstone.map
|
||||
import io.element.android.libraries.matrix.impl.user.from
|
||||
import io.element.android.libraries.matrix.impl.user.into
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import org.matrix.rustcomponents.sdk.Membership
|
||||
@@ -102,7 +105,9 @@ fun RustRoomNotificationMode.map(): RoomNotificationMode = when (this) {
|
||||
fun RoomHero.map(): MatrixUser = MatrixUser(
|
||||
userId = UserId(userId),
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl
|
||||
avatarUrl = avatarUrl,
|
||||
displayedStatus = DisplayedStatus.from(status, call),
|
||||
rawStatus = status?.into(),
|
||||
)
|
||||
|
||||
fun mapPowerLevels(roomPowerLevels: RustRoomPowerLevels): RoomPowerLevels {
|
||||
|
||||
+3
-2
@@ -11,7 +11,9 @@ package io.element.android.libraries.matrix.impl.room.member
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.impl.room.powerlevels.into
|
||||
import io.element.android.libraries.matrix.impl.user.from
|
||||
import uniffi.matrix_sdk.RoomMemberRole
|
||||
import org.matrix.rustcomponents.sdk.MembershipState as RustMembershipState
|
||||
import org.matrix.rustcomponents.sdk.RoomMember as RustRoomMember
|
||||
@@ -30,8 +32,7 @@ object RoomMemberMapper {
|
||||
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
|
||||
membershipChangeReason = roomMember.membershipChangeReason,
|
||||
isServiceMember = roomMember.isServiceMember,
|
||||
// TODO map this value when available
|
||||
displayedStatus = null,
|
||||
displayedStatus = DisplayedStatus.from(roomMember.status, roomMember.call),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.scanner
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
|
||||
/**
|
||||
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
|
||||
*/
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
|
||||
override suspend fun getContentScannerUrl(homeserver: String): Result<String?> = Result.success(null)
|
||||
}
|
||||
+3
-2
@@ -21,7 +21,9 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetail
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.impl.core.RustSendHandle
|
||||
import io.element.android.libraries.matrix.impl.user.from
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
@@ -75,8 +77,7 @@ fun RustProfileDetails.map(): ProfileDetails {
|
||||
displayName = displayName,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
avatarUrl = avatarUrl,
|
||||
// TODO map this value when available
|
||||
displayedStatus = null,
|
||||
displayedStatus = DisplayedStatus.from(status, call),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -6,9 +6,10 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.mapper
|
||||
package io.element.android.libraries.matrix.impl.user
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import org.matrix.rustcomponents.sdk.UserProfile
|
||||
|
||||
@@ -16,4 +17,6 @@ fun UserProfile.map() = MatrixUser(
|
||||
userId = UserId(userId),
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
rawStatus = status?.into(),
|
||||
displayedStatus = DisplayedStatus.from(status, call)
|
||||
)
|
||||
+1
-2
@@ -6,10 +6,9 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.usersearch
|
||||
package io.element.android.libraries.matrix.impl.user
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.impl.mapper.map
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.matrix.rustcomponents.sdk.SearchUsersResults
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.user
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import org.matrix.rustcomponents.sdk.UserCall
|
||||
import org.matrix.rustcomponents.sdk.UserStatus as RustUserStatus
|
||||
|
||||
fun UserStatus.into(): RustUserStatus {
|
||||
return RustUserStatus(emoji = emoji, text = text)
|
||||
}
|
||||
|
||||
fun RustUserStatus.into(): UserStatus {
|
||||
return UserStatus(emoji = emoji, text = text)
|
||||
}
|
||||
|
||||
fun DisplayedStatus.Companion.from(userStatus: RustUserStatus?, userCall: UserCall?): DisplayedStatus? {
|
||||
return when {
|
||||
userStatus != null -> DisplayedStatus.UserSet(userStatus.into())
|
||||
userCall != null -> DisplayedStatus.InCall(userCall.callJoinedTs?.toLong())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ import io.element.android.libraries.matrix.api.core.FlowId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationRequestDetails
|
||||
import io.element.android.libraries.matrix.api.verification.VerificationRequest
|
||||
import io.element.android.libraries.matrix.impl.mapper.map
|
||||
import io.element.android.libraries.matrix.impl.user.map
|
||||
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails
|
||||
|
||||
fun RustSessionVerificationRequestDetails.map() = SessionVerificationRequestDetails(
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ fun TestScope.createRustMatrixClientFactory(
|
||||
),
|
||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
contentScannerUrlProvider: ContentScannerUrlProvider = { Result.success(null) },
|
||||
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
|
||||
) = RustMatrixClientFactory(
|
||||
cacheDirectory = cacheDirectory,
|
||||
appCoroutineScope = backgroundScope,
|
||||
@@ -67,5 +67,5 @@ fun TestScope.createRustMatrixClientFactory(
|
||||
clientBuilderProvider = clientBuilderProvider,
|
||||
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
contentScannerUrlProvider = contentScannerUrlProvider,
|
||||
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
|
||||
)
|
||||
|
||||
+31
-3
@@ -32,9 +32,9 @@ import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
@@ -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(
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
|
||||
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
|
||||
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
|
||||
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
|
||||
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
|
||||
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class RustTemporaryMatrixClientFactoryTest {
|
||||
@Test
|
||||
fun `create returns a TemporaryMatrixClient`() = runTest {
|
||||
val sut = createRustTemporaryMatrixClientFactory()
|
||||
val result = sut.create("https://matrix.org")
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create can fail gracefully`() = runTest {
|
||||
val sut = createRustTemporaryMatrixClientFactory(
|
||||
rustMatrixClientFactory = createRustMatrixClientFactory(
|
||||
clientBuilderProvider = FakeClientBuilderProvider(
|
||||
provideResult = { throw IllegalStateException("Failed to create client builder") }
|
||||
)
|
||||
)
|
||||
)
|
||||
val result = sut.create("https://matrix.org")
|
||||
assertThat(result.isFailure).isTrue()
|
||||
}
|
||||
|
||||
private fun TestScope.createRustTemporaryMatrixClientFactory(
|
||||
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
|
||||
rustMatrixClientFactory: RustMatrixClientFactory = createRustMatrixClientFactory(),
|
||||
): RustTemporaryMatrixClientFactory {
|
||||
return RustTemporaryMatrixClientFactory(
|
||||
sessionPathsFactory = sessionPathsFactory,
|
||||
rustMatrixClientFactory = rustMatrixClientFactory,
|
||||
)
|
||||
}
|
||||
|
||||
private fun TestScope.createRustMatrixClientFactory(
|
||||
cacheDirectory: File = File("/cache"),
|
||||
sessionStore: SessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
),
|
||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
contentScannerUrlProviderFactory: ContentScannerUrlProvider.Factory = { ContentScannerUrlProvider { Result.success(null) } },
|
||||
) = RustMatrixClientFactory(
|
||||
cacheDirectory = cacheDirectory,
|
||||
appCoroutineScope = backgroundScope,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
sessionStore = sessionStore,
|
||||
userAgentProvider = SimpleUserAgentProvider(),
|
||||
proxyProvider = FakeProxyProvider(),
|
||||
clock = FakeSystemClock(),
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
featureFlagService = FakeFeatureFlagService(),
|
||||
timelineEventFilterFactory = FakeTimelineEventFilterFactory(),
|
||||
clientBuilderProvider = clientBuilderProvider,
|
||||
sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(),
|
||||
workManagerScheduler = workManagerScheduler,
|
||||
contentScannerUrlProviderFactory = contentScannerUrlProviderFactory,
|
||||
)
|
||||
}
|
||||
+24
@@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.test.auth.FakeOAuthRedirectUrlProvide
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -50,6 +51,29 @@ class RustMatrixAuthenticationServiceTest {
|
||||
assertThat(sut.setHomeserver("matrix.org").isSuccess).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setHomeserver can fail gracefully and clean up the temporary client`() = runTest {
|
||||
val closeResult = lambdaRecorder<Unit> {}
|
||||
val sut = createRustMatrixAuthenticationService(
|
||||
clientBuilderProvider = FakeClientBuilderProvider(
|
||||
provideResult = {
|
||||
FakeFfiClientBuilder(
|
||||
buildResult = {
|
||||
FakeFfiClient(
|
||||
homeserverLoginDetailsResult = {
|
||||
throw IllegalStateException("Failed to get homeserver login details")
|
||||
},
|
||||
closeResult = closeResult,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
assertThat(sut.setHomeserver("matrix.org").isFailure).isTrue()
|
||||
closeResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun TestScope.createRustMatrixAuthenticationService(
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||
|
||||
+20
@@ -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()
|
||||
}
|
||||
|
||||
+1
@@ -15,5 +15,6 @@ import org.matrix.rustcomponents.sdk.SyncServiceBuilder
|
||||
class FakeFfiSyncServiceBuilder : SyncServiceBuilder(NoHandle) {
|
||||
override fun withOfflineMode(): SyncServiceBuilder = this
|
||||
override fun withSharePos(enable: Boolean): SyncServiceBuilder = this
|
||||
override fun withProfilesExtension(): SyncServiceBuilder = this
|
||||
override suspend fun finish(): SyncService = FakeFfiSyncService()
|
||||
}
|
||||
|
||||
+1
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.mapper
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
|
||||
import io.element.android.libraries.matrix.impl.user.map
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import org.junit.Test
|
||||
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSearchUsersResults
|
||||
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
|
||||
import io.element.android.libraries.matrix.impl.user.UserSearchResultMapper
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import org.junit.Test
|
||||
|
||||
+16
@@ -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)
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.test
|
||||
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeTemporaryMatrixClient(
|
||||
private val getUrlResult: (String) -> Result<ByteArray> = { lambdaError() },
|
||||
private val closeLambda: () -> Unit = {},
|
||||
) : TemporaryMatrixClient {
|
||||
override suspend fun getUrl(url: String): Result<ByteArray> = getUrlResult(url)
|
||||
|
||||
override fun close() = closeLambda()
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.test
|
||||
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClient
|
||||
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
|
||||
|
||||
class FakeTemporaryMatrixClientFactory(
|
||||
private val createResult: (String) -> Result<TemporaryMatrixClient> = { Result.success(FakeTemporaryMatrixClient()) },
|
||||
) : TemporaryMatrixClientFactory {
|
||||
override suspend fun create(homeServerUrl: String): Result<TemporaryMatrixClient> = createResult(homeServerUrl)
|
||||
}
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+9
-7
@@ -27,7 +27,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
@Composable
|
||||
internal fun ContentErrorView(
|
||||
title: String,
|
||||
message: String,
|
||||
message: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
@@ -51,12 +51,14 @@ internal fun ContentErrorView(
|
||||
color = ElementTheme.colors.textCriticalPrimary,
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
)
|
||||
Text(
|
||||
text = message,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
onTextLayout = onTextLayout,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-18
@@ -19,7 +19,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -45,6 +44,7 @@ import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.getDisambiguatedDisplayName
|
||||
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentErrorView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentValidationValue
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.InvalidContentView
|
||||
import io.element.android.libraries.matrix.ui.media.contentvalidation.NotFoundContentView
|
||||
@@ -77,7 +77,7 @@ fun InReplyToView(
|
||||
else -> ReplyToLoadingContent(modifier = modifier)
|
||||
}
|
||||
is InReplyToDetails.Error ->
|
||||
ReplyToErrorContent(data = inReplyTo, maxLines = maxLines, modifier = modifier)
|
||||
ReplyToErrorContent(modifier = modifier)
|
||||
is InReplyToDetails.Loading ->
|
||||
ReplyToLoadingContent(modifier = modifier)
|
||||
}
|
||||
@@ -150,24 +150,15 @@ private fun ReplyToLoadingContent(
|
||||
|
||||
@Composable
|
||||
private fun ReplyToErrorContent(
|
||||
data: InReplyToDetails.Error,
|
||||
maxLines: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val paddings = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
||||
Row(
|
||||
modifier
|
||||
.background(ElementTheme.colors.bgCanvasDefault)
|
||||
.padding(paddings)
|
||||
) {
|
||||
Text(
|
||||
text = data.message,
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
color = ElementTheme.colors.textCriticalPrimary,
|
||||
maxLines = maxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
ContentErrorView(
|
||||
title = stringResource(CommonStrings.error_message_not_found),
|
||||
message = null,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
|
||||
onTextLayout = null,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+2
@@ -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>
|
||||
|
||||
+2
-1
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+40
@@ -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
|
||||
|
||||
+3
-1
@@ -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 ->
|
||||
|
||||
+5
-6
@@ -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>
|
||||
|
||||
+29
-1
@@ -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(),
|
||||
)
|
||||
|
||||
+47
-4
@@ -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>
|
||||
+135
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
+20
-5
@@ -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,
|
||||
|
||||
+89
@@ -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>
|
||||
|
||||
+7
-4
@@ -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 }
|
||||
|
||||
|
||||
+28
-3
@@ -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,
|
||||
|
||||
+4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,7 @@ plugins {
|
||||
android {
|
||||
namespace = "io.element.android.libraries.wellknown.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
||||
+4
@@ -11,4 +11,8 @@ interface ElementWellknownStore {
|
||||
suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown>
|
||||
suspend fun update(domain: String, wellknown: String): Result<Unit>
|
||||
suspend fun delete(domain: String): Result<Unit>
|
||||
|
||||
fun interface Factory {
|
||||
fun create(prefix: String?): ElementWellknownStore
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.wellknown.api
|
||||
|
||||
/**
|
||||
* The source from which the enterprise remote configuration is retrieved.
|
||||
*/
|
||||
enum class EnterpriseRemoteConfigSource {
|
||||
/**
|
||||
* Retrieve the enterprise remote configuration from the Element Server Suite configuration (ESS_CONFIG) endpoint.
|
||||
*/
|
||||
ESS_CONFIG,
|
||||
|
||||
/**
|
||||
* Retrieve the enterprise remote configuration from the Element server's well-known endpoint.
|
||||
*/
|
||||
WELLKNOWN_ENDPOINT,
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.wellknown.api
|
||||
|
||||
interface SessionWellknownRetriever {
|
||||
suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown>
|
||||
}
|
||||
+10
-1
@@ -8,6 +8,15 @@
|
||||
|
||||
package io.element.android.libraries.wellknown.api
|
||||
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
|
||||
interface WellknownRetriever {
|
||||
suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown>
|
||||
suspend fun getElementWellKnown(
|
||||
host: String,
|
||||
source: EnterpriseRemoteConfigSource
|
||||
): WellknownRetrieverResult<ElementWellKnown>
|
||||
|
||||
interface Factory {
|
||||
fun create(urlContentFetcher: UrlContentFetcher): WellknownRetriever
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -35,4 +35,11 @@ sealed interface WellknownRetrieverResult<out T> {
|
||||
is Error -> null
|
||||
NotFound -> null
|
||||
}
|
||||
|
||||
fun upToDateDataOrNull(): T? = when (this) {
|
||||
is Success<T> -> data
|
||||
is Outdated<T> -> null
|
||||
is Error -> null
|
||||
NotFound -> null
|
||||
}
|
||||
}
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.SessionWellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultSessionWellknownRetriever(
|
||||
private val matrixClient: MatrixClient,
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val elementWellknownStore: ElementWellknownStore,
|
||||
private val elementWellKnownParser: ElementWellKnownParser,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
) : SessionWellknownRetriever {
|
||||
private val domain by lazy { matrixClient.userIdServerName() }
|
||||
|
||||
override suspend fun getElementWellKnown(): WellknownRetrieverResult<ElementWellKnown> {
|
||||
val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown()
|
||||
if (overriddenElementWellKnown != null) {
|
||||
return WellknownRetrieverResult.Success(overriddenElementWellKnown)
|
||||
}
|
||||
|
||||
val cacheData = elementWellknownStore.get(domain)
|
||||
return when (cacheData) {
|
||||
is WellknownRetrieverResult.Success -> {
|
||||
Timber.d("Using cached well-known for domain $domain")
|
||||
cacheData
|
||||
}
|
||||
is WellknownRetrieverResult.Outdated -> {
|
||||
// Return the outdated data but refresh in the background
|
||||
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
|
||||
Timber.d("Outdated cached well-known for domain $domain, returning existing value and fetching new one from network")
|
||||
sessionCoroutineScope.launch {
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
fetchElementWellKnown(url)
|
||||
}
|
||||
cacheData
|
||||
}
|
||||
is WellknownRetrieverResult.NotFound -> {
|
||||
// Try to fetch from the server
|
||||
Timber.d("No cached well-known for domain $domain, fetching from network")
|
||||
val url = "https://$domain/.well-known/element/element.json"
|
||||
fetchElementWellKnown(url)
|
||||
}
|
||||
is WellknownRetrieverResult.Error -> {
|
||||
// Return the error
|
||||
Timber.e(cacheData.exception, "Error retrieving well-known for domain $domain")
|
||||
cacheData.exception.toWellknownRetrieverResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchElementWellKnown(url: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
return matrixClient
|
||||
.getUrl(url)
|
||||
.mapCatchingExceptions {
|
||||
val data = String(it)
|
||||
val parsed = elementWellKnownParser.parse(data).getOrThrow()
|
||||
// Also store in cache, if valid
|
||||
elementWellknownStore.update(domain, data)
|
||||
.onFailure { exception ->
|
||||
Timber.e(exception, "Failed to parse cached Element .well-known data for $domain, deleting cache")
|
||||
elementWellknownStore.delete(domain)
|
||||
}
|
||||
parsed
|
||||
}
|
||||
.toWellknownRetrieverResult()
|
||||
}
|
||||
|
||||
private fun <T> Result<T>.toWellknownRetrieverResult(): WellknownRetrieverResult<T> = fold(
|
||||
onSuccess = {
|
||||
WellknownRetrieverResult.Success(it)
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to retrieve Element .well-known from $domain")
|
||||
// This check on message value is not ideal but this is what we got from the SDK.
|
||||
it.toWellknownRetrieverResult()
|
||||
}
|
||||
)
|
||||
|
||||
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
|
||||
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Error(this as Exception)
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -8,6 +8,9 @@
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.cachestore.api.CacheData
|
||||
import io.element.android.libraries.cachestore.api.CacheStore
|
||||
@@ -18,12 +21,19 @@ import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedInject
|
||||
class DefaultWellKnownStore(
|
||||
@Assisted
|
||||
private val prefix: String?,
|
||||
private val cacheStore: CacheStore,
|
||||
private val elementWellKnownParser: ElementWellKnownParser,
|
||||
private val systemClock: SystemClock,
|
||||
) : ElementWellknownStore {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(AppScope::class)
|
||||
fun interface Factory : ElementWellknownStore.Factory {
|
||||
override fun create(prefix: String?): DefaultWellKnownStore
|
||||
}
|
||||
override suspend fun get(domain: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
return runCatchingExceptions {
|
||||
val cachedData = cacheStore.getData(key(domain))
|
||||
@@ -54,7 +64,11 @@ class DefaultWellKnownStore(
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(domain: String): String = "https://$domain/.well-known/element/element.json"
|
||||
private fun key(domain: String): String = if (prefix != null) {
|
||||
"$prefix-$domain"
|
||||
} else {
|
||||
"https://$domain/.well-known/element/element.json"
|
||||
}
|
||||
|
||||
companion object {
|
||||
// 1 day
|
||||
|
||||
+106
-37
@@ -9,65 +9,134 @@
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.androidutils.json.JsonProvider
|
||||
import io.element.android.libraries.androidutils.text.takeIfNotBlank
|
||||
import io.element.android.libraries.core.extensions.mapCatchingExceptions
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.uri.ensureProtocol
|
||||
import io.element.android.libraries.network.RetrofitFactory
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import retrofit2.HttpException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedInject
|
||||
class DefaultWellknownRetriever(
|
||||
private val retrofitFactory: RetrofitFactory,
|
||||
private val elementWellknownStore: ElementWellknownStore,
|
||||
private val jsonProvider: JsonProvider,
|
||||
private val elementWellknownStoreFactory: ElementWellknownStore.Factory,
|
||||
private val enterpriseService: EnterpriseService,
|
||||
private val elementWellKnownParser: ElementWellKnownParser,
|
||||
@Assisted private val urlContentFetcher: UrlContentFetcher,
|
||||
@AppCoroutineScope private val coroutineScope: CoroutineScope,
|
||||
) : WellknownRetriever {
|
||||
override suspend fun getElementWellKnown(baseUrl: String): WellknownRetrieverResult<ElementWellKnown> {
|
||||
@ContributesBinding(AppScope::class)
|
||||
@AssistedFactory
|
||||
fun interface Factory : WellknownRetriever.Factory {
|
||||
override fun create(urlContentFetcher: UrlContentFetcher): DefaultWellknownRetriever
|
||||
}
|
||||
|
||||
override suspend fun getElementWellKnown(
|
||||
host: String,
|
||||
source: EnterpriseRemoteConfigSource
|
||||
): WellknownRetrieverResult<ElementWellKnown> {
|
||||
val overriddenElementWellKnown = enterpriseService.overriddenElementWellKnown()
|
||||
if (overriddenElementWellKnown != null) {
|
||||
return WellknownRetrieverResult.Success(overriddenElementWellKnown)
|
||||
}
|
||||
|
||||
val domain = URL(baseUrl).host
|
||||
return buildWellknownApi(baseUrl)
|
||||
.map { wellknownApi ->
|
||||
try {
|
||||
val json = jsonProvider()
|
||||
val result = wellknownApi.getElementWellKnown()
|
||||
elementWellknownStore.update(domain, json.encodeToString(result))
|
||||
WellknownRetrieverResult.Success(result.map())
|
||||
} catch (e: Exception) {
|
||||
// Is it a 404?
|
||||
Timber.e(e, "Failed to retrieve Element well-known data for $baseUrl")
|
||||
if ((e as? HttpException)?.code() == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Error(e)
|
||||
}
|
||||
}
|
||||
// The passed `host` function may be a full URL, in that case we're only interested in the actual host
|
||||
val checkedHost = runCatchingExceptions { URL(host.ensureProtocol()).host.takeIfNotBlank() }
|
||||
.getOrElse { return WellknownRetrieverResult.Error(IllegalArgumentException("host parameter is not valid", it)) }
|
||||
?: host
|
||||
|
||||
// We instantiate different stores for different sources, so that we can cache them separately.
|
||||
// The ESS_CONFIG source is cached with a prefix to differentiate it from the WELLKNOWN_ENDPOINT.
|
||||
val prefix = when (source) {
|
||||
EnterpriseRemoteConfigSource.ESS_CONFIG -> "ess_config"
|
||||
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> null
|
||||
}
|
||||
val store = elementWellknownStoreFactory.create(prefix)
|
||||
return when (val cacheData = store.get(checkedHost)) {
|
||||
is WellknownRetrieverResult.Success -> {
|
||||
Timber.d("Using cached well-known for domain $checkedHost")
|
||||
cacheData
|
||||
}
|
||||
.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = { WellknownRetrieverResult.Error(it as Exception) }
|
||||
)
|
||||
is WellknownRetrieverResult.Outdated -> {
|
||||
// Return the outdated data but refresh in the background
|
||||
// If the cache is missing or outdated, trigger a refresh in background but still return the cached value
|
||||
Timber.d("Outdated cached well-known for domain $checkedHost, returning existing value and fetching new one from network")
|
||||
coroutineScope.launch { fetchElementWellKnown(checkedHost, source, store) }
|
||||
cacheData
|
||||
}
|
||||
is WellknownRetrieverResult.NotFound -> {
|
||||
// Try to fetch from the server
|
||||
Timber.d("No cached well-known for domain $checkedHost, fetching from network")
|
||||
fetchElementWellKnown(checkedHost, source, store)
|
||||
}
|
||||
is WellknownRetrieverResult.Error -> {
|
||||
// Return the error
|
||||
Timber.e(cacheData.exception, "Error retrieving well-known for domain $checkedHost")
|
||||
cacheData.exception.toWellknownRetrieverResult()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildWellknownApi(accountProviderUrl: String): Result<WellknownAPI> {
|
||||
return runCatchingExceptions {
|
||||
retrofitFactory.create(accountProviderUrl.ensureProtocol())
|
||||
.create(WellknownAPI::class.java)
|
||||
}.onFailure { e ->
|
||||
// If the base URL is not valid, we cannot retrieve the well-known data
|
||||
Timber.e(e, "Failed to create Retrofit instance for $accountProviderUrl")
|
||||
private suspend fun fetchElementWellKnown(
|
||||
host: String,
|
||||
source: EnterpriseRemoteConfigSource,
|
||||
store: ElementWellknownStore
|
||||
): WellknownRetrieverResult<ElementWellKnown> {
|
||||
return urlContentFetcher
|
||||
.getUrl(url(host, source))
|
||||
.mapCatchingExceptions {
|
||||
val data = String(it)
|
||||
val parsed = elementWellKnownParser.parse(data).getOrThrow()
|
||||
// Also store in cache, if valid
|
||||
store.update(host, data)
|
||||
.onFailure { exception ->
|
||||
Timber.e(exception, "Failed to parse cached Element .well-known data for $host, deleting cache")
|
||||
store.delete(host)
|
||||
}
|
||||
parsed
|
||||
}
|
||||
.toWellknownRetrieverResult(host)
|
||||
}
|
||||
|
||||
private fun <T> Result<T>.toWellknownRetrieverResult(host: String): WellknownRetrieverResult<T> = fold(
|
||||
onSuccess = {
|
||||
WellknownRetrieverResult.Success(it)
|
||||
},
|
||||
onFailure = {
|
||||
Timber.e(it, "Failed to retrieve Element .well-known from $host")
|
||||
// This check on message value is not ideal but this is what we got from the SDK.
|
||||
it.toWellknownRetrieverResult()
|
||||
}
|
||||
)
|
||||
|
||||
private fun <T> Throwable.toWellknownRetrieverResult(): WellknownRetrieverResult<T> {
|
||||
return if ((this as? ClientException.Generic)?.message?.contains("404") == true) {
|
||||
WellknownRetrieverResult.NotFound
|
||||
} else {
|
||||
WellknownRetrieverResult.Error(this as Exception)
|
||||
}
|
||||
}
|
||||
|
||||
private fun url(host: String, source: EnterpriseRemoteConfigSource): String {
|
||||
val elementWellKnownUrl = "https://$host/.well-known/element/element.json"
|
||||
return when (source) {
|
||||
EnterpriseRemoteConfigSource.ESS_CONFIG -> enterpriseService.essConfigEndpointUrl(host) ?: elementWellKnownUrl
|
||||
EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT -> elementWellKnownUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import dev.zacsweers.metro.Provides
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.UrlContentFetcher
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
|
||||
import io.element.android.libraries.wellknown.api.ElementWellknownStore
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetriever
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
@BindingContainer
|
||||
@ContributesTo(SessionScope::class)
|
||||
object SessionWellknownModule {
|
||||
@Provides
|
||||
fun provideWellKnownRetriever(
|
||||
elementWellknownStoreFactory: ElementWellknownStore.Factory,
|
||||
enterpriseService: EnterpriseService,
|
||||
elementWellKnownParser: ElementWellKnownParser,
|
||||
urlContentFetcher: UrlContentFetcher,
|
||||
@SessionCoroutineScope coroutineScope: CoroutineScope,
|
||||
): WellknownRetriever {
|
||||
return DefaultWellknownRetriever(
|
||||
elementWellknownStoreFactory = elementWellknownStoreFactory,
|
||||
enterpriseService = enterpriseService,
|
||||
elementWellKnownParser = elementWellKnownParser,
|
||||
urlContentFetcher = urlContentFetcher,
|
||||
coroutineScope = coroutineScope
|
||||
)
|
||||
}
|
||||
}
|
||||
-376
@@ -1,376 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.libraries.wellknown.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeEnterpriseService
|
||||
import io.element.android.features.wellknown.test.FakeElementWellknownStore
|
||||
import io.element.android.features.wellknown.test.anElementWellKnown
|
||||
import io.element.android.libraries.androidutils.json.DefaultJsonProvider
|
||||
import io.element.android.libraries.matrix.api.exception.ClientException
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.wellknown.api.CustomRecoveryPassphrase
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnown
|
||||
import io.element.android.libraries.wellknown.api.ElementWellKnownParser
|
||||
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class DefaultSessionWellknownRetrieverTest {
|
||||
@Test
|
||||
fun `get empty element wellknown`() = runTest {
|
||||
val getUrlLambda = lambdaRecorder<String, Result<ByteArray>> {
|
||||
Result.success("{}".toByteArray())
|
||||
}
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = getUrlLambda,
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = null,
|
||||
enforceElementPro = null,
|
||||
rageshakeUrl = null,
|
||||
brandColor = null,
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = null,
|
||||
forceDisableE2EE = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
getUrlLambda.assertions().isCalledOnce()
|
||||
.with(value("https://user.domain.org/.well-known/element/element.json"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with full content`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success(
|
||||
WELLKNOWN_CONTENT.toByteArray()
|
||||
)
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
rageshakeUrl = "a_rageshake_url",
|
||||
brandColor = "#FF0000",
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
forceDisableE2EE = false,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with unknown key`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success(
|
||||
"""{
|
||||
"registration_helper_url": "a_registration_url",
|
||||
"enforce_element_pro": true,
|
||||
"rageshake_url": "a_rageshake_url",
|
||||
// Note the trailing comma, and the comment!
|
||||
"other": true,
|
||||
}""".trimIndent().toByteArray()
|
||||
)
|
||||
},
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
rageshakeUrl = "a_rageshake_url",
|
||||
brandColor = null,
|
||||
notificationSound = null,
|
||||
identityProviderAppScheme = null,
|
||||
contentScannerUrl = null,
|
||||
customRecoveryPassphrase = null,
|
||||
forceDisableE2EE = null,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with custom recovery passphrase settings`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success(
|
||||
"""{
|
||||
"custom_recovery_passphrase": {
|
||||
"min_character_count": 8
|
||||
}
|
||||
}""".trimIndent().toByteArray()
|
||||
)
|
||||
},
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 8)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with custom recovery passphrase settings missing min character count floors to 1`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success(
|
||||
"""{
|
||||
"custom_recovery_passphrase": {}
|
||||
}""".trimIndent().toByteArray()
|
||||
)
|
||||
},
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with zero min character count floors to 1`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success(
|
||||
"""{
|
||||
"custom_recovery_passphrase": {
|
||||
"min_character_count": 0
|
||||
}
|
||||
}""".trimIndent().toByteArray()
|
||||
)
|
||||
},
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown with negative min character count floors to 1`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success(
|
||||
"""{
|
||||
"custom_recovery_passphrase": {
|
||||
"min_character_count": -5
|
||||
}
|
||||
}""".trimIndent().toByteArray()
|
||||
)
|
||||
},
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown(
|
||||
customRecoveryPassphrase = CustomRecoveryPassphrase(minCharacterCount = 1)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown json error`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success(
|
||||
"""{
|
||||
"registration_helper_url" = "a_registration_url",
|
||||
error
|
||||
}""".trimIndent().toByteArray()
|
||||
)
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown network error`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.failure(AN_EXCEPTION)
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown 404 http error counts as not found`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.failure(ClientException.Generic("Not Found: 404 status code", "The file could not be found"))
|
||||
}
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.NotFound::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting cache`() = runTest {
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = { lambdaError() },
|
||||
cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Success(parsedWellKnownContent())
|
||||
)
|
||||
)
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
rageshakeUrl = "a_rageshake_url",
|
||||
brandColor = "#FF0000",
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
forceDisableE2EE = false,
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting cache containing invalid json`() = runTest {
|
||||
val cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Error(IllegalStateException("Invalid JSON"))
|
||||
)
|
||||
)
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success("{}".toByteArray())
|
||||
},
|
||||
cacheStore = cacheStore,
|
||||
elementWellKnownParser = { Result.failure(IllegalStateException("Failed to parse JSON")) }
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isInstanceOf(WellknownRetrieverResult.Error::class.java)
|
||||
// Ensure that the cache is deleted after the failure to parse it
|
||||
assertThat(cacheStore.get(WELLKNOWN_URL)).isEqualTo(WellknownRetrieverResult.NotFound)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown hitting outdated cache`() = runTest {
|
||||
val cacheStore = FakeElementWellknownStore(
|
||||
initialData = mapOf(
|
||||
WELLKNOWN_URL to WellknownRetrieverResult.Outdated(parsedWellKnownContent()),
|
||||
)
|
||||
)
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = {
|
||||
Result.success("{}".toByteArray())
|
||||
},
|
||||
cacheStore = cacheStore,
|
||||
)
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Outdated(
|
||||
ElementWellKnown(
|
||||
registrationHelperUrl = "a_registration_url",
|
||||
enforceElementPro = true,
|
||||
rageshakeUrl = "a_rageshake_url",
|
||||
brandColor = "#FF0000",
|
||||
notificationSound = "a_notification_sound.flac",
|
||||
identityProviderAppScheme = "an_app_scheme",
|
||||
customRecoveryPassphrase = null,
|
||||
contentScannerUrl = "https://content-scanner.example.com",
|
||||
forceDisableE2EE = false,
|
||||
)
|
||||
)
|
||||
)
|
||||
// Next call returns the updated value
|
||||
runCurrent()
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(
|
||||
anElementWellKnown()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `get element wellknown was overridden`() = runTest {
|
||||
val getLambda = lambdaRecorder<String, Result<ByteArray>> { Result.failure(IllegalStateException("BOOM")) }
|
||||
val wellKnown = anElementWellKnown()
|
||||
|
||||
val sut = createDefaultSessionWellknownRetriever(
|
||||
getUrlLambda = getLambda,
|
||||
enterpriseService = FakeEnterpriseService(
|
||||
overrideWellKnownResult = { wellKnown }
|
||||
)
|
||||
)
|
||||
|
||||
// The overridden value is returned
|
||||
assertThat(sut.getElementWellKnown()).isEqualTo(
|
||||
WellknownRetrieverResult.Success(wellKnown)
|
||||
)
|
||||
|
||||
// And the endpoint is never hit
|
||||
getLambda.assertions().isNeverCalled()
|
||||
}
|
||||
|
||||
private fun parsedWellKnownContent() = DefaultJsonProvider().invoke().decodeFromString<InternalElementWellKnown>(WELLKNOWN_CONTENT).map()
|
||||
|
||||
private fun TestScope.createDefaultSessionWellknownRetriever(
|
||||
cacheStore: FakeElementWellknownStore = FakeElementWellknownStore(),
|
||||
getUrlLambda: (String) -> Result<ByteArray>,
|
||||
elementWellKnownParser: ElementWellKnownParser = DefaultElementWellKnownParser(DefaultJsonProvider()),
|
||||
enterpriseService: FakeEnterpriseService = FakeEnterpriseService(overrideWellKnownResult = { null }),
|
||||
) = DefaultSessionWellknownRetriever(
|
||||
matrixClient = FakeMatrixClient(
|
||||
userIdServerNameLambda = { "user.domain.org" },
|
||||
getUrlLambda = getUrlLambda,
|
||||
),
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
elementWellknownStore = cacheStore,
|
||||
elementWellKnownParser = elementWellKnownParser,
|
||||
enterpriseService = enterpriseService,
|
||||
)
|
||||
|
||||
companion object {
|
||||
private const val WELLKNOWN_URL = "https://user.domain.org/.well-known/element/element.json"
|
||||
private const val WELLKNOWN_CONTENT = """{
|
||||
"registration_helper_url": "a_registration_url",
|
||||
"enforce_element_pro": true,
|
||||
"rageshake_url": "a_rageshake_url",
|
||||
"brand_color": "#FF0000",
|
||||
"notification_sound": "a_notification_sound.flac",
|
||||
"idp_app_scheme": "an_app_scheme",
|
||||
"content_scanner_url": "https://content-scanner.example.com",
|
||||
"force_disable_e2ee": false,
|
||||
}"""
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user