Merge branch 'develop' into fix/1612-redaction-reason

This commit is contained in:
hayaksi1
2026-09-03 03:02:35 +03:00
1121 changed files with 7527 additions and 3389 deletions
@@ -42,6 +42,32 @@ fun File.safeRenameTo(dest: File) {
)
}
/**
* Returns this file name with [suffix] inserted before its extension, i.e. `"image.png"` with the
* suffix `"_1"` gives `"image_1.png"`. A name without extension simply gets the suffix appended.
*/
fun String.withFileNameSuffix(suffix: String): String {
val extension = substringAfterLast('.', "")
return if (extension.isEmpty()) {
"$this$suffix"
} else {
"${substringBeforeLast('.')}$suffix.$extension"
}
}
fun <T> saveWithUniqueFileName(
fileName: String,
uniqueSuffix: () -> String = { "_${System.currentTimeMillis()}" },
saveAs: (String) -> T,
): T {
return try {
saveAs(fileName)
} catch (nameConflict: IllegalStateException) {
Timber.w(nameConflict, "Unable to build a unique file name, retrying with a suffixed one")
saveAs(fileName.withFileNameSuffix(uniqueSuffix()))
}
}
fun Context.createTmpFile(baseDir: File = cacheDir, extension: String? = null): File {
val suffix = extension?.let { ".$extension" }
return File.createTempFile(UUID.randomUUID().toString(), suffix, baseDir).apply { mkdirs() }
@@ -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.
*/
package io.element.android.libraries.androidutils.file
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class FileTest {
@Test
fun `withFileNameSuffix - inserts the suffix before the extension`() {
assertThat("image.png".withFileNameSuffix("_1")).isEqualTo("image_1.png")
}
@Test
fun `withFileNameSuffix - appends the suffix when there is no extension`() {
assertThat("README".withFileNameSuffix("_1")).isEqualTo("README_1")
}
@Test
fun `withFileNameSuffix - only considers the last extension`() {
assertThat("archive.tar.gz".withFileNameSuffix("_1")).isEqualTo("archive.tar_1.gz")
}
@Test
fun `withFileNameSuffix - handles a name starting with a dot`() {
assertThat(".hidden".withFileNameSuffix("_1")).isEqualTo("_1.hidden")
}
@Test
fun `saveWithUniqueFileName - uses the original name when it works`() {
val usedNames = mutableListOf<String>()
val result = saveWithUniqueFileName(
fileName = "image.png",
uniqueSuffix = { "_1" },
) { name ->
usedNames.add(name)
"saved as $name"
}
assertThat(result).isEqualTo("saved as image.png")
assertThat(usedNames).containsExactly("image.png")
}
@Test
fun `saveWithUniqueFileName - retries with a suffixed name when the name cannot be made unique`() {
val usedNames = mutableListOf<String>()
val result = saveWithUniqueFileName(
fileName = "image.png",
uniqueSuffix = { "_1" },
) { name ->
usedNames.add(name)
if (name == "image.png") {
error("Failed to build unique file")
}
"saved as $name"
}
assertThat(result).isEqualTo("saved as image_1.png")
assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder()
}
@Test
fun `saveWithUniqueFileName - does not retry more than once`() {
val usedNames = mutableListOf<String>()
val error = try {
saveWithUniqueFileName(
fileName = "image.png",
uniqueSuffix = { "_1" },
) { name ->
usedNames.add(name)
error("Failed to build unique file")
}
null
} catch (nameConflict: IllegalStateException) {
nameConflict
}
assertThat(error).isInstanceOf(IllegalStateException::class.java)
assertThat(usedNames).containsExactly("image.png", "image_1.png").inOrder()
}
}
@@ -0,0 +1,31 @@
/*
* 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.core.log.logger
import kotlin.math.max
/**
* Wraps a list of strings in a box with borders.
* Allow to print pretty logs.
*
* @param minBoxInsideWidth The minimum width of the box inside, default is 80.
* @return A list of strings representing the boxed lines.
*/
fun List<String>.wrapInBox(
minBoxInsideWidth: Int = 80,
): List<String> {
val maxLength = maxOfOrNull { it.length } ?: 0
val boxWidth = max(maxLength, minBoxInsideWidth) + 4
val topBorder = "┌" + "─".repeat(boxWidth - 2) + "┐"
val bottomBorder = "└" + "─".repeat(boxWidth - 2) + "┘"
val boxedLines = map { line ->
val padding = " ".repeat(max(0, boxWidth - line.length - 4))
"│ $line$padding │"
}
return listOf(topBorder) + boxedLines + listOf(bottomBorder)
}
@@ -10,6 +10,7 @@ package io.element.android.libraries.designsystem.atomic.atoms
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign
import io.element.android.compound.theme.ElementTheme
@@ -20,11 +21,12 @@ fun RoomPreviewTitleAtom(
title: String,
modifier: Modifier = Modifier,
fontStyle: FontStyle? = null,
style: TextStyle = ElementTheme.typography.fontHeadingLgBold
) {
Text(
modifier = modifier,
text = title,
style = ElementTheme.typography.fontHeadingLgBold,
style = style,
textAlign = TextAlign.Center,
fontStyle = fontStyle,
color = ElementTheme.colors.textPrimary,
@@ -0,0 +1,144 @@
/*
* 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.text
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
/**
* Render the title of a screen, but adapt the style if it does not fit on a single line,
* so that a wrapped title does not look oversized.
*
* @param title the title to render.
* @param style the style to use to render the [title].
* @param twoLinesStyle the style to use instead of [style] when the [title] does not fit on a single line with [style],
* so that a wrapped title does not look oversized.
* @param modifier the [Modifier] to apply to this layout.
*/
@Composable
fun AdaptativeTitle(
title: String,
style: TextStyle,
twoLinesStyle: TextStyle,
modifier: Modifier = Modifier,
) {
// If style and twoLinesStyle are the same, we don't need to measure the text.
if (style == twoLinesStyle) {
Text(
modifier = modifier.semantics {
heading()
},
style = style,
maxLines = 2,
text = title,
)
} else {
val textMeasurer = rememberTextMeasurer()
BoxWithConstraints(modifier = modifier) {
// Measure the title before rendering it, so that the style is picked in a single layout pass.
val fitsOnASingleLine = textMeasurer.measure(
text = title,
style = style,
constraints = constraints,
).lineCount == 1
Text(
modifier = Modifier.semantics {
heading()
},
style = if (fitsOnASingleLine) style else twoLinesStyle,
maxLines = 2,
text = title,
)
}
}
}
@PreviewsDayNight
@Composable
internal fun AdaptativeTitlePreview() = ElementPreview {
@Composable
fun AdaptativeTitleItem(
text: String,
content: @Composable () -> Unit,
) {
Column(
modifier = Modifier
.width(180.dp)
.background(ElementTheme.colors.bgSubtlePrimary),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = text,
style = ElementTheme.typography.fontBodyXsRegular,
color = ElementTheme.colors.textSecondary,
)
content()
}
}
Column(
modifier = Modifier.padding(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Fits on a single line: the large style is kept.
AdaptativeTitleItem("Adaptative, 1 line:") {
AdaptativeTitle(
title = "Chats",
style = ElementTheme.typography.fontHeadingLgBold,
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
)
}
// Would be rendered on two lines with the large style: the smaller style is used instead.
AdaptativeTitleItem("Adaptative, 2 lines:") {
AdaptativeTitle(
title = "Tutte le conversazioni:",
style = ElementTheme.typography.fontHeadingLgBold,
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
)
}
// Would be rendered on two lines with the large style: the smaller style is used instead
// and it's finally rendered on a single line with the smaller style.
AdaptativeTitleItem("Adaptative, but 1 line:") {
AdaptativeTitle(
title = "Tous les chats",
style = ElementTheme.typography.fontHeadingLgBold,
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
)
}
// Short title, with small font, 1 line.
AdaptativeTitleItem("Always small, 1 line:") {
AdaptativeTitle(
title = "My space",
style = ElementTheme.typography.fontHeadingSmMedium,
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
)
}
// Space name, which can still be rendered on two lines.
AdaptativeTitleItem("Always small, 2 lines:") {
AdaptativeTitle(
title = "A very long space name",
style = ElementTheme.typography.fontHeadingSmMedium,
twoLinesStyle = ElementTheme.typography.fontHeadingSmMedium,
)
}
}
}
+4
View File
@@ -5,6 +5,8 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
import extension.testCommonDependencies
plugins {
id("io.element.android-library")
}
@@ -27,4 +29,6 @@ dependencies {
implementation(libs.google.tink)
implementation(projects.libraries.androidutils)
testCommonDependencies(libs)
}
@@ -51,12 +51,28 @@ class SqlCipherDriverFactory(
is ClientSecret.Passphrase -> secret.formattedAsString().hexToByteArray()
}
val factory = SupportOpenHelperFactory(key)
return AndroidSqliteDriver(schema = schema, context = context, name = name, factory = factory, callback = object : AndroidSqliteDriver.Callback(
schema
) {
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
}
})
return AndroidSqliteDriver(
schema = schema,
context = context,
name = name,
factory = factory,
callback = MigrationCallback(schema, onUpgradeCallback),
)
}
}
/**
* Runs [onUpgradeCallback] and then the schema migrations themselves.
*
* @param schema The SQLite DB schema, whose migrations are applied by [AndroidSqliteDriver.Callback.onUpgrade].
* @param onUpgradeCallback Called before the migrations run, so that the database key can be replaced first.
*/
internal class MigrationCallback(
schema: SqlSchema<QueryResult.Value<Unit>>,
private val onUpgradeCallback: ((driver: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) -> Unit)?,
) : AndroidSqliteDriver.Callback(schema) {
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgradeCallback?.invoke(db, oldVersion, newVersion)
super.onUpgrade(db, oldVersion, newVersion)
}
}
@@ -0,0 +1,55 @@
/*
* 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.encrypteddb
import app.cash.sqldelight.db.AfterVersion
import app.cash.sqldelight.db.QueryResult
import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import com.google.common.truth.Truth.assertThat
import io.mockk.mockk
import org.junit.Test
class MigrationCallbackTest {
@Test
fun `onUpgrade migrates the schema`() {
val schema = RecordingSchema()
MigrationCallback(schema, onUpgradeCallback = null).onUpgrade(mockk(relaxed = true), 9, 11)
assertThat(schema.migrations).containsExactly(9L to 11L)
}
@Test
fun `onUpgrade replaces the database key before migrating the schema`() {
val order = mutableListOf<String>()
val schema = RecordingSchema(onMigrate = { order.add("migrate") })
MigrationCallback(schema, onUpgradeCallback = { _, _, _ -> order.add("rekey") })
.onUpgrade(mockk(relaxed = true), 9, 11)
assertThat(order).containsExactly("rekey", "migrate").inOrder()
}
}
private class RecordingSchema(
private val onMigrate: () -> Unit = {},
) : SqlSchema<QueryResult.Value<Unit>> {
val migrations = mutableListOf<Pair<Long, Long>>()
override val version = 11L
override fun create(driver: SqlDriver) = QueryResult.Unit
override fun migrate(
driver: SqlDriver,
oldVersion: Long,
newVersion: Long,
vararg callbacks: AfterVersion,
): QueryResult.Value<Unit> {
migrations.add(oldVersion to newVersion)
onMigrate()
return QueryResult.Unit
}
}
@@ -56,7 +56,7 @@ class DefaultPinnedMessagesBannerFormatter(
is PollContent -> {
content.question.prefixWith(CommonStrings.a11y_poll)
}
RedactedContent -> {
is RedactedContent -> {
sp.getString(CommonStrings.common_message_removed)
}
else -> {
@@ -89,7 +89,7 @@ class DefaultRoomLatestEventFormatter(
): CharSequence? {
return when (content) {
is MessageContent -> content.process(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
RedactedContent -> {
is RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
message.prefixIfNeeded(senderDisambiguatedDisplayName, isDmRoom, isOutgoing)
}
@@ -62,7 +62,7 @@ class DefaultTimelineEventFormatter(
is CallNotifyContent -> {
sp.getString(CommonStrings.common_call_started)
}
RedactedContent,
is RedactedContent,
is LegacyCallInviteContent,
is StickerContent,
is PollContent,
@@ -49,9 +49,9 @@
<string name="state_event_room_none_by_you">"Du hast keine Änderungen vorgenommen"</string>
<string name="state_event_room_pinned_events_changed">"%1$s hat die fixierten Nachrichten geändert"</string>
<string name="state_event_room_pinned_events_changed_by_you">"Du hast die fixierten Nachrichten geändert"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s fixierte eine Nachricht"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s hat eine Nachricht fixiert"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"Du hast eine Nachricht fixiert"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s löste eine Nachricht"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s hat eine Nachricht gelöst"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"Du hast eine Nachricht gelöst"</string>
<string name="state_event_room_reject">"%1$s lehnte die Einladung ab"</string>
<string name="state_event_room_reject_by_you">"Du hast die Einladung abgelehnt"</string>
@@ -49,8 +49,8 @@
<string name="state_event_room_none_by_you">"你未产生任何更改"</string>
<string name="state_event_room_pinned_events_changed">"%1$s 更改了已置顶的消息"</string>
<string name="state_event_room_pinned_events_changed_by_you">"你更改了已置顶的消息"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s 置顶了 1 个消息"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"你已置顶了 1 个消息"</string>
<string name="state_event_room_pinned_events_pinned">"%1$s 已置顶 1 个消息"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"你已置顶 1 个消息"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s 取消置顶了 1 个消息"</string>
<string name="state_event_room_pinned_events_unpinned_by_you">"你取消置顶了 1 个消息"</string>
<string name="state_event_room_reject">"%1$s 拒绝了邀请"</string>
@@ -81,7 +81,7 @@ class DefaultPinnedMessagesBannerFormatterTest : RobolectricTest() {
fun `Redacted content`() {
val expected = "Message removed"
val senderName = "Someone"
val message = createRoomEvent(false, senderName, RedactedContent)
val message = createRoomEvent(false, senderName, RedactedContent(threadInfo = null))
val result = formatter.format(message)
assertThat(result).isEqualTo(expected)
}
@@ -85,7 +85,7 @@ class DefaultRoomLatestEventFormatterTest : RobolectricTest() {
val expected = "Message removed"
val senderName = "Someone"
sequenceOf(false, true).forEach { isDm ->
val message = createLatestEvent(false, senderName, RedactedContent)
val message = createLatestEvent(false, senderName, RedactedContent(threadInfo = null))
val result = formatter.format(message, isDm)
if (isDm) {
assertThat(result).isEqualTo(expected)
@@ -14,11 +14,16 @@ import io.element.android.libraries.matrix.api.roomlist.LatestEventValue
class FakeRoomLatestEventFormatter : RoomLatestEventFormatter {
private var result: CharSequence? = null
var lastIsDmRoom: Boolean? = null
private set
override fun format(latestEvent: LatestEventValue.Local, isDmRoom: Boolean): CharSequence? {
lastIsDmRoom = isDmRoom
return result
}
override fun format(latestEvent: LatestEventValue.Remote, isDmRoom: Boolean): CharSequence? {
lastIsDmRoom = isDmRoom
return result
}
@@ -45,13 +45,6 @@ enum class FeatureFlags(
defaultValue = { false },
isFinished = false,
),
Knock(
key = "feature.knock",
title = "Ask to join",
description = "Allow creating rooms which users can request access to.",
defaultValue = { false },
isFinished = false,
),
PrintLogsToLogcat(
key = "feature.print_logs_to_logcat",
title = "Print logs to logcat",
@@ -151,6 +144,15 @@ enum class FeatureFlags(
description = "Allow sending multiple media items in a single message.",
defaultValue = { false },
isFinished = false,
isInLabs = true,
),
Knock(
key = "feature.knock",
title = "Ask to join",
description = "Allow creating rooms which users can request access to.",
defaultValue = { false },
isFinished = false,
isInLabs = true,
),
MessageSearch(
key = "feature.message_search",
@@ -10,7 +10,6 @@ package io.element.android.libraries.matrix.api.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
@@ -47,13 +46,6 @@ interface MatrixAuthenticationService {
*/
suspend fun login(username: String, password: String): Result<SessionId>
/**
* Import a session that was created using another client, for instance Element Web.
*
* @param externalSession the tokens and identifiers of the session to import.
*/
suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId>
/*
* OAuth part.
*/
@@ -1,20 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 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.matrix.api.auth.external
/***
* Represents a session data of a session created by another client.
*/
data class ExternalSession(
val userId: String,
val deviceId: String,
val accessToken: String,
val refreshToken: String?,
val homeserverUrl: String,
)
@@ -35,6 +35,7 @@ sealed interface StateEventType {
data object RoomImagePack : StateEventType
data object RoomLanguage : StateEventType
data object RoomPolicy : StateEventType
data object RoomRetention : StateEventType
data class Custom(val type: String) : StateEventType
}
@@ -32,7 +32,9 @@ data class MessageContent(
val type: MessageType,
) : EventContent
data object RedactedContent : EventContent
data class RedactedContent(
val threadInfo: EventThreadInfo?,
) : EventContent
data class StickerContent(
val filename: String,
@@ -51,6 +51,7 @@ data class EventTimelineItem(
is PollContent -> content.threadInfo
is StickerContent -> content.threadInfo
is UnableToDecryptContent -> content.threadInfo
is RedactedContent -> content.threadInfo
else -> null
}
@@ -99,7 +99,6 @@ class RustMatrixClientFactory(
isMessageSearchAvailable = isMessageSearchAvailable,
)
.homeserverUrl(sessionData.homeserverUrl)
.username(sessionData.userId)
.let { (clientBuilderEnterpriseHook(RustMatrixClientBuilder(it), SessionId(sessionData.userId)) as RustMatrixClientBuilder).inner }
.use { it.build() }
@@ -27,7 +27,6 @@ import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
import io.element.android.libraries.matrix.api.auth.SessionRestorationException
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
@@ -44,7 +43,6 @@ import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.keys.SecretGenerator
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
import io.element.android.libraries.matrix.impl.toSession
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CancellationException
@@ -232,39 +230,6 @@ class RustMatrixAuthenticationService(
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
withContext(coroutineDispatchers.io) {
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
val sessionData = externalSession.toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
// We restore the client using the just retrieved session data
client.restoreSession(sessionData.toSession())
val matrixClient = rustMatrixClientFactory.create(client, sessionData, isMessageSearchAvailable())
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
// We wait for the verification state to be known
matrixClient.waitForKnownVerificationState()
// And once it's ready we share it and save the actual session data
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
// Clean up the strong reference held here since it's no longer necessary
clear(destroyClient = false)
SessionId(sessionData.userId)
}
}
private var pendingOAuthAuthorizationData: OAuthAuthorizationData? = null
override suspend fun getOAuthUrl(
@@ -8,7 +8,6 @@
package io.element.android.libraries.matrix.impl.mapper
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.sessionstorage.api.LoginType
import io.element.android.libraries.sessionstorage.api.SessionData
@@ -40,27 +39,3 @@ internal fun Session.toSessionData(
userDisplayName = null,
userAvatarUrl = null,
)
internal fun ExternalSession.toSessionData(
isTokenValid: Boolean,
loginType: LoginType,
passphrase: String?,
sessionPaths: SessionPaths,
) = SessionData(
userId = userId,
deviceId = deviceId,
accessToken = accessToken,
refreshToken = refreshToken,
homeserverUrl = homeserverUrl,
oAuthData = null,
loginTimestamp = Date(),
isTokenValid = isTokenValid,
loginType = loginType,
passphrase = passphrase,
sessionPath = sessionPaths.fileDirectory.absolutePath,
cachePath = sessionPaths.cacheDirectory.absolutePath,
position = 0,
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
)
@@ -265,7 +265,7 @@ class JoinedRustRoom(
filter = filter,
internalIdPrefix = internalIdPrefix,
dateDividerMode = dateDividerMode,
trackReadReceipts = if (trackReadReceipts) TimelineReadReceiptTracking.ALL_EVENTS else TimelineReadReceiptTracking.DISABLED,
trackReadReceipts = if (trackReadReceipts) TimelineReadReceiptTracking.MESSAGE_LIKE_EVENTS else TimelineReadReceiptTracking.DISABLED,
reportUtds = true,
)
).let { innerTimeline ->
@@ -140,7 +140,7 @@ class RustRoomFactory(
filter = timelineFilter?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All,
internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS,
trackReadReceipts = TimelineReadReceiptTracking.MESSAGE_LIKE_EVENTS,
reportUtds = true,
)
)
@@ -39,6 +39,7 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack
StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage
StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy
StateEventType.RoomRetention -> RustStateEventType.RoomRetention
is StateEventType.Custom -> stateEventTypeFromString(type)
}
@@ -69,5 +70,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack
RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage
RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy
RustStateEventType.RoomRetention -> StateEventType.RoomRetention
is RustStateEventType.Custom -> StateEventType.Custom(this.toString())
}
@@ -81,7 +81,7 @@ class TimelineEventContentMapper(
)
}
is MsgLikeKind.Redacted -> {
RedactedContent
RedactedContent(threadInfo = extractThreadInfo(it.content))
}
is MsgLikeKind.Poll -> {
PollContent(
@@ -266,6 +266,9 @@ class RustSessionVerificationService(
if (!isInitialized.get()) {
encryptionService.waitForE2eeInitializationTasks()
isInitialized.set(true)
// Also update the state, because update may have been ignored by VerificationStateListener.onUpdate
// If we do not do that the FTUE is stuck on blank screen.
updateVerificationStatus()
}
if (!this::verificationController.isInitialized) {
@@ -42,7 +42,7 @@ class FakeFfiClientBuilder(
override fun setSessionDelegate(sessionDelegate: ClientSessionDelegate) = this
override fun slidingSyncVersionBuilder(versionBuilder: SlidingSyncVersionBuilder) = this
override fun userAgent(userAgent: String) = this
override fun username(username: String) = this
override fun serverNameFromUserId(userId: String): ClientBuilder = this
override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this
override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this
override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this
@@ -9,7 +9,6 @@
package io.element.android.libraries.matrix.impl.mapper
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
import io.element.android.libraries.matrix.test.A_DEVICE_ID
@@ -67,51 +66,4 @@ class SessionKtTest {
)
assertThat(result.homeserverUrl).isEqualTo(A_HOMESERVER_URL_2)
}
@Test
fun `ExternalSession toSessionData compute the expected result`() {
val result = anExternalSession().toSessionData(
isTokenValid = true,
loginType = LoginType.PASSWORD,
passphrase = A_SECRET,
sessionPaths = SessionPaths(File("/a/file"), File("/a/cache")),
)
assertThat(result.userId).isEqualTo(A_USER_ID.value)
assertThat(result.deviceId).isEqualTo(A_DEVICE_ID.value)
assertThat(result.accessToken).isEqualTo("accessToken")
assertThat(result.refreshToken).isNull()
assertThat(result.homeserverUrl).isEqualTo(A_HOMESERVER_URL)
assertThat(result.isTokenValid).isTrue()
assertThat(result.oAuthData).isNull()
assertThat(result.loginType).isEqualTo(LoginType.PASSWORD)
assertThat(result.loginTimestamp).isNotNull()
assertThat(result.passphrase).isEqualTo(A_SECRET)
assertThat(result.sessionPath).isEqualTo("/a/file")
assertThat(result.cachePath).isEqualTo("/a/cache")
}
@Test
fun `ExternalSession toSessionData can change the validity of the token`() {
val result = anExternalSession().toSessionData(
isTokenValid = false,
loginType = LoginType.PASSWORD,
passphrase = A_SECRET,
sessionPaths = SessionPaths(File("/a/file"), File("/a/cache")),
)
assertThat(result.isTokenValid).isFalse()
}
}
private fun anExternalSession(
userId: String = A_USER_ID.value,
deviceId: String = A_DEVICE_ID.value,
accessToken: String = "accessToken",
refreshToken: String? = null,
homeserverUrl: String = A_HOMESERVER_URL,
) = ExternalSession(
userId = userId,
deviceId = deviceId,
accessToken = accessToken,
refreshToken = refreshToken,
homeserverUrl = homeserverUrl,
)
@@ -14,7 +14,6 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.matrix.api.auth.OAuthPrompt
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
@@ -32,7 +31,6 @@ class FakeMatrixAuthenticationService(
var matrixClientResult: ((SessionId) -> Result<MatrixClient>)? = null,
var loginWithQrCodeResult: (qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) -> Result<SessionId> =
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
@@ -66,10 +64,6 @@ class FakeMatrixAuthenticationService(
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> = simulateLongTask {
return importCreatedSessionLambda(externalSession)
}
/** The login hint passed to the most recent [getOAuthUrl] call. */
var getOAuthUrlLoginHint: String? = null
private set
@@ -30,6 +30,7 @@ fun aNotificationData(
senderIsNameAmbiguous: Boolean = false,
roomDisplayName: String? = A_ROOM_NAME,
roomJoinRule: JoinRule? = null,
isNoisy: Boolean = false,
): NotificationData {
return NotificationData(
sessionId = A_SESSION_ID,
@@ -45,7 +46,7 @@ fun aNotificationData(
isDm = false,
isSpace = isSpace,
isEncrypted = false,
isNoisy = false,
isNoisy = isNoisy,
timestamp = timestamp,
content = content,
hasMention = hasMention,
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.PollContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
import io.element.android.libraries.matrix.api.timeline.item.event.Receipt
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.SendHandleProvider
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@@ -137,6 +138,10 @@ fun aStickerContent(
threadInfo = threadInfo,
)
fun aRedactedContent(
threadInfo: EventThreadInfo? = null,
) = RedactedContent(threadInfo = threadInfo)
fun aTimelineItemDebugInfo(
model: String = "Rust(Model())",
originalJson: String? = null,
@@ -46,7 +46,7 @@ fun SpaceHeaderRootView(
)
Text(
text = stringResource(CommonStrings.screen_space_list_title),
style = ElementTheme.typography.fontHeadingLgBold,
style = ElementTheme.typography.fontHeadingMdBold,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
@@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
@@ -68,11 +69,15 @@ fun SpaceHeaderView(
},
title = {
if (name != null) {
RoomPreviewTitleAtom(title = name)
RoomPreviewTitleAtom(
title = name,
style = ElementTheme.typography.fontHeadingMdBold,
)
} else {
RoomPreviewTitleAtom(
title = stringResource(id = CommonStrings.common_no_space_name),
fontStyle = FontStyle.Italic
fontStyle = FontStyle.Italic,
style = ElementTheme.typography.fontHeadingMdBold,
)
}
},
@@ -19,7 +19,8 @@ import org.jsoup.safety.Safelist
/**
* Converts the HTML string [FormattedBody.body] to a [Document] by parsing it.
* If the message is not formatted or the format is not [MessageFormat.HTML] we return `null`.
* If the message is not formatted, the format is not [MessageFormat.HTML], or nothing renderable is left once the
* unsupported tags have been removed, we return `null` so that the plain text body is used instead.
*
* This will also make sure mentions are prefixed with `@`.
*
@@ -44,7 +45,7 @@ fun FormattedBody.toHtmlDocument(
// Prepend `@` to mentions
fixMentions(dom, permalinkParser)
dom
dom.takeIf { it.text().isNotBlank() }
}
}
@@ -118,7 +118,7 @@ class InReplyToDetailsDisambiguatedPreviewParam : InReplyToDetailsPreviewParam()
class InReplyToDetailsInformativePreviewParam : InReplyToDetailsPreviewParam() {
override val values: Sequence<InReplyToDetails>
get() = sequenceOf(
RedactedContent,
RedactedContent(threadInfo = null),
UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null),
).map {
aInReplyToDetails(
@@ -103,4 +103,28 @@ class ToHtmlDocumentTest : RobolectricTest() {
})
assertThat(document?.text()).isEqualTo("Hey Alice!")
}
@Test
fun `toHtmlDocument - returns null if the body only contains unsupported tags`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "<img data-mx-emoticon src=\"mxc://matrix.org/anImage\" height=\"32\" width=\"32\" alt=\"\uD83D\uDE1C\" />"
)
val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document).isNull()
}
@Test
fun `toHtmlDocument - returns a Document if some text survives next to unsupported tags`() {
val body = FormattedBody(
format = MessageFormat.HTML,
body = "Look at this <img src=\"mxc://matrix.org/anImage\" alt=\"cat\" />!"
)
val document = body.toHtmlDocument(permalinkParser = FakePermalinkParser())
assertThat(document?.text()).isEqualTo("Look at this !")
}
}
@@ -149,4 +149,16 @@ class ToPlainTextTest : RobolectricTest() {
)
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("This is the message content.")
}
@Test
fun `TextMessageType toPlainText - returns the body if the formatted one only contains an image`() {
val messageType = TextMessageType(
body = "\uD83D\uDE1C",
formatted = FormattedBody(
format = MessageFormat.HTML,
body = "<img data-mx-emoticon src=\"mxc://matrix.org/anImage\" height=\"32\" width=\"32\" alt=\"\uD83D\uDE1C\" />"
)
)
assertThat(messageType.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo("\uD83D\uDE1C")
}
}
@@ -656,7 +656,7 @@ class InReplyToMetadataKtTest : RobolectricTest() {
fun `redacted content`() = runTest {
moleculeFlow(RecompositionMode.Immediate) {
anInReplyToDetailsReady(
eventContent = RedactedContent
eventContent = RedactedContent(threadInfo = null)
).metadata(hideImage = false)
}.test {
awaitItem().let {
@@ -0,0 +1,44 @@
/*
* 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.mediaviewer.api
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
/**
* Renders content which is not a Matrix media as a file, the same way the media viewer does.
*
* The content is provided by the caller and written to a temporary file, so that it can be
* displayed, shared and saved on disk.
*/
interface FileViewerEntryPoint : FeatureEntryPoint {
fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: Params,
callback: Callback,
): Node
/**
* @param filename the name of the file, used as the screen title and for the shared and saved files.
* @param mimeType the mime type of [content], it drives how the content is rendered.
* @param content the whole content of the file.
*/
data class Params(
val filename: String,
val mimeType: String,
val content: String,
) : NodeInputs
interface Callback : Plugin {
fun onDone()
}
}
@@ -79,7 +79,7 @@ class EventItemFactory(
LegacyCallInviteContent,
is PollContent,
is ProfileChangeContent,
RedactedContent,
is RedactedContent,
is RoomMembershipContent,
is StateContent,
is StickerContent,
@@ -0,0 +1,30 @@
/*
* 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.mediaviewer.impl.fileviewer
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
@ContributesBinding(AppScope::class)
class DefaultFileViewerEntryPoint : FileViewerEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: FileViewerEntryPoint.Params,
callback: FileViewerEntryPoint.Callback,
): Node {
return parentNode.createNode<FileViewerNode>(
buildContext = buildContext,
plugins = listOf(params, callback),
)
}
}
@@ -0,0 +1,13 @@
/*
* 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.mediaviewer.impl.fileviewer
sealed interface FileViewerEvent {
data object Share : FileViewerEvent
data object SaveOnDisk : FileViewerEvent
}
@@ -0,0 +1,47 @@
/*
* 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.mediaviewer.impl.fileviewer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
@ContributesNode(AppScope::class)
@AssistedInject
class FileViewerNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: FileViewerPresenter.Factory,
private val textFileViewer: TextFileViewer,
) : Node(buildContext, plugins = plugins) {
private val callback: FileViewerEntryPoint.Callback = callback()
private val inputs = inputs<FileViewerEntryPoint.Params>()
private val presenter = presenterFactory.create(params = inputs)
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
FileViewerView(
state = state,
textFileViewer = textFileViewer,
onBackClick = callback::onDone,
modifier = modifier,
)
}
}
@@ -0,0 +1,143 @@
/*
* 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.mediaviewer.impl.fileviewer
import android.content.ActivityNotFoundException
import android.content.Context
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.rememberCoroutineScope
import androidx.core.net.toUri
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import io.element.android.libraries.androidutils.R as UtilsR
@AssistedInject
class FileViewerPresenter(
@Assisted private val params: FileViewerEntryPoint.Params,
@ApplicationContext private val context: Context,
private val coroutineDispatchers: CoroutineDispatchers,
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
private val localMediaActions: LocalMediaActions,
) : Presenter<FileViewerState> {
@AssistedFactory
fun interface Factory {
fun create(params: FileViewerEntryPoint.Params): FileViewerPresenter
}
// Use a local snackbarDispatcher, because this screen can be rendered from any Node.
private val snackbarDispatcher = SnackbarDispatcher()
@Composable
override fun present(): FileViewerState {
val coroutineScope = rememberCoroutineScope()
val localMedia = remember { mutableStateOf<AsyncData<LocalMedia>>(AsyncData.Uninitialized) }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
LaunchedEffect(Unit) {
suspend { createLocalMedia() }.runCatchingUpdatingState(localMedia)
}
fun handleEvent(event: FileViewerEvent) {
when (event) {
FileViewerEvent.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
FileViewerEvent.Share -> coroutineScope.share(localMedia.value)
}
}
return FileViewerState(
filename = params.filename,
localMedia = localMedia.value,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent,
)
}
/**
* Write the content to a temporary file, so that it can be rendered, shared and saved on disk.
* The file is always written to the same location, so it does not need to be cleaned up.
*/
private suspend fun createLocalMedia(): LocalMedia = withContext(coroutineDispatchers.io) {
val folder = File(context.cacheDir, "temp/fileviewer").apply { mkdirs() }
val file = File(folder, params.filename)
file.writeText(params.content)
LocalMedia(
uri = file.toUri(),
info = MediaInfo(
filename = params.filename,
caption = null,
mimeType = params.mimeType,
fileSize = file.length(),
formattedFileSize = fileSizeFormatter.format(file.length()),
fileExtension = fileExtensionExtractor.extractFromName(params.filename),
senderId = null,
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
),
)
}
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.saveOnDisk(localMedia.data)
.onSuccess {
snackbarDispatcher.post(SnackbarMessage(CommonStrings.common_file_saved_on_disk_android))
}
.onFailure {
snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it)))
}
}
}
private fun CoroutineScope.share(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.share(localMedia.data)
.onFailure {
snackbarDispatcher.post(SnackbarMessage(mediaActionsError(it)))
}
}
}
private fun mediaActionsError(throwable: Throwable): Int {
return if (throwable is ActivityNotFoundException) {
UtilsR.string.error_no_compatible_app_found
} else {
CommonStrings.error_unknown
}
}
}
@@ -0,0 +1,19 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.mediaviewer.impl.fileviewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
data class FileViewerState(
val filename: String,
val localMedia: AsyncData<LocalMedia>,
val snackbarMessage: SnackbarMessage?,
val eventSink: (FileViewerEvent) -> Unit,
)
@@ -0,0 +1,62 @@
/*
* 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.mediaviewer.impl.fileviewer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.mediaviewer.api.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
private const val A_FILENAME = "push_rules@alice_server.org.json"
open class FileViewerStatePreviewParam : PreviewParameterProvider<FileViewerState> {
override val values: Sequence<FileViewerState>
get() = sequenceOf(
aFileViewerState(),
aFileViewerState(localMedia = AsyncData.Failure(Exception("A failure"))),
aFileViewerState(
localMedia = AsyncData.Success(
LocalMedia(
uri = Uri.EMPTY,
info = aJsonMediaInfo(),
)
)
),
)
}
fun aFileViewerState(
filename: String = A_FILENAME,
localMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
eventSink: (FileViewerEvent) -> Unit = {},
) = FileViewerState(
filename = filename,
localMedia = localMedia,
snackbarMessage = null,
eventSink = eventSink,
)
fun aJsonMediaInfo(
filename: String = A_FILENAME,
) = MediaInfo(
filename = filename,
caption = null,
mimeType = MimeTypes.Json,
fileSize = 2 * 1024,
formattedFileSize = "2kB",
fileExtension = "json",
senderId = null,
senderName = null,
senderAvatar = null,
dateSent = null,
dateSentFull = null,
waveform = null,
duration = null,
)
@@ -0,0 +1,134 @@
/*
* 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(ExperimentalMaterial3Api::class)
package io.element.android.libraries.mediaviewer.impl.fileviewer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.viewfolder.api.TextFileViewer
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.async.AsyncFailure
import io.element.android.libraries.designsystem.components.async.AsyncLoading
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaView
import io.element.android.libraries.mediaviewer.impl.util.bgCanvasWithTransparency
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun FileViewerView(
state: FileViewerState,
textFileViewer: TextFileViewer,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
Scaffold(
modifier = modifier,
topBar = {
FileViewerTopBar(
filename = state.filename,
actionsEnabled = state.localMedia.isSuccess(),
onBackClick = onBackClick,
onShareClick = { state.eventSink(FileViewerEvent.Share) },
onSaveClick = { state.eventSink(FileViewerEvent.SaveOnDisk) },
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
when (val localMedia = state.localMedia) {
AsyncData.Uninitialized,
is AsyncData.Loading -> AsyncLoading(
modifier = Modifier.padding(padding),
)
is AsyncData.Failure -> AsyncFailure(
modifier = Modifier.padding(padding),
throwable = localMedia.error,
onRetry = null,
)
is AsyncData.Success -> {
// Do not apply the padding, the content is rendered behind the translucent top bar,
// the same way the media viewer does.
LocalMediaView(
localMedia = localMedia.data,
bottomPaddingInPixels = 0,
audioFocus = null,
onClick = {},
onOpenWith = null,
textFileViewer = textFileViewer,
forPreview = false,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@Composable
private fun FileViewerTopBar(
filename: String,
actionsEnabled: Boolean,
onBackClick: () -> Unit,
onShareClick: () -> Unit,
onSaveClick: () -> Unit,
) {
TopAppBar(
titleStr = filename,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = bgCanvasWithTransparency,
),
navigationIcon = { BackButton(onClick = onBackClick) },
actions = {
IconButton(
onClick = onShareClick,
enabled = actionsEnabled,
) {
Icon(
imageVector = CompoundIcons.ShareAndroid(),
contentDescription = stringResource(id = CommonStrings.action_share),
)
}
IconButton(
onClick = onSaveClick,
enabled = actionsEnabled,
) {
Icon(
imageVector = CompoundIcons.Download(),
contentDescription = stringResource(id = CommonStrings.action_download),
)
}
},
)
}
@PreviewsDayNight
@Composable
internal fun FileViewerViewPreview(
@PreviewParameter(FileViewerStatePreviewParam::class) state: FileViewerState,
) = ElementPreview {
FileViewerView(
state = state,
textFileViewer = { _, _ -> },
onBackClick = {},
)
}
@@ -32,6 +32,7 @@ import androidx.core.content.PermissionChecker
import androidx.core.net.toFile
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.file.saveWithUniqueFileName
import io.element.android.libraries.androidutils.system.startInstallFromSourceIntent
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
@@ -44,6 +45,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
@ContributesBinding(AppScope::class)
@@ -172,18 +174,18 @@ class AndroidLocalMediaActions(
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.info.filename)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val outputUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (outputUri != null) {
localMedia.openStream()?.use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
val outputUri = saveWithUniqueFileName(localMedia.info.filename) { displayName ->
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.info.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
} ?: throw IOException("Unable to create the destination file")
localMedia.openStream()?.use { input ->
resolver.openOutputStream(outputUri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
}
}
@@ -59,7 +59,7 @@ fun LocalMediaView(
forPreview = forPreview,
modifier = modifier,
)
mimeType == MimeTypes.PlainText -> TextFileView(
mimeType == MimeTypes.PlainText || mimeType == MimeTypes.Json -> TextFileView(
localMedia = localMedia,
textFileViewer = textFileViewer,
modifier = modifier,
@@ -16,6 +16,7 @@
<string name="screen_media_details_filename">"Dateiname"</string>
<string name="screen_media_details_no_more_files_to_show">"Keine weiteren Dateien zum Anzeigen"</string>
<string name="screen_media_details_no_more_media_to_show">"Keine weiteren Medien mehr zum Anzeigen"</string>
<string name="screen_media_details_title">"Datei-Info"</string>
<string name="screen_media_details_uploaded_by">"Hochgeladen von"</string>
<string name="screen_media_details_uploaded_on">"Hochgeladen am"</string>
</resources>
@@ -35,7 +35,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherState
import io.element.android.libraries.matrix.api.timeline.item.event.RedactedContent
import io.element.android.libraries.matrix.api.timeline.item.event.StateContent
import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@@ -49,6 +48,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.timeline.aMessageContent
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
import io.element.android.libraries.matrix.test.timeline.aRedactedContent
import io.element.android.libraries.matrix.test.timeline.aStickerContent
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.item.event.aRoomMembershipContent
@@ -80,7 +80,7 @@ class EventItemFactoryTest {
LegacyCallInviteContent,
aPollContent(),
aProfileChangeMessageContent(),
RedactedContent,
aRedactedContent(),
aRoomMembershipContent(
userId = A_USER_ID,
),
@@ -0,0 +1,73 @@
/*
* 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.mediaviewer.impl.fileviewer
import android.content.Context
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class DefaultFileViewerEntryPointTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun `test node builder`() = runTest {
val entryPoint = DefaultFileViewerEntryPoint()
val parentNode = TestParentNode.create { buildContext, plugins ->
FileViewerNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = { nodeParams ->
FileViewerPresenter(
params = nodeParams,
context = mockk<Context>(relaxed = true),
coroutineDispatchers = testCoroutineDispatchers(),
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
localMediaActions = FakeLocalMediaActions(),
)
},
textFileViewer = { _, _ -> lambdaError() },
)
}
val params = FileViewerEntryPoint.Params(
filename = "a file.json",
mimeType = MimeTypes.Json,
content = "{}",
)
val callback = object : FileViewerEntryPoint.Callback {
override fun onDone() = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
buildContext = BuildContext.root(null),
params = params,
callback = callback,
)
assertThat(result).isInstanceOf(FileViewerNode::class.java)
assertThat(result.plugins).contains(params)
assertThat(result.plugins).contains(callback)
}
}
@@ -0,0 +1,100 @@
/*
* 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.mediaviewer.impl.fileviewer
import android.content.ActivityNotFoundException
import androidx.core.net.toFile
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions
import io.element.android.libraries.mediaviewer.test.FakeLocalMediaActions
import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractorWithoutValidation
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
import io.element.android.libraries.androidutils.R as UtilsR
private const val A_FILENAME = "push_rules@alice_server.org.json"
private const val A_CONTENT = """{"global":{"override":[]}}"""
class FileViewerPresenterTest : RobolectricTest() {
@Test
fun `present - writes the content to a file and exposes it as a local media`() = runTest {
val presenter = createFileViewerPresenter()
presenter.test {
assertThat(awaitItem().localMedia).isEqualTo(AsyncData.Uninitialized)
consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().also { state ->
assertThat(state.filename).isEqualTo(A_FILENAME)
val localMedia = state.localMedia.dataOrNull()!!
assertThat(localMedia.info.filename).isEqualTo(A_FILENAME)
assertThat(localMedia.info.mimeType).isEqualTo(MimeTypes.Json)
assertThat(localMedia.info.fileExtension).isEqualTo("json")
val file = localMedia.uri.toFile()
assertThat(file.name).isEqualTo(A_FILENAME)
assertThat(file.readText()).isEqualTo(A_CONTENT)
}
}
}
@Test
fun `present - SaveOnDisk event saves the file and posts a snackbar message`() = runTest {
val saveOnDiskResult = lambdaRecorder<LocalMedia, Result<Unit>> { Result.success(Unit) }
val presenter = createFileViewerPresenter(
localMediaActions = FakeLocalMediaActions(saveOnDiskResult = saveOnDiskResult),
)
presenter.test {
consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.SaveOnDisk)
consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state ->
assertThat(state.snackbarMessage?.messageResId).isEqualTo(CommonStrings.common_file_saved_on_disk_android)
}
saveOnDiskResult.assertions().isCalledOnce()
}
}
@Test
fun `present - Share event failure posts a snackbar message`() = runTest {
val shareResult = lambdaRecorder<LocalMedia, Result<Unit>> { Result.failure(ActivityNotFoundException()) }
val presenter = createFileViewerPresenter(
localMediaActions = FakeLocalMediaActions(shareResult = shareResult),
)
presenter.test {
consumeItemsUntilPredicate { it.localMedia.isSuccess() }.last().eventSink(FileViewerEvent.Share)
consumeItemsUntilPredicate { it.snackbarMessage != null }.last().also { state ->
assertThat(state.snackbarMessage?.messageResId).isEqualTo(UtilsR.string.error_no_compatible_app_found)
}
shareResult.assertions().isCalledOnce()
}
}
private fun TestScope.createFileViewerPresenter(
params: FileViewerEntryPoint.Params = FileViewerEntryPoint.Params(
filename = A_FILENAME,
mimeType = MimeTypes.Json,
content = A_CONTENT,
),
localMediaActions: LocalMediaActions = FakeLocalMediaActions(),
) = FileViewerPresenter(
params = params,
context = RuntimeEnvironment.getApplication(),
coroutineDispatchers = testCoroutineDispatchers(),
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
localMediaActions = localMediaActions,
)
}
@@ -0,0 +1,87 @@
/*
* 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.mediaviewer.impl.fileviewer
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.annotation.StringRes
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasContentDescription
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class FileViewerViewTest : RobolectricTest() {
@Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<FileViewerEvent>(expectEvents = false)
ensureCalledOnce {
setFileViewerView(
state = aFileViewerState(eventSink = eventsRecorder),
onBackClick = it,
)
pressBack()
}
}
@Test
fun `clicking on share emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<FileViewerEvent>()
setFileViewerView(
state = aFileViewerState(
localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())),
eventSink = eventsRecorder,
),
)
clickOnContentDescription(CommonStrings.action_share)
eventsRecorder.assertSingle(FileViewerEvent.Share)
}
@Test
fun `clicking on download emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<FileViewerEvent>()
setFileViewerView(
state = aFileViewerState(
localMedia = AsyncData.Success(LocalMedia(Uri.EMPTY, aJsonMediaInfo())),
eventSink = eventsRecorder,
),
)
clickOnContentDescription(CommonStrings.action_download)
eventsRecorder.assertSingle(FileViewerEvent.SaveOnDisk)
}
}
private fun AndroidComposeUiTest<ComponentActivity>.clickOnContentDescription(@StringRes res: Int) {
val text = activity!!.getString(res)
onNode(hasContentDescription(text) and hasClickAction()).performClick()
}
private fun AndroidComposeUiTest<ComponentActivity>.setFileViewerView(
state: FileViewerState,
onBackClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
FileViewerView(
state = state,
textFileViewer = { _, _ -> },
onBackClick = onBackClick,
)
}
}
@@ -12,18 +12,22 @@ import android.net.Uri
import androidx.activity.compose.LocalActivityResultRegistryOwner
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalContext
import androidx.core.net.toUri
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.matrix.test.core.aBuildMeta
import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia
import io.element.android.tests.testutils.fake.registerFakeMediaStoreContentProvider
import io.element.android.tests.testutils.robolectric.RobolectricTest
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.io.IOException
class AndroidLocalMediaActionsTest : RobolectricTest() {
@Test
@@ -62,6 +66,43 @@ class AndroidLocalMediaActionsTest : RobolectricTest() {
assertThat(result.exceptionOrNull()).isNotNull()
}
@Test
fun `test AndroidLocalMediaAction save on disk inserts the media under its own name`() = runTest {
val mediaStore = registerFakeMediaStoreContentProvider()
val sut = createAndroidLocalMediaActions()
val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri()))
assertThat(result.exceptionOrNull()).isNull()
assertThat(mediaStore.insertedDisplayNames).containsExactly("an image file.jpg")
}
@Test
fun `test AndroidLocalMediaAction save on disk retries with a suffixed name when the name cannot be made unique`() = runTest {
val mediaStore = registerFakeMediaStoreContentProvider().apply {
conflictingDisplayNames = setOf("an image file.jpg")
}
val sut = createAndroidLocalMediaActions()
val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri()))
assertThat(result.exceptionOrNull()).isNull()
assertThat(mediaStore.insertedDisplayNames).hasSize(2)
assertThat(mediaStore.insertedDisplayNames.last()).matches("an image file_\\d+\\.jpg")
}
@Test
fun `test AndroidLocalMediaAction save on disk fails when the destination cannot be created`() = runTest {
registerFakeMediaStoreContentProvider().apply {
returnsNoUriOnInsert = true
}
val sut = createAndroidLocalMediaActions()
val result = sut.saveOnDisk(aLocalMedia(aMediaFile().toUri()))
assertThat(result.exceptionOrNull()).isInstanceOf(IOException::class.java)
}
private fun aMediaFile(): File {
return File(RuntimeEnvironment.getApplication().cacheDir, "an image file.jpg").apply {
writeText("Some content")
}
}
private fun TestScope.createAndroidLocalMediaActions() = AndroidLocalMediaActions(
RuntimeEnvironment.getApplication(),
testCoroutineDispatchers(),
@@ -0,0 +1,22 @@
/*
* 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.mediaviewer.test
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.tests.testutils.lambda.lambdaError
class FakeFileViewerEntryPoint : FileViewerEntryPoint {
override fun createNode(
parentNode: Node,
buildContext: BuildContext,
params: FileViewerEntryPoint.Params,
callback: FileViewerEntryPoint.Callback,
): Node = lambdaError()
}
@@ -298,6 +298,7 @@ class DefaultNotifiableEventResolver(
roomId = roomId,
eventId = eventId,
cause = "Unable to decrypt event content",
noisy = isNoisy,
)
ResolvedPushEvent.Event(fallbackNotifiableEvent)
}
@@ -274,6 +274,12 @@ private fun NotifiableEvent.toFallbackNotifiableEvent(): FallbackNotifiableEvent
is FallbackNotifiableEvent -> timestamp
is NotifiableRingingCallEvent -> return null
}
val noisy = when (this) {
is NotifiableMessageEvent -> noisy
is InviteNotifiableEvent -> noisy
is SimpleNotifiableEvent -> noisy
is FallbackNotifiableEvent -> noisy
}
return FallbackNotifiableEvent(
sessionId = sessionId,
roomId = roomId,
@@ -283,6 +289,7 @@ private fun NotifiableEvent.toFallbackNotifiableEvent(): FallbackNotifiableEvent
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
noisy = noisy,
timestamp = timestamp,
cause = null,
)
@@ -24,6 +24,7 @@ class FallbackNotificationFactory(
roomId: RoomId,
eventId: EventId,
cause: String?,
noisy: Boolean,
): FallbackNotifiableEvent = FallbackNotifiableEvent(
sessionId = sessionId,
roomId = roomId,
@@ -32,6 +33,7 @@ class FallbackNotificationFactory(
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
noisy = noisy,
timestamp = clock.epochMillis(),
description = "",
cause = cause,
@@ -170,6 +170,7 @@ class DefaultNotificationResultProcessor(
roomId = RoomId(request.roomId),
eventId = EventId(request.eventId),
cause = exception.message,
noisy = false,
)
)
}
@@ -42,6 +42,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import timber.log.Timber
/**
* Category shared with the `<share-target>` in `app/src/main/res/xml/shortcuts.xml`. The system only offers a shortcut
* in the Direct Share row of the share sheet when the two match, so the literal has to be kept in step with that file.
*/
private const val SHARE_TARGET_CATEGORY = "io.element.android.category.SHARE_TARGET"
@SingleIn(AppScope::class)
@ContributesBinding(AppScope::class)
class DefaultNotificationConversationService(
@@ -86,7 +92,8 @@ class DefaultNotificationConversationService(
}
val categories = setOfNotNull(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION else null,
SHARE_TARGET_CATEGORY,
)
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return
@@ -317,7 +317,7 @@ class DefaultNotificationCreator(
val fallbackNotifiableEvent = fallbackNotifiableEvents.first()
val channelId = notificationChannels.getChannelIdForMessage(
sessionId = fallbackNotifiableEvent.sessionId,
noisy = false,
noisy = fallbackNotifiableEvents.any { it.noisy },
)
val existingCounter = existingNotification
?.extras
@@ -25,6 +25,7 @@ data class FallbackNotifiableEvent(
override val canBeReplaced: Boolean,
override val isRedacted: Boolean,
override val isUpdated: Boolean,
val noisy: Boolean,
val timestamp: Long,
val cause: String?,
) : NotifiableEvent
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Anruf"</string>
<string name="notification_channel_listening_for_events">"Auf Ereignisse achten"</string>
<string name="notification_channel_listening_for_events">"Warten auf Events"</string>
<string name="notification_channel_noisy">"Laute Benachrichtigungen"</string>
<string name="notification_channel_ringing_calls">"Klingelnde Anrufe"</string>
<string name="notification_channel_silent">"Stumme Benachrichtigungen"</string>
@@ -19,9 +19,13 @@
<item quantity="one">"Du hast %d neue Nachricht."</item>
<item quantity="other">"Du hast %d neue Nachrichten."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="one">"Hat %1$d Anhang geteilt"</item>
<item quantity="other">"Hat %1$d Anhänge geteilt"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Eingehender Anruf"</string>
<string name="notification_incoming_call">"Eingehender Anruf"</string>
<string name="notification_inline_reply_failed">"** Fehler beim Senden - bitte Chat öffnen"</string>
<string name="notification_inline_reply_failed">"** Fehler beim Senden – bitte Chat öffnen"</string>
<string name="notification_invitation_action_join">"Beitreten"</string>
<string name="notification_invitation_action_reject">"Ablehnen"</string>
<plurals name="notification_invitations">
@@ -30,7 +34,9 @@
</plurals>
<string name="notification_invite_body">"Du wurdest zu einem Chat eingeladen"</string>
<string name="notification_invite_body_with_sender">"%1$s hat dich zum Chatten eingeladen"</string>
<string name="notification_mentioned_you_body">"Hat Dich erwähnt: %1$s"</string>
<string name="notification_knock_request_body">"Hat um Beitritt gebeten"</string>
<string name="notification_live_location_started_body">"Hat begonnen, den Live-Standort zu teilen"</string>
<string name="notification_mentioned_you_body">"Hat dich erwähnt: %1$s"</string>
<string name="notification_new_messages">"Neue Nachrichten"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d neue Nachricht"</item>
@@ -39,13 +45,13 @@
<string name="notification_reaction_body">"Reagiert mit %1$s"</string>
<string name="notification_room_action_mark_as_read">"Als gelesen markieren"</string>
<string name="notification_room_action_quick_reply">"Schnelle Antwort"</string>
<string name="notification_room_invite_body">"Du wurdest eingeladen, den Chat zu betreten"</string>
<string name="notification_room_invite_body">"Du wurdest eingeladen, dem Chat beizutreten"</string>
<string name="notification_room_invite_body_with_sender">"%1$s hat dich eingeladen, dem Chat beizutreten"</string>
<string name="notification_sender_me">"Ich"</string>
<string name="notification_sender_mention_reply">"%1$s hat Dich erwähnt oder geantwortet"</string>
<string name="notification_sender_mention_reply">"%1$s hat dich erwähnt oder geantwortet"</string>
<string name="notification_space_invite_body">"Einladung zum Space"</string>
<string name="notification_space_invite_body_with_sender">"%1$s hat dich eingeladen, dem Space beizutreten"</string>
<string name="notification_test_push_notification_content">"Du siehst dir die Benachrichtigung an! Klicke hier!"</string>
<string name="notification_test_push_notification_content">"Du siehst dir die Benachrichtigung an! Tippe hier!"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
@@ -63,7 +69,7 @@
<string name="push_distributor_background_sync_android">"Hintergrundsynchronisation"</string>
<string name="push_distributor_firebase_android">"Google-Dienste"</string>
<string name="push_no_valid_google_play_services_apk_android">"Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig."</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Überprüfen von gesperrten Nutzern"</string>
<string name="troubleshoot_notifications_test_blocked_users_description">"Gesperrte Nutzer werden überprüft"</string>
<string name="troubleshoot_notifications_test_blocked_users_quick_fix">"Gesperrte Nutzer ansehen"</string>
<string name="troubleshoot_notifications_test_blocked_users_result_none">"Keine Nutzer sind gesperrt."</string>
<plurals name="troubleshoot_notifications_test_blocked_users_result_some">
@@ -82,7 +88,7 @@
<string name="troubleshoot_notifications_test_detect_push_provider_failure">"Keine Unterstützung für Push-Dienst gefunden."</string>
<plurals name="troubleshoot_notifications_test_detect_push_provider_success">
<item quantity="one">"%1$d Push-Dienst gefunden: %2$s"</item>
<item quantity="other">"%1$d Push-Dienst gefunden: %2$s"</item>
<item quantity="other">"%1$d Push-Dienste gefunden: %2$s"</item>
</plurals>
<string name="troubleshoot_notifications_test_detect_push_provider_success_2">"Die Anwendung bietet Unterstützung für: %1$s"</string>
<string name="troubleshoot_notifications_test_detect_push_provider_title">"Unterstützung für Push-Dienst"</string>
@@ -91,12 +97,13 @@
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Die Benachrichtigung kann nicht angezeigt werden."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Die Benachrichtigung wurde angeklickt!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Benachrichtigung anzeigen"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Bitte klicke auf die Benachrichtigung, um den Test fortzusetzen."</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Bitte tippe auf die Benachrichtigung, um den Test fortzusetzen."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Stelle sicher, dass die App Push-Benachrichtigungen empfängt."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Fehler: Der Pusher hat die Anfrage abgelehnt."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Fehler:%1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Fehler: Push kann nicht getestet werden."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Fehler: Timeout beim Warten auf Push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"Fehler: Das Push-Gateway drosselt die Benachrichtigungsrate dieses Geräts, daher sind Benachrichtigungen nicht zuverlässig. Bitte richte ein anderes Push-Gateway ein."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Push-Loop-Back Dauer: %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Teste Push-Loop-Back"</string>
</resources>
@@ -45,7 +45,7 @@
<string name="notification_sender_mention_reply">"Ο χρήστης %1$s αναφέρθηκε ή απάντησε"</string>
<string name="notification_space_invite_body">"Σας προσκάλεσε να συμμετάσχετε στον χώρο"</string>
<string name="notification_space_invite_body_with_sender">"%1$s σας προσκάλεσε να συμμετάσχετε στον χώρο"</string>
<string name="notification_test_push_notification_content">"Βλέπεις την ειδοποίηση! Κάνε μου κλικ!"</string>
<string name="notification_test_push_notification_content">"Προβάλλετε την ειδοποίηση! Πατήστε εδώ!"</string>
<string name="notification_thread_in_room">"Νήμα στο %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
@@ -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_knock_request_body">"Palun võimalust liitumiseks"</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>
@@ -46,7 +47,7 @@
<string name="notification_sender_mention_reply">"%1$s mainis või vastas"</string>
<string name="notification_space_invite_body">"Kutsus sind liituma kogukonnaga"</string>
<string name="notification_space_invite_body_with_sender">"%1$s kutsus sind liituma kogukonnaga"</string>
<string name="notification_test_push_notification_content">"See ongi teavitus! Klõpsi mind!"</string>
<string name="notification_test_push_notification_content">"See ongi teavitus! Klõpsa mind!"</string>
<string name="notification_thread_in_room">"Jutulõng „%1$s“ jututoas"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
@@ -92,7 +93,7 @@
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Teavituse kuvamine ei õnnestu."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Sa oled teavitust klõpsinud!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Kuva teavitust"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Testiga jätkamiseks palun klõpsi teavitust."</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Testiga jätkamiseks palun klõpsa teavitust."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Palun veendu, et rakendus saab tõuketeavitusi."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Viga: tõuketeenuse teostaja on päringust keeldunud."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Viga: %1$s."</string>
@@ -34,6 +34,7 @@
</plurals>
<string name="notification_invite_body">"Vous a invité(e) à discuter"</string>
<string name="notification_invite_body_with_sender">"%1$s vous a invité à discuter"</string>
<string name="notification_knock_request_body">"A demandé à rejoindre"</string>
<string name="notification_live_location_started_body">"A commencé à partager sa position en direct"</string>
<string name="notification_mentioned_you_body">"Mentionné(e) : %1$s"</string>
<string name="notification_new_messages">"Nouveaux messages"</string>
@@ -102,6 +103,7 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Erreur :%1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Erreur, impossible de tester les Push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Erreur, le délai d’attente du Push est dépassé."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"Erreur : la passerelle de notification push limite le nombre de push: les notifications ne seront donc pas fiables. Veuillez configurer une autre passerelle de notification push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"La demande d’envoi de Push et sa réception ont pris %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Tester la réception des Push"</string>
</resources>
@@ -37,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_knock_request_body">"Zatraženo pridruživanje"</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>
@@ -110,6 +111,7 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Pogreška: %1$s ."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Pogreška, nije moguće testirati push obavijesti."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Pogreška, isteklo je vrijeme čekanja na push obavijest."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"Pogreška: push gateway ograničava brzinu slanja obavijesti na ovom uređaju, pa obavijesti neće biti pouzdane. Postavite drugi push gateway."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Povratna push obavijest trajala je %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Testiraj povratnu push obavijest"</string>
</resources>
@@ -16,6 +16,9 @@
<plurals name="notification_fallback_n_content">
<item quantity="other">"Anda memiliki %d pesan baru."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="other">"Lampiran %1$d yang dibagikan"</item>
</plurals>
<string name="notification_incoming_audio_call">"Panggilan masuk"</string>
<string name="notification_incoming_call">"📹 Panggilan masuk"</string>
<string name="notification_inline_reply_failed">"** Gagal mengirim — silakan buka ruangan"</string>
@@ -26,6 +29,7 @@
</plurals>
<string name="notification_invite_body">"Mengundang Anda untuk mengobrol"</string>
<string name="notification_invite_body_with_sender">"%1$s mengundang Anda untuk mengobrol"</string>
<string name="notification_knock_request_body">"Diminta untuk bergabung"</string>
<string name="notification_mentioned_you_body">"Menyebutkan Anda: %1$s"</string>
<string name="notification_new_messages">"Pesan Baru"</string>
<plurals name="notification_new_messages_for_room">
@@ -88,6 +92,7 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Kesalahan: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Terjadi kesalahan, tidak dapat menguji notifikasi dorongan."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Terjadi kesalahan, melebihi batas waktu menunggu notifikasi dorongan."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"Kesalahan: gateway push membatasi frekuensi pengiriman ke perangkat ini, sehingga notifikasi tidak akan dapat diandalkan. Silakan atur gateway push yang lain."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Ulangan notifikasi dorongan membutuhkan %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Uji ulangan notifikasi dorongan lagi"</string>
</resources>
@@ -19,6 +19,10 @@
<item quantity="one">"Hai %d nuovo messaggio."</item>
<item quantity="other">"Hai %d nuovi messaggi."</item>
</plurals>
<plurals name="notification_gallery_body">
<item quantity="one">"Condiviso %1$d allegato"</item>
<item quantity="other">"Condivisi %1$d allegati"</item>
</plurals>
<string name="notification_incoming_audio_call">"📞 Chiamata in arrivo"</string>
<string name="notification_incoming_call">"📹 Chiamata in arrivo"</string>
<string name="notification_inline_reply_failed">"** Invio fallito - si prega di aprire la stanza"</string>
@@ -30,6 +34,8 @@
</plurals>
<string name="notification_invite_body">"Ti ha invitato ad una conversazione"</string>
<string name="notification_invite_body_with_sender">"%1$s ti ha invitato ad una conversazione"</string>
<string name="notification_knock_request_body">"Richiesta di accesso"</string>
<string name="notification_live_location_started_body">"Ha iniziato a condividere la posizione in tempo reale"</string>
<string name="notification_mentioned_you_body">"Ti ha menzionato: %1$s"</string>
<string name="notification_new_messages">"Nuovi messaggi"</string>
<plurals name="notification_new_messages_for_room">
@@ -97,6 +103,7 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Errore: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Errore, impossibile testare le notifiche push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Errore, tempo scaduto in attesa della notifica push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"Errore: il gateway push sta limitando la frequenza delle notifiche su questo dispositivo, pertanto le notifiche non saranno affidabili. Si prega di configurare un gateway push diverso."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Il test push loop back ha impiegato %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Prova invio notifica push loop back"</string>
</resources>
@@ -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,8 @@
</plurals>
<string name="notification_invite_body">"チャットにあなたを招待しました"</string>
<string name="notification_invite_body_with_sender">"%1$sがあなたをチャットに招待しました"</string>
<string name="notification_knock_request_body">"参加をリクエスト"</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">
@@ -88,6 +93,7 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"エラー: %1$s"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"エラー: プッシュ通知をテストできません。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"エラー: 通知の待機がタイムアウトしました。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"エラー: 現在のプッシュゲートウェイは、この端末に通信制限を設けているため、確実に通知を受け取ることができません。異なるプッシュゲートウェイを設定してください。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"プッシュ通知のループバックに %1$d ms 要しました。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"プッシュ通知のループバックをテスト"</string>
</resources>
@@ -39,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_knock_request_body">"Poprosił o dołączenie"</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>
@@ -56,7 +57,7 @@
<string name="notification_sender_mention_reply">"%1$s wspomniał lub odpowiedział"</string>
<string name="notification_space_invite_body">"Zaprosił Cię do dołączenia do przestrzeni"</string>
<string name="notification_space_invite_body_with_sender">"%1$s zaprosił Cię do dołączenia do przestrzeni"</string>
<string name="notification_test_push_notification_content">"Wyświetlasz powiadomienie! Kliknij mnie!"</string>
<string name="notification_test_push_notification_content">"Oto powiadomienie! Dotknij mnie!"</string>
<string name="notification_thread_in_room">"Wątek w %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
@@ -106,12 +107,13 @@
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Nie można wyświetlić powiadomienia."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Powiadomienie zostało kliknięte!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Wyświetl powiadomienie"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Kliknij powiadomienie, aby kontynuować test."</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Dotknij powiadomienia, aby kontynuować test."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Upewnij się, że aplikacja otrzymuje powiadomienie push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Błąd: pusher odrzucił żądanie."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Błąd: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Błąd, nie można przetestować push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Błąd, upłynął limit czasu powiadomienia push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"Błąd: bramka push posiada ograniczenie ilości powiadomień, co sprawi że mogą nie działać prawidłowo. Prosimy o ustawienie innej bramki push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Pętla powrotna push zajęła %1$d ms."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Przetestuj pętlę Push back"</string>
</resources>
@@ -39,6 +39,7 @@
</plurals>
<string name="notification_invite_body">"Запрошує вас до бесіди"</string>
<string name="notification_invite_body_with_sender">"%1$s запрошує вас до бесіди"</string>
<string name="notification_knock_request_body">"Запит на приєднання"</string>
<string name="notification_live_location_started_body">"Починає ділитися своїм поточним місцем розташування"</string>
<string name="notification_mentioned_you_body">"Вас згадує: %1$s"</string>
<string name="notification_new_messages">"Нові повідомлення"</string>
@@ -56,7 +57,7 @@
<string name="notification_sender_mention_reply">"%1$s згадували або відповідали"</string>
<string name="notification_space_invite_body">"Вас запросили приєднатися до простору"</string>
<string name="notification_space_invite_body_with_sender">"%1$s запрошує вас приєднатися до простору"</string>
<string name="notification_test_push_notification_content">"Ви переглядаєте сповіщення! Натисніть тут!"</string>
<string name="notification_test_push_notification_content">"Ви переглядаєте сповіщення! Торкніться мене!"</string>
<string name="notification_thread_in_room">"Гілка в %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
@@ -106,12 +107,13 @@
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Не вдається показати сповіщення."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"Ви натиснули на сповіщення!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Показ сповіщення"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Натисніть на сповіщення, щоб продовжити тест."</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Торкніться сповіщення, щоб продовжити тест."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Переконується, що застосунок отримує push-сповіщення."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Помилка: постачальник push-сповіщень відхилив запит."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Помилка: %1$s."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"Помилка, неможливо перевірити push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"Помилка, час очікування вийшов на push-повідомлення."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"Помилка: шлюз push обмежує швидкість для цього пристрою, тому сповіщення не будуть надійними. Налаштуйте інший шлюз push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"Зворотнє відправлення push-повідомлення, зайняло %1$d мс."</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"Перевірка зворотного надсилання"</string>
</resources>
@@ -29,6 +29,7 @@
</plurals>
<string name="notification_invite_body">"邀請您聊天"</string>
<string name="notification_invite_body_with_sender">"%1$s 邀請您加入聊天"</string>
<string name="notification_knock_request_body">"已要求加入"</string>
<string name="notification_live_location_started_body">"已開始分享他們的即時位置"</string>
<string name="notification_mentioned_you_body">"提及您:%1$s"</string>
<string name="notification_new_messages">"新訊息"</string>
@@ -92,6 +93,7 @@
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"錯誤:%1$s。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_3">"錯誤,無法測試推播。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_4">"錯誤,等待推播逾時。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_rate_limit">"錯誤:推播閘道已限速此裝置,因此通知可能不穩定。請設定其他推播閘道。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_success">"推播返回需要 %1$d 毫秒。"</string>
<string name="troubleshoot_notifications_test_push_loop_back_title">"測試推播返回"</string>
</resources>
@@ -51,7 +51,7 @@
<string name="notification_sender_mention_reply">"%1$s mentioned or replied"</string>
<string name="notification_space_invite_body">"Invited you to join the space"</string>
<string name="notification_space_invite_body_with_sender">"%1$s invited you to join the space"</string>
<string name="notification_test_push_notification_content">"You are viewing the notification! Click me!"</string>
<string name="notification_test_push_notification_content">"You are viewing the notification! Tap me!"</string>
<string name="notification_thread_in_room">"Thread in %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
@@ -97,7 +97,7 @@
<string name="troubleshoot_notifications_test_display_notification_permission_failure">"Cannot display the notification."</string>
<string name="troubleshoot_notifications_test_display_notification_success">"The notification has been clicked!"</string>
<string name="troubleshoot_notifications_test_display_notification_title">"Display notification"</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Please click on the notification to continue the test."</string>
<string name="troubleshoot_notifications_test_display_notification_waiting">"Please tap the notification to continue the test."</string>
<string name="troubleshoot_notifications_test_push_loop_back_description">"Ensure that the application is receiving push."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_1">"Error: pusher has rejected the request."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Error: %1$s."</string>
@@ -712,6 +712,41 @@ class DefaultNotifiableEventResolverTest : RobolectricTest() {
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
noisy = false,
timestamp = A_FAKE_TIMESTAMP,
cause = "Unable to decrypt event content",
)
)
assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult))
}
@Test
fun `resolve RoomEncrypted noisy`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
mapOf(
AN_EVENT_ID to Result.success(
aNotificationData(
content = NotificationContent.MessageLike.RoomEncrypted,
isNoisy = true,
)
)
)
)
)
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(
FallbackNotifiableEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
description = "",
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
noisy = true,
timestamp = A_FAKE_TIMESTAMP,
cause = "Unable to decrypt event content",
)
@@ -15,28 +15,35 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
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_THREAD_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID_2
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.test.FakeImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.factories.NotificationAccountParams
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.factories.aNotificationAccountParams
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
@@ -48,6 +55,8 @@ import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.services.appnavstate.test.aNavigationState
import io.element.android.services.appnavstate.test.anAppNavigationState
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -472,6 +481,137 @@ class DefaultNotificationDrawerManagerTest {
extraInvocationsForNotificationSummary = 1,
)
@Test
fun `when the app is locked, a noisy message event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived(
notifiableEvent = aNotifiableMessageEvent(noisy = true),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = A_TIMESTAMP),
)
@Test
fun `when the app is locked, a silent message event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived(
notifiableEvent = aNotifiableMessageEvent(noisy = false),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_TIMESTAMP),
)
@Test
fun `when the app is locked, a noisy invite event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived(
notifiableEvent = anInviteNotifiableEvent(noisy = true),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = 0),
)
@Test
fun `when the app is locked, a silent invite event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived(
notifiableEvent = anInviteNotifiableEvent(noisy = false),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = 0),
)
@Test
fun `when the app is locked, a noisy simple event produces a noisy fallback notification`() = testAppLockedNotifiableEventReceived(
notifiableEvent = aSimpleNotifiableEvent(noisy = true),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = 0),
)
@Test
fun `when the app is locked, a silent simple event produces a silent fallback notification`() = testAppLockedNotifiableEventReceived(
notifiableEvent = aSimpleNotifiableEvent(noisy = false),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = 0),
)
@Test
fun `when the app is locked, a noisy fallback event stays noisy`() = testAppLockedNotifiableEventReceived(
notifiableEvent = aFallbackNotifiableEvent(noisy = true),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = true, timestamp = A_FAKE_TIMESTAMP),
)
@Test
fun `when the app is locked, a silent fallback event stays silent`() = testAppLockedNotifiableEventReceived(
notifiableEvent = aFallbackNotifiableEvent(noisy = false),
expectedFallbackEvent = anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_FAKE_TIMESTAMP),
)
@Test
fun `when the app is locked, all the events are grouped into a single fallback notification`() = runTest {
val createFallbackNotificationResult = aCreateFallbackNotificationRecorder()
val sut = createAppLockedNotificationDrawerManager(createFallbackNotificationResult)
sut.onNotifiableEventsReceived(
listOf(
aNotifiableMessageEvent(noisy = false),
aNotifiableMessageEvent(eventId = AN_EVENT_ID_2, noisy = true),
)
)
createFallbackNotificationResult.assertions()
.isCalledOnce()
.with(
value(null),
any(),
value(
listOf(
anExpectedFallbackNotifiableEvent(noisy = false, timestamp = A_TIMESTAMP),
anExpectedFallbackNotifiableEvent(eventId = AN_EVENT_ID_2, noisy = true, timestamp = A_TIMESTAMP),
)
),
)
}
@Test
fun `when the app is not locked, no fallback notification is created`() = runTest {
val createFallbackNotificationResult = aCreateFallbackNotificationRecorder()
val sut = createDefaultNotificationDrawerManager(
appNavigationStateService = FakeAppNavigationStateService(
initialAppNavigationState = anAppNavigationState(isInForeground = false),
),
notificationCreator = FakeNotificationCreator(
createFallbackNotificationResult = createFallbackNotificationResult,
),
lockScreenService = FakeLockScreenService().apply { setIsPinSetup(false) },
)
sut.onNotifiableEventsReceived(listOf(aNotifiableMessageEvent(noisy = true)))
createFallbackNotificationResult.assertions().isNeverCalled()
}
/**
* The content of the original event must be stripped, only the noisy flag, the ids and the timestamp are kept.
*/
private fun anExpectedFallbackNotifiableEvent(
eventId: EventId = AN_EVENT_ID,
noisy: Boolean,
timestamp: Long,
) = aFallbackNotifiableEvent(
eventId = eventId,
description = null,
canBeReplaced = false,
noisy = noisy,
timestamp = timestamp,
cause = null,
)
private fun aCreateFallbackNotificationRecorder() =
lambdaRecorder<Notification?, NotificationAccountParams, List<FallbackNotifiableEvent>, Notification> { _, _, _ -> A_NOTIFICATION }
private fun TestScope.createAppLockedNotificationDrawerManager(
createFallbackNotificationResult: LambdaThreeParamsRecorder<Notification?, NotificationAccountParams, List<FallbackNotifiableEvent>, Notification>,
) = createDefaultNotificationDrawerManager(
appNavigationStateService = FakeAppNavigationStateService(
initialAppNavigationState = anAppNavigationState(isInForeground = false),
),
notificationCreator = FakeNotificationCreator(
createFallbackNotificationResult = createFallbackNotificationResult,
),
lockScreenService = FakeLockScreenService().apply { setIsPinSetup(true) },
)
private fun testAppLockedNotifiableEventReceived(
notifiableEvent: NotifiableEvent,
expectedFallbackEvent: FallbackNotifiableEvent,
) = runTest {
val createFallbackNotificationResult = aCreateFallbackNotificationRecorder()
val sut = createAppLockedNotificationDrawerManager(createFallbackNotificationResult)
sut.onNotifiableEventsReceived(listOf(notifiableEvent))
createFallbackNotificationResult.assertions()
.isCalledOnce()
.with(value(null), any(), value(listOf(expectedFallbackEvent)))
}
private fun testOnNotifiableEventReceived(
appNavigationState: AppNavigationState,
notifiableEvents: List<NotifiableEvent>,
@@ -503,6 +643,7 @@ class DefaultNotificationDrawerManagerTest {
fun TestScope.createDefaultNotificationDrawerManager(
notificationDisplayer: NotificationDisplayer = FakeNotificationDisplayer(),
notificationRenderer: NotificationRenderer? = null,
notificationCreator: NotificationCreator = FakeNotificationCreator(),
appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(),
roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(),
summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(),
@@ -519,7 +660,7 @@ fun TestScope.createDefaultNotificationDrawerManager(
notificationRenderer = notificationRenderer ?: NotificationRenderer(
notificationDisplayer = notificationDisplayer,
notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = FakeNotificationCreator(),
notificationCreator = notificationCreator,
roomGroupMessageCreator = roomGroupMessageCreator,
summaryGroupMessageCreator = summaryGroupMessageCreator,
activeNotificationsProvider = activeNotificationsProvider,
@@ -267,6 +267,42 @@ class DefaultNotificationResultProcessorTest {
assertThat(receivedFallbackEvent).isTrue()
}
@Test
fun `when an event cannot be resolved, the generated fallback event is silent`() = runTest {
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val onPushReceivedResult = lambdaRecorder<String, EventId?, RoomId?, SessionId?, Boolean, Boolean, String?, Unit> { _, _, _, _, _, _, _ -> }
val processor = createDefaultNotificationResultProcessor(
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {},
),
pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
),
onNotifiableEventsReceived = onNotifiableEventsReceived,
)
runningProcessor(processor) {
emit(mapOf(aPushRequest() to Result.failure(NotificationResolverException.UnknownError("Unable to resolve event"))))
}
advanceTimeBy(300.milliseconds)
onNotifiableEventsReceived.assertions()
.isCalledOnce()
.with(
value(
listOf(
aFallbackNotifiableEvent(
description = "",
canBeReplaced = true,
noisy = false,
cause = "Unable to resolve event",
)
)
)
)
}
private suspend fun TestScope.runningProcessor(processor: NotificationResultProcessor, block: suspend NotificationResultProcessor.() -> Unit) {
processor.start()
@@ -0,0 +1,58 @@
/*
* 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.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.impl.notifications.fixtures.aFallbackNotifiableEvent
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import org.junit.Test
class FallbackNotificationFactoryTest {
@Test
fun `create silent event`() {
val sut = FallbackNotificationFactory(FakeSystemClock())
val result = sut.create(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
cause = "A cause",
noisy = false,
)
assertThat(result).isEqualTo(
aFallbackNotifiableEvent(
description = "",
canBeReplaced = true,
noisy = false,
cause = "A cause",
)
)
}
@Test
fun `create noisy event`() {
val sut = FallbackNotificationFactory(FakeSystemClock())
val result = sut.create(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
cause = "A cause",
noisy = true,
)
assertThat(result).isEqualTo(
aFallbackNotifiableEvent(
description = "",
canBeReplaced = true,
noisy = true,
cause = "A cause",
)
)
}
}
@@ -10,6 +10,7 @@ package io.element.android.libraries.push.impl.notifications.conversations
import android.content.Context
import android.content.Intent
import android.content.pm.ShortcutInfo
import android.os.Build
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
@@ -52,6 +53,27 @@ class DefaultNotificationConversationServiceTest : RobolectricTest() {
assertThat(shortcuts).isNotEmpty()
}
@Test
fun `the shortcut is offered to the share sheet as a conversation`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val service = createService(context)
service.onSendMessage(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
roomName = "Room title",
roomIsDirect = false,
roomAvatarUrl = null,
)
val shortcut = ShortcutManagerCompat.getDynamicShortcuts(context).single()
assertThat(shortcut.categories).containsAtLeast(
ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION,
// Matches the <share-target> category in app/src/main/res/xml/shortcuts.xml
"io.element.android.category.SHARE_TARGET",
)
}
@Test
fun `onLeftRoom removes a shortcut`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
@@ -18,6 +18,7 @@ import io.element.android.appconfig.NotificationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.test.FakeEnterpriseService
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_COLOR_INT
@@ -33,6 +34,7 @@ import io.element.android.libraries.push.impl.notifications.DefaultNotificationB
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.DefaultNotificationChannels
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
@@ -47,6 +49,8 @@ import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvid
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -82,7 +86,10 @@ class DefaultNotificationCreatorTest : RobolectricTest() {
@Test
fun `test createFallbackNotification`() {
val sut = createNotificationCreator()
val channelIdForMessage = lambdaRecorder<SessionId, Boolean, String> { _, _ -> A_CHANNEL_ID }
val sut = createNotificationCreator(
notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage),
)
val result = sut.createFallbackNotification(
existingNotification = null,
notificationAccountParams = aNotificationAccountParams(),
@@ -93,6 +100,50 @@ class DefaultNotificationCreatorTest : RobolectricTest() {
result.commonAssertions(
expectedCategory = null,
)
assertThat(result.channelId).isEqualTo(A_CHANNEL_ID)
// The notification must be posted on the silent channel.
channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(false))
}
@Test
fun `test createFallbackNotification noisy`() {
val channelIdForMessage = lambdaRecorder<SessionId, Boolean, String> { _, _ -> A_CHANNEL_ID }
val sut = createNotificationCreator(
notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage),
)
val result = sut.createFallbackNotification(
existingNotification = null,
notificationAccountParams = aNotificationAccountParams(),
fallbackNotifiableEvents = listOf(
aFallbackNotifiableEvent(noisy = true),
)
)
result.commonAssertions(
expectedCategory = null,
)
assertThat(result.channelId).isEqualTo(A_CHANNEL_ID)
// The notification must be posted on the noisy channel.
channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(true))
}
@Test
fun `test createFallbackNotification is noisy when at least one event is noisy`() {
val channelIdForMessage = lambdaRecorder<SessionId, Boolean, String> { _, _ -> A_CHANNEL_ID }
val sut = createNotificationCreator(
notificationChannels = FakeNotificationChannels(channelIdForMessage = channelIdForMessage),
)
val result = sut.createFallbackNotification(
existingNotification = null,
notificationAccountParams = aNotificationAccountParams(),
fallbackNotifiableEvents = listOf(
aFallbackNotifiableEvent(noisy = false),
aFallbackNotifiableEvent(eventId = AN_EVENT_ID_2, noisy = true),
)
)
result.commonAssertions(
expectedCategory = null,
)
channelIdForMessage.assertions().isCalledOnce().with(value(A_SESSION_ID), value(true))
}
@Test
@@ -389,6 +440,8 @@ class DefaultNotificationCreatorTest : RobolectricTest() {
}
}
private const val A_CHANNEL_ID = "aChannelId"
const val MARK_AS_READ_ACTION_TITLE = "MarkAsReadAction"
const val QUICK_REPLY_ACTION_TITLE = "QuickReplyAction"
const val ACCEPT_INVITATION_ACTION_TITLE = "AcceptInvitationAction"
@@ -39,13 +39,14 @@ fun aSimpleNotifiableEvent(
type: String? = null,
isRedacted: Boolean = false,
canBeReplaced: Boolean = false,
editedEventId: EventId? = null
editedEventId: EventId? = null,
noisy: Boolean = false,
) = SimpleNotifiableEvent(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
editedEventId = editedEventId,
noisy = false,
noisy = noisy,
title = "title",
description = "description",
type = type,
@@ -59,14 +60,15 @@ fun anInviteNotifiableEvent(
sessionId: SessionId = A_SESSION_ID,
roomId: RoomId = A_ROOM_ID,
eventId: EventId = AN_EVENT_ID,
isRedacted: Boolean = false
isRedacted: Boolean = false,
noisy: Boolean = false,
) = InviteNotifiableEvent(
sessionId = sessionId,
eventId = eventId,
roomId = roomId,
roomName = A_ROOM_NAME,
editedEventId = null,
noisy = false,
noisy = noisy,
title = "title",
description = "description",
type = null,
@@ -89,11 +91,12 @@ fun aNotifiableMessageEvent(
senderId: UserId = A_USER_ID_2,
senderDisambiguatedDisplayName: String = A_USER_NAME_2,
roomName: String? = A_ROOM_NAME,
noisy: Boolean = false,
) = NotifiableMessageEvent(
sessionId = sessionId,
eventId = eventId,
editedEventId = null,
noisy = false,
noisy = noisy,
timestamp = timestamp,
senderDisambiguatedDisplayName = senderDisambiguatedDisplayName,
senderId = senderId,
@@ -149,15 +152,23 @@ fun aNotifiableCallEvent(
fun aFallbackNotifiableEvent(
sessionId: SessionId = A_SESSION_ID,
roomId: RoomId = A_ROOM_ID,
eventId: EventId = AN_EVENT_ID,
description: String? = "A fallback notification",
canBeReplaced: Boolean = false,
noisy: Boolean = false,
timestamp: Long = A_FAKE_TIMESTAMP,
cause: String? = "Unable to decrypt event",
) = FallbackNotifiableEvent(
sessionId = sessionId,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
roomId = roomId,
eventId = eventId,
editedEventId = null,
description = "A fallback notification",
canBeReplaced = false,
description = description,
canBeReplaced = canBeReplaced,
isRedacted = false,
isUpdated = false,
timestamp = A_FAKE_TIMESTAMP,
cause = "Unable to decrypt event",
noisy = noisy,
timestamp = timestamp,
cause = cause,
)
@@ -5,8 +5,8 @@
<string name="rich_text_editor_close_formatting_options">"Textformatierung abbrechen und schließen"</string>
<string name="rich_text_editor_code_block">"Codeblock umschalten"</string>
<string name="rich_text_editor_composer_caption_placeholder">"Bildunterschrift hinzufügen"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Verschlüsselte Nachricht…"</string>
<string name="rich_text_editor_composer_placeholder">"Nachricht…"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Verschlüsselte Nachricht …"</string>
<string name="rich_text_editor_composer_placeholder">"Nachricht …"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Unverschlüsselte Nachricht"</string>
<string name="rich_text_editor_create_link">"Einen Link erstellen"</string>
<string name="rich_text_editor_edit_link">"Link bearbeiten"</string>
@@ -1,22 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_add_avatar">"Avatar hinzufügen"</string>
<string name="a11y_add_reaction">"Reaktion hinzufügen: %1$s"</string>
<string name="a11y_address">"Adresse"</string>
<string name="a11y_avatar">"Avatar"</string>
<string name="a11y_clear_search_field">"Suchfeld leeren"</string>
<string name="a11y_collapse_message_text_field">"Nachrichtentextfeld minimieren"</string>
<string name="a11y_create_poll_votes_allowed_decrease">"Stimmen pro Person verringern"</string>
<string name="a11y_create_poll_votes_allowed_increase">"Stimmen pro Person erhöhen"</string>
<string name="a11y_custom_emoji">"Benutzerdefiniertes Emoji"</string>
<string name="a11y_delete">"Löschen"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d eingegebene Ziffer"</item>
<item quantity="other">"%1$d eingegebene Ziffern"</item>
</plurals>
<string name="a11y_duration">"Dauer: %1$s"</string>
<string name="a11y_edit_avatar">"Avatar bearbeiten"</string>
<string name="a11y_edit_room_address_hint">"Die vollständige Adresse lautet %1$s"</string>
<string name="a11y_encryption_details">"Details zur Verschlüsselung"</string>
<string name="a11y_expand_message_text_field">"Nachrichtentextfeld vergrößern"</string>
<string name="a11y_gallery_attachment">"Galerie, Anhang %1$d"</string>
<string name="a11y_gallery_failed_scan">"Galerie, Scan %1$d fehlgeschlagen"</string>
<string name="a11y_gallery_image">"Galerie, Bild %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="one">"Galerie, %1$d weitere Mediendatei"</item>
<item quantity="other">"Galerie, %1$d weitere Mediendateien"</item>
</plurals>
<string name="a11y_gallery_scanning">"Galerie, scanne %1$d"</string>
<string name="a11y_gallery_video">"Galerie, Video %1$d"</string>
<string name="a11y_hide_password">"Passwort ausblenden"</string>
<string name="a11y_info">"Info"</string>
<string name="a11y_join_call">"Anruf beitreten"</string>
<string name="a11y_jump_to_bottom">"Nach unten springen"</string>
<string name="a11y_move_the_map_to_my_location">"Verschiebe die Karte zu meinem Standort."</string>
<string name="a11y_jump_to_unread">"Zu den ungelesenen springen"</string>
<string name="a11y_jump_to_unread_messages">"Zur ersten ungelesenen Nachricht springen"</string>
<string name="a11y_move_the_map_to_my_location">"Verschiebe die Karte zu meinem Standort"</string>
<string name="a11y_notifications_mentions_only">"Nur Erwähnungen"</string>
<string name="a11y_notifications_muted">"Stummgeschaltet"</string>
<string name="a11y_notifications_new_mentions">"Neue Erwähnungen"</string>
@@ -26,34 +44,43 @@
<string name="a11y_page_n">"Seite %1$d"</string>
<string name="a11y_pause">"Pausieren"</string>
<string name="a11y_paused_voice_message">"Sprachnachricht, Dauer:%1$s, aktuelle Position: %2$s"</string>
<string name="a11y_photo_preview">"Fotovorschau"</string>
<string name="a11y_pin_field">"PIN-Feld"</string>
<string name="a11y_pinned_location">"Fixierter Standort"</string>
<string name="a11y_play">"Abspielen"</string>
<string name="a11y_playback_speed">"Wiedergabegeschwindigkeit"</string>
<string name="a11y_poll">"Umfrage"</string>
<string name="a11y_poll_end">"Umfrage beendet"</string>
<string name="a11y_position">"Position: %1$s"</string>
<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">"Vorherige Nachricht gelesen von %1$s und %2$s"</string>
<plurals name="a11y_read_receipts_multiple_with_others">
<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>
<item quantity="one">"Vorherige Nachricht gelesen von %1$s und %2$d weiteren Person"</item>
<item quantity="other">"Vorherige Nachricht gelesen von %1$s und %2$d weiteren Personen"</item>
</plurals>
<string name="a11y_read_receipts_single">"Vorherige Nachricht gelesen von %1$s"</string>
<string name="a11y_read_receipts_tap_to_show_all_android">"Alle Lesebestätigungen anzeigen"</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>
<string name="a11y_send_files">"Dateien senden"</string>
<string name="a11y_sender_location">"Standort des Absenders"</string>
<string name="a11y_sent_by_sender_at_date">"Gesendet von %1$s um %2$s"</string>
<string name="a11y_session_verification_time_limited_action_required">"Zeitlich begrenzte Handlung erforderlich, du hast eine Minute Zeit zur Verifizierung"</string>
<string name="a11y_settings_with_required_action">"Einstellungen, Aktion erforderlich"</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_status">"Status"</string>
<string name="a11y_thread_in_room">"Thread in %1$s"</string>
<string name="a11y_threads_in_room">"Threads in %1$s"</string>
<string name="a11y_tombstoned_room">"Stillgelegter Chat"</string>
<string name="a11y_user_avatar">"Nutzer-Avatar"</string>
<string name="a11y_user_menu">"Nutzer-Menü"</string>
<string name="a11y_video_preview">"Videovorschau"</string>
<string name="a11y_view_avatar">"Avatar ansehen"</string>
<string name="a11y_view_details">"Details anzeigen"</string>
<string name="a11y_voice_message">"Sprachnachricht, Dauer: %1$s"</string>
@@ -68,6 +95,7 @@
<string name="action_call">"Anruf"</string>
<string name="action_cancel">"Abbrechen"</string>
<string name="action_cancel_for_now">"Vorerst abbrechen"</string>
<string name="action_choose_file">"Datei auswählen"</string>
<string name="action_choose_photo">"Foto auswählen"</string>
<string name="action_clear">"Löschen"</string>
<string name="action_close">"Schließen"</string>
@@ -81,18 +109,22 @@
<string name="action_copy_link_to_message">"Link zur Nachricht kopieren"</string>
<string name="action_copy_text">"Text kopieren"</string>
<string name="action_create">"Erstellen"</string>
<string name="action_create_room">"Chat erstellen"</string>
<string name="action_create_room">"Gruppe erstellen"</string>
<string name="action_create_space">"Space erstellen"</string>
<string name="action_deactivate">"Deaktivieren"</string>
<string name="action_deactivate_account">"Nutzerkonto deaktivieren"</string>
<string name="action_deactivate_account">"Konto deaktivieren"</string>
<string name="action_decline">"Ablehnen"</string>
<string name="action_decline_and_block">"Ablehnen und blockieren"</string>
<string name="action_delete">"Löschen"</string>
<string name="action_delete_account">"Konto löschen"</string>
<string name="action_delete_file">"Datei löschen"</string>
<string name="action_delete_poll">"Umfrage löschen"</string>
<string name="action_deselect_all">"Auswahl aufheben"</string>
<string name="action_disable">"Deaktivieren"</string>
<string name="action_discard">"Verwerfen"</string>
<string name="action_dismiss">"Schließen"</string>
<string name="action_done">"Erledigt"</string>
<string name="action_download">"Herunterladen"</string>
<string name="action_edit">"Bearbeiten"</string>
<string name="action_edit_caption">"Bildunterschrift bearbeiten"</string>
<string name="action_edit_poll">"Umfrage bearbeiten"</string>
@@ -108,7 +140,7 @@
<string name="action_go_to_settings">"Zu den Einstellungen"</string>
<string name="action_ignore">"Ignorieren"</string>
<string name="action_invite">"Einladen"</string>
<string name="action_invite_friends">"Nutzer einladen"</string>
<string name="action_invite_friends">"Personen einladen"</string>
<string name="action_invite_friends_to_app">"Lade Personen in %1$s ein"</string>
<string name="action_invite_people_to_app">"Lade Personen in %1$s ein"</string>
<string name="action_invites_list">"Einladungen"</string>
@@ -116,13 +148,14 @@
<string name="action_learn_more">"Mehr erfahren"</string>
<string name="action_leave">"Verlassen"</string>
<string name="action_leave_conversation">"Unterhaltung verlassen"</string>
<string name="action_leave_room">"Verlassen"</string>
<string name="action_leave_room">"Chat verlassen"</string>
<string name="action_leave_space">"Space verlassen"</string>
<string name="action_load_more">"Mehr laden…"</string>
<string name="action_load_more">"Mehr laden"</string>
<string name="action_manage_account">"Konto verwalten"</string>
<string name="action_manage_account_and_devices">"Konto &amp; Geräte verwalten"</string>
<string name="action_manage_devices">"Geräte verwalten"</string>
<string name="action_manage_rooms">"Chats und Gruppen konfigurieren"</string>
<string name="action_mark_as_read">"Als gelesen markieren"</string>
<string name="action_message">"Nachricht"</string>
<string name="action_minimize">"Minimieren"</string>
<string name="action_next">"Weiter"</string>
@@ -142,10 +175,10 @@
<string name="action_remove_message">"Nachricht entfernen"</string>
<string name="action_reply">"Antworten"</string>
<string name="action_reply_in_thread">"Im Thread antworten"</string>
<string name="action_report">"Bericht"</string>
<string name="action_report">"Melden"</string>
<string name="action_report_bug">"Fehler melden"</string>
<string name="action_report_content">"Inhalt melden"</string>
<string name="action_report_dm">"Konversation melden"</string>
<string name="action_report_dm">"Chat melden"</string>
<string name="action_report_room">"Chat melden"</string>
<string name="action_reset">"Zurücksetzen"</string>
<string name="action_reset_identity">"Identität zurücksetzen"</string>
@@ -177,9 +210,11 @@
<string name="action_translate">"Übersetzen"</string>
<string name="action_try_again">"Erneut versuchen"</string>
<string name="action_unpin">"Lösen"</string>
<string name="action_video_call">"Videoanruf"</string>
<string name="action_view">"Ansicht"</string>
<string name="action_view_in_timeline">"Im Nachrichtenverlauf anzeigen"</string>
<string name="action_view_source">"Quellcode anzeigen"</string>
<string name="action_view_source">"Rohdaten anzeigen"</string>
<string name="action_voice_call">"Sprachanruf"</string>
<string name="action_yes">"Ja"</string>
<string name="action_yes_try_again">"Ja, versuche es noch einmal"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Dein Homeserver unterstützt jetzt ein neues, schnelleres Protokoll. Melde dich ab und wieder an, um zu aktualisieren. Wenn du das jetzt tust, vermeidest du eine erzwungene Abmeldung, wenn das alte Protokoll später entfernt wird."</string>
@@ -193,18 +228,21 @@
<string name="common_an_image">"ein Bild"</string>
<string name="common_analytics">"Analysedaten"</string>
<string name="common_android_shortcuts_remove_reason_left_room">"Du hast den Chat verlassen"</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"Du wurdest aus der Sitzung abgemeldet."</string>
<string name="common_android_shortcuts_remove_reason_session_logged_out">"Du wurdest aus der Sitzung abgemeldet"</string>
<string name="common_appearance">"Erscheinungsbild"</string>
<string name="common_audio">"Audio"</string>
<string name="common_beta">"Beta"</string>
<string name="common_blocked_users">"Blockierte Nutzer"</string>
<string name="common_bubbles">"Sprechblasen"</string>
<string name="common_call_declined">"Anruf abgelehnt"</string>
<string name="common_call_in_progress">"Anruf läuft"</string>
<string name="common_call_started">"Anruf gestartet"</string>
<string name="common_call_you_declined">"Du hast einen Anruf abgelehnt"</string>
<string name="common_chat_backup">"Chat-Backup"</string>
<string name="common_copied_to_clipboard">"In die Zwischenablage kopiert"</string>
<string name="common_copyright">"Copyright"</string>
<string name="common_creating_room">"Chat wird erstellt…"</string>
<string name="common_creating_space">"Space wird angelegt…"</string>
<string name="common_creating_room">"Chat wird erstellt …"</string>
<string name="common_creating_space">"Space wird erstellt …"</string>
<string name="common_current_user_canceled_knock">"Anfrage abgebrochen"</string>
<string name="common_current_user_left_room">"Hat den Chat verlassen"</string>
<string name="common_current_user_left_space">"Space verlassen"</string>
@@ -216,10 +254,10 @@
<string name="common_direct_chat">"Direktnachricht"</string>
<string name="common_do_not_show_this_again">"Nicht mehr anzeigen"</string>
<string name="common_download_failed">"Download fehlgeschlagen"</string>
<string name="common_downloading">"Herunterladen"</string>
<string name="common_downloading">"Wird heruntergeladen"</string>
<string name="common_edited_suffix">"(bearbeitet)"</string>
<string name="common_editing">"Bearbeitung"</string>
<string name="common_editing_caption">"Bildunterschrift bearbeiten"</string>
<string name="common_editing_caption">"Bildunterschrift wird bearbeitet"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_empty_file">"Leere Datei"</string>
<string name="common_encryption">"Verschlüsselung"</string>
@@ -234,27 +272,37 @@ Grund: %1$s."</string>
<string name="common_failed">"Fehlgeschlagen"</string>
<string name="common_favourite">"Favorit"</string>
<string name="common_favourited">"Favorisiert"</string>
<string name="common_fetching_notifications_title_android">"Benachrichtigungen werden synchronisiert…"</string>
<string name="common_fetching_notifications_title_android">"Benachrichtigungen werden synchronisiert …"</string>
<string name="common_file">"Datei"</string>
<string name="common_file_deleted">"Datei wurde gelöscht"</string>
<string name="common_file_saved">"Datei gespeichert"</string>
<string name="common_file_saved_on_disk_android">"Datei wurde unter Downloads gespeichert"</string>
<string name="common_forward_message">"Nachricht weiterleiten"</string>
<string name="common_frequently_used">"Häufig verwendet"</string>
<string name="common_gallery">"Galerie"</string>
<plurals name="common_gallery_reply_attachments">
<item quantity="one">"%1$d Anhang…"</item>
<item quantity="other">"%1$d Anhänge…"</item>
</plurals>
<plurals name="common_gallery_reply_media_items">
<item quantity="one">"%1$d Mediendatei…"</item>
<item quantity="other">"%1$d Mediendateien…"</item>
</plurals>
<string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Gruppenanruf läuft"</string>
<string name="common_image">"Bild"</string>
<string name="common_in_reply_to">"Als Antwort auf %1$s"</string>
<string name="common_install_apk_android">"APK installieren"</string>
<string name="common_invite_unknown_profile">"Diese Matrix Kennung wurde nicht gefunden, daher wird die Einladung möglicherweise nicht empfangen."</string>
<string name="common_leaving_room">"Chat verlassen"</string>
<string name="common_leaving_room">"Chat wird verlassen"</string>
<string name="common_leaving_space">"Space wird verlassen"</string>
<string name="common_line_copied_to_clipboard">"Zeile in die Zwischenablage kopiert"</string>
<string name="common_link_copied_to_clipboard">"Link in die Zwischenablage kopiert"</string>
<string name="common_link_new_device">"Neues Gerät verknüpfen"</string>
<string name="common_live_location">"Live-Standort"</string>
<string name="common_live_location_ended">"Live-Standort teilen beendet"</string>
<string name="common_loading">"Laden…"</string>
<string name="common_loading_more">"Mehr wird geladen…"</string>
<string name="common_loading">"Wird geladen …"</string>
<string name="common_loading_more">"Weitere werden geladen …"</string>
<plurals name="common_many_members">
<item quantity="one">"%d weitere"</item>
<item quantity="other">"%d weitere"</item>
@@ -277,6 +325,7 @@ Grund: %1$s."</string>
<string name="common_no_space_name">"Kein Space Name"</string>
<string name="common_not_encrypted">"Nicht verschlüsselt"</string>
<string name="common_offline">"Offline"</string>
<string name="common_on_a_call">"In einem Anruf"</string>
<string name="common_open_source_licenses">"Open-Source-Lizenzen"</string>
<string name="common_or">"oder"</string>
<string name="common_other_options">"Weitere Optionen"</string>
@@ -286,16 +335,17 @@ Grund: %1$s."</string>
<string name="common_permission">"Berechtigung"</string>
<string name="common_pinned">"Fixiert"</string>
<string name="common_please_check_internet_connection">"Bitte überprüfe deine Internetverbindung"</string>
<string name="common_please_wait">"Bitte warten…"</string>
<string name="common_please_wait">"Bitte warten …"</string>
<string name="common_poll_end_confirmation">"Bist du sicher, dass du diese Umfrage beenden möchtest?"</string>
<string name="common_poll_summary">"Umfrage: %1$s"</string>
<string name="common_poll_summary_prefix">"Umfrage"</string>
<string name="common_poll_total_votes">"Stimmen insgesamt: %1$s"</string>
<string name="common_poll_undisclosed_text">"Ergebnisse werden nach Ende der Umfrage angezeigt"</string>
<plurals name="common_poll_votes_count">
<item quantity="one">"%d Stimme"</item>
<item quantity="other">"%d Stimmen"</item>
</plurals>
<string name="common_preparing">"Vorbereitung läuft …"</string>
<string name="common_preparing">"Wird vorbereitet …"</string>
<string name="common_privacy_policy">"Datenschutz­erklärung"</string>
<string name="common_private">"Privat"</string>
<string name="common_private_room">"Privater Chat"</string>
@@ -307,15 +357,15 @@ Grund: %1$s."</string>
<string name="common_reactions">"Reaktionen"</string>
<string name="common_reason">"Grund"</string>
<string name="common_recovery_key">"Wiederherstellungsschlüssel"</string>
<string name="common_refreshing">"Wird erneuert…"</string>
<string name="common_refreshing">"Wird aktualisiert …"</string>
<string name="common_removing">"Entferne…"</string>
<plurals name="common_replies">
<item quantity="one">"%1$d Antwort"</item>
<item quantity="other">"%1$d Antworten"</item>
</plurals>
<string name="common_replying_to">"%1$s antworten"</string>
<string name="common_replying_to">"Antwort an %1$s"</string>
<string name="common_report_a_problem">"Ein Problem melden"</string>
<string name="common_report_submitted">"Bericht eingereicht"</string>
<string name="common_report_submitted">"Meldung übermittelt"</string>
<string name="common_rich_text_editor">"Rich-Text-Editor"</string>
<string name="common_role">"Rolle"</string>
<string name="common_room">"Chat"</string>
@@ -326,7 +376,7 @@ Grund: %1$s."</string>
<item quantity="other">"%1$d Chats"</item>
</plurals>
<string name="common_saved_changes">"Gespeicherte Änderungen"</string>
<string name="common_saving">"Speichern"</string>
<string name="common_saving">"Wird gespeichert"</string>
<string name="common_screen_lock">"Bildschirmsperre"</string>
<string name="common_search_for_someone">"Nach jemandem suchen"</string>
<string name="common_search_results">"Suchergebnisse"</string>
@@ -338,7 +388,7 @@ Grund: %1$s."</string>
<item quantity="other">"%1$d ausgewählt"</item>
</plurals>
<string name="common_send_to">"Senden an"</string>
<string name="common_sending">"Wird gesendet…"</string>
<string name="common_sending">"Wird gesendet …"</string>
<string name="common_sending_failed">"Senden fehlgeschlagen"</string>
<string name="common_sent">"Gesendet"</string>
<string name="common_sentence_delimiter">". "</string>
@@ -351,8 +401,8 @@ Grund: %1$s."</string>
<string name="common_shared_live_location">"Geteilter Live-Standort"</string>
<string name="common_shared_location">"Geteilter Standort"</string>
<string name="common_shared_space">"Gemeinsamer Space"</string>
<string name="common_signing_out">"Gerät entfernen"</string>
<string name="common_something_went_wrong">"Es ist ein Fehler aufgetreten."</string>
<string name="common_signing_out">"Gerät wird entfernt"</string>
<string name="common_something_went_wrong">"Etwas ist schief gelaufen"</string>
<string name="common_something_went_wrong_message">"Wir haben ein Problem festgestellt. Bitte versuch es erneut."</string>
<string name="common_space">"Space"</string>
<string name="common_space_members">"Space Mitglieder"</string>
@@ -361,12 +411,12 @@ Grund: %1$s."</string>
<item quantity="one">"%1$d Space"</item>
<item quantity="other">"%1$d Spaces"</item>
</plurals>
<string name="common_starting_chat">"Chat wird gestartet…"</string>
<string name="common_starting_chat">"Chat wird gestartet …"</string>
<string name="common_sticker">"Sticker"</string>
<string name="common_success">"Erfolg"</string>
<string name="common_suggested">"Empfohlen"</string>
<string name="common_suggestions">"Vorschläge"</string>
<string name="common_syncing">"Synchronisieren"</string>
<string name="common_syncing">"Wird synchronisiert"</string>
<string name="common_text">"Text"</string>
<string name="common_third_party_notices">"Hinweise von Drittanbietern"</string>
<string name="common_thread">"Thread"</string>
@@ -375,14 +425,15 @@ Grund: %1$s."</string>
<string name="common_topic_placeholder">"Worum geht is in diesem Chat?"</string>
<string name="common_unable_to_decrypt">"Entschlüsselung nicht möglich"</string>
<string name="common_unable_to_decrypt_insecure_device">"Von einem ungesicherten Gerät gesendet"</string>
<string name="common_unable_to_decrypt_no_access">"Du hast keinen Zugriff auf diese Nachricht."</string>
<string name="common_unable_to_decrypt_verification_violation">"Die verifizierte Identität des Senders wurde zurückgesetzt"</string>
<string name="common_unable_to_decrypt_no_access">"Du hast keinen Zugriff auf diese Nachricht"</string>
<string name="common_unable_to_decrypt_verification_violation">"Die verifizierte digitale Identität des Absenders wurde zurückgesetzt"</string>
<string name="common_unable_to_invite_message">"Einladungen konnten nicht an einen oder mehrere Nutzer gesendet werden."</string>
<string name="common_unable_to_invite_title">"Einladung(en) konnte(n) nicht gesendet werden"</string>
<string name="common_unlock">"Entsperren"</string>
<string name="common_unmute">"Stummschaltung aufheben"</string>
<string name="common_unsupported_call">"Anruf nicht unterstützt"</string>
<string name="common_unsupported_event">"Nicht unterstütztes Ereignis"</string>
<string name="common_user_started_a_call">"%1$s hat einen Anruf gestartet"</string>
<string name="common_username">"Nutzername"</string>
<string name="common_verification_cancelled">"Verifizierung abgebrochen"</string>
<string name="common_verification_complete">"Verifizierung abgeschlossen"</string>
@@ -404,14 +455,19 @@ Grund: %1$s."</string>
<string name="common_waiting_live_location">"Warten auf Live-Standort…"</string>
<string name="common_world_readable_history">"Jeder kann den Nachrichtenverlauf sehen"</string>
<string name="common_you">"Du"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) hat diese Nachricht geteilt, weil du nicht im Chat warst, als sie verschickt wurde."</string>
<string name="content_scanner_not_found">"Download nicht verfügbar."</string>
<string name="content_scanner_not_found_title">"Etwas ist schief gelaufen"</string>
<string name="content_scanner_scanning">"Scanvorgang läuft…"</string>
<string name="content_scanner_unsafe_message">"Vorschau und Download wurden deaktiviert."</string>
<string name="content_scanner_unsafe_title">"Die Datei ist nicht sicher"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) hat diese Nachricht mit dir geteilt, weil du nicht im Chat warst, als sie verschickt wurde."</string>
<string name="crypto_event_key_forwarded_unknown_profile_dialog_content">"Diese Nachricht wurde von %1$s weitergeleitet, da du zum Zeitpunkt des Versands kein Mitglied der Gruppe warst."</string>
<string name="crypto_history_visible">"Diese Gruppe wurde so konfiguriert, dass neue Mitglieder den vergangenen Nachrichtenverlauf lesen können. %1$s"</string>
<string name="crypto_identity_change_pin_violation">"Die Identität von %1$s wurde zurückgesetzt. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Die Identität von %1$s %2$s wurde zurückgesetzt. %3$s"</string>
<string name="crypto_identity_change_pin_violation">"Die digitale Identität von %1$s wurde zurückgesetzt. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Die digitale Identität von %1$s %2$s wurde zurückgesetzt. %3$s"</string>
<string name="crypto_identity_change_pin_violation_new_user_id">"(%1$s)"</string>
<string name="crypto_identity_change_profile_pin_violation">"Die Identität von %1$s wurde zurückgesetzt."</string>
<string name="crypto_identity_change_verification_violation_new">"Die Identität von %1$s %2$s wurde zurückgesetzt. %3$s"</string>
<string name="crypto_identity_change_profile_pin_violation">"Die digitale Identität von %1$s wurde zurückgesetzt."</string>
<string name="crypto_identity_change_verification_violation_new">"Die digitale Identität von %1$s %2$s wurde zurückgesetzt. %3$s"</string>
<string name="crypto_identity_change_withdraw_verification_action">"Verifizierung zurückziehen"</string>
<string name="dialog_allow_access">"Zugriff erlauben"</string>
<string name="dialog_confirm_link_message">"Der Link %1$s führt dich zu einer anderen Seite %2$s.
@@ -421,7 +477,7 @@ Möchtest du wirklich fortfahren?"</string>
<string name="dialog_default_video_quality_selector_subtitle">"Wähle die Standardqualität für Videos, die du hochlädst."</string>
<string name="dialog_default_video_quality_selector_title">"Video-Upload-Qualität"</string>
<string name="dialog_file_too_large_to_upload_subtitle">"Die maximal erlaubte Dateigröße ist: %1$s"</string>
<string name="dialog_file_too_large_to_upload_title">"Die Datei ist zu groß zum Hochladen."</string>
<string name="dialog_file_too_large_to_upload_title">"Die Datei ist zu groß zum Hochladen"</string>
<string name="dialog_room_reported">"Chat gemeldet"</string>
<string name="dialog_room_reported_and_left">"Gemeldet und Chat verlassen"</string>
<string name="dialog_title_confirmation">"Bestätigung"</string>
@@ -435,7 +491,7 @@ Möchtest du wirklich fortfahren?"</string>
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Wähle die Qualität des Videos, das du hochladen möchtest."</string>
<string name="dialog_video_quality_selector_title">"Wähle die Video-Upload-Qualität"</string>
<string name="error_account_already_logged_in">"Du bist auf diesem Gerät bereits als %1$s angemeldet."</string>
<string name="error_account_creation_not_possible">"Dein Homeserver muss aktualisiert werden, um den Matrix Authentication Services und die Erstellung von Konten zu unterstützen."</string>
<string name="error_account_creation_not_possible">"Dein Kontoanbieter muss aktualisiert werden, um Matrix Authentication Service und die Erstellung von Konten zu unterstützen."</string>
<string name="error_failed_creating_the_permalink">"Fehler beim Erstellen des Permalinks"</string>
<string name="error_failed_loading_map">"%1$s konnte die Karte nicht laden. Bitte versuche es später erneut."</string>
<string name="error_failed_loading_messages">"Fehler beim Laden der Nachrichten"</string>
@@ -454,6 +510,9 @@ Möchtest du wirklich fortfahren?"</string>
<string name="error_unknown">"Entschuldigung, es ist ein Fehler aufgetreten"</string>
<string name="invite_friends_rich_title">"🔐️ Begleite mich auf %1$s"</string>
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="live_location_sharing_foreground_service_channel_title_android">"Live-Standortfreigabe"</string>
<string name="live_location_sharing_foreground_service_message_android">"Standortfreigabe läuft"</string>
<string name="live_location_sharing_foreground_service_title_android">"%1$s Live-Standort"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Heftiges Schütteln um Fehler zu melden"</string>
<string name="screen_bug_report_a11y_screenshot">"Bildschirmfoto"</string>
@@ -461,38 +520,41 @@ 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_create_poll_votes_allowed_per_person">"Stimmen pro Person"</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>
<string name="screen_live_location_sheet_sharing_live_location">"Live-Standort wird geteilt"</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_local_network_opt_in_subtitle">"Dein Homeserver befindet sich in deinem lokalen Netzwerk. Um eine Verbindung herzustellen, benötigt %1$s die Berechtigung, auf Geräte in diesem Netzwerk zuzugreifen."</string>
<string name="screen_local_network_opt_in_title">"Zugriff auf das lokale Netzwerk zulassen"</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_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>
<plurals name="screen_pinned_timeline_screen_title">
<item quantity="one">"%1$d fixierte Nachricht"</item>
<item quantity="other">"%1$d fixierte Nachrichten"</item>
</plurals>
<string name="screen_pinned_timeline_screen_title_empty">"Fixierte Nachrichten"</string>
<string name="screen_reset_identity_confirmation_subtitle">"Du wirst gleich zu deinem %1$s Konto weitergeleitet, um deine digitale Identität zurückzusetzen. Danach kehrst du zur App zurück."</string>
<string name="screen_reset_identity_confirmation_subtitle">"Du wirst gleich zu deinem Konto bei %1$s weitergeleitet, um deine digitale Identität zurückzusetzen. Danach kehrst du zur App zurück."</string>
<string name="screen_reset_identity_confirmation_title">"Bestätigung nicht möglich? Rufe dein Konto auf, um deine digitale Identität zurückzusetzen."</string>
<string name="screen_resolve_send_failure_changed_identity_primary_button_title">"Verifizierung zurückziehen und senden"</string>
<string name="screen_resolve_send_failure_changed_identity_subtitle">"Du kannst deine Verifizierung zurückziehen und diese Nachricht trotzdem senden, oder du kannst vorerst abbrechen und es später noch einmal versuchen, nachdem du %1$s erneut verifiziert hast."</string>
<string name="screen_resolve_send_failure_changed_identity_title">"Deine Nachricht wurde nicht gesendet, da die verifizierte Identität von %1$s zurückgesetzt wurde"</string>
<string name="screen_resolve_send_failure_changed_identity_title">"Deine Nachricht wurde nicht gesendet, da die verifizierte digitale Identität von %1$s zurückgesetzt wurde"</string>
<string name="screen_resolve_send_failure_unsigned_device_primary_button_title">"Nachricht trotzdem senden"</string>
<string name="screen_resolve_send_failure_unsigned_device_subtitle">"%1$s verwendet wenigstens ein nicht verifiziertes Gerät. Du kannst die Nachricht trotzdem verschicken, oder vorerst abbrechen und später erneut versuchen, nachdem %2$s alle Geräte verifiziert hat."</string>
<string name="screen_resolve_send_failure_unsigned_device_title">"Deine Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"Mindestens eines deiner Geräte ist nicht verifiziert. Du kannst die Nachricht trotzdem senden, oder den Vorgang zunächst abbrechen und es später erneut versuchen, nachdem du alle deine Geräte verifiziert hast."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Deine Nachricht wurde nicht gesendet, da du eines oder mehrere deiner Geräte nicht verifiziert hast."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Deine Nachricht wurde nicht gesendet, da du eines oder mehrere deiner Geräte nicht verifiziert hast"</string>
<string name="screen_room_change_role_administrators_or_owners_title">"Admins oder Eigentümer bearbeiten"</string>
<string name="screen_room_error_failed_processing_media">"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nutzerdetails konnten nicht abgerufen werden"</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_live_location_banner">"Live-Standort wird geteilt"</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>
@@ -513,6 +575,10 @@ Möchtest du wirklich fortfahren?"</string>
<string name="screen_space_list_title">"Spaces"</string>
<string name="screen_static_location_sheet_timestamp_description">"Geteilt %1$s"</string>
<string name="screen_static_location_sheet_title">"Auf der Karte"</string>
<plurals name="screen_timeline_active_call_joined_count">
<item quantity="one">"%1$d ist beigetreten"</item>
<item quantity="other">"%1$d sind beigetreten"</item>
</plurals>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Die Nachricht wurde nicht gesendet, da die verifizierte digitale Identität von %1$s zurückgesetzt wurde."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Die Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Die Nachricht wurde nicht gesendet, weil du eines oder mehrere deiner Geräte nicht verifiziert hast."</string>
@@ -521,7 +587,9 @@ Möchtest du wirklich fortfahren?"</string>
<string name="test_language_identifier">"en"</string>
<string name="timeline_decryption_failure_historical_event_no_key_backup">"Der Nachrichtenverlauf ist auf diesem Gerät nicht verfügbar"</string>
<string name="timeline_decryption_failure_historical_event_unverified_device">"Für den Zugriff auf den Nachrichtenverlauf musst du dieses Gerät verifizieren"</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"Du hast keinen Zugriff auf diese Nachricht."</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"Du hast keinen Zugriff auf diese Nachricht"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Nachricht kann nicht entschlüsselt werden"</string>
<string name="timeline_decryption_failure_withheld_unverified">"Diese Nachricht wurde entweder blockiert, weil du dein Gerät nicht verifiziert hast, oder weil der Absender deine digitale Identität verifizieren muss."</string>
<string name="vulkan_not_supported_dialog_content_android">"Dein Gerät ist zu alt – ein Gerät mit Android 8 oder neuer ist erforderlich."</string>
<string name="vulkan_not_supported_dialog_title_android">"Kartendarstellung wird nicht unterstützt"</string>
</resources>
@@ -35,12 +35,12 @@
<string name="a11y_qr_code">"Κωδικός QR"</string>
<string name="a11y_react_with">"Αντέδρασε με %1$s"</string>
<string name="a11y_react_with_other_emojis">"Αντέδρασε με άλλα emoji"</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="one">"Διαβάστηκε από %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_remove_reaction">"Αφαίρεση αντίδρασης με %1$s"</string>
<string name="a11y_remove_reaction_with">"Αφαιρέστε την αντίδραση με %1$s"</string>
<string name="a11y_room_avatar">"Άβαταρ αίθουσας"</string>
@@ -491,7 +491,7 @@ Raison : %1$s."</string>
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Sélectionnez la qualité des vidéos que vous souhaitez envoyer."</string>
<string name="dialog_video_quality_selector_title">"Sélectionnez la qualité d’envoi des vidéos"</string>
<string name="error_account_already_logged_in">"Vous êtes déjà connecté sur cet appareil en tant que %1$s."</string>
<string name="error_account_creation_not_possible">"Votre serveur d’accueil doit être mis à jour pour prendre en charge le protocole MAS (Matrix Authentication Service) et la création de compte."</string>
<string name="error_account_creation_not_possible">"Votre fournisseur de compte doit être mis à jour pour prendre en charge le protocole MAS (Matrix Authentication Service) et la création de compte."</string>
<string name="error_failed_creating_the_permalink">"Échec de la création du permalien"</string>
<string name="error_failed_loading_map">"%1$s n’a pas pu charger la carte. Veuillez réessayer ultérieurement."</string>
<string name="error_failed_loading_messages">"Échec du chargement des messages"</string>
@@ -20,6 +20,15 @@
<string name="a11y_edit_room_address_hint">"Potpuna adresa bit će %1$s"</string>
<string name="a11y_encryption_details">"Pojedinosti o šifriranju"</string>
<string name="a11y_expand_message_text_field">"Proširi tekstno polje poruke"</string>
<string name="a11y_gallery_attachment">"Galerija, prilog %1$d"</string>
<string name="a11y_gallery_failed_scan">"Galerija, neuspješno skeniranje %1$d"</string>
<string name="a11y_gallery_image">"Galerija, slika %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="one">"Galerija, %1$d više medija"</item>
<item quantity="other">"Galerija, %1$d više medija"</item>
</plurals>
<string name="a11y_gallery_scanning">"Galerija, skeniranje %1$d"</string>
<string name="a11y_gallery_video">"Galerija, video %1$d"</string>
<string name="a11y_hide_password">"Sakrij zaporku"</string>
<string name="a11y_info">"Informacije"</string>
<string name="a11y_join_call">"Pridruži se pozivu"</string>
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_add_avatar">"Tambahkan avatar"</string>
<string name="a11y_add_reaction">"Tambahkan reaksi: %1$s"</string>
<string name="a11y_address">"Alamat"</string>
<string name="a11y_avatar">"Avatar"</string>
@@ -13,6 +14,13 @@
<string name="a11y_edit_room_address_hint">"Alamat lengkapnya adalah%1$s"</string>
<string name="a11y_encryption_details">"Detail enkripsi"</string>
<string name="a11y_expand_message_text_field">"Perluas kolom teks pesan"</string>
<string name="a11y_gallery_attachment">"Galeri, lampiran %1$d"</string>
<string name="a11y_gallery_image">"Galeri, gambar %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="other">"Galeri, %1$d media lainnya"</item>
</plurals>
<string name="a11y_gallery_scanning">"Galeri, memindai %1$d"</string>
<string name="a11y_gallery_video">"Galeri, video %1$d"</string>
<string name="a11y_hide_password">"Sembunyikan kata sandi"</string>
<string name="a11y_info">"Informasi"</string>
<string name="a11y_join_call">"Bergabung dalam panggilan"</string>
@@ -96,6 +104,7 @@
<string name="action_decline_and_block">"Tolak dan blokir"</string>
<string name="action_delete">"Hapus"</string>
<string name="action_delete_account">"Hapus akun"</string>
<string name="action_delete_file">"Hapus file"</string>
<string name="action_delete_poll">"Hapus jajak pendapat"</string>
<string name="action_deselect_all">"Batalkan semua pilihan"</string>
<string name="action_disable">"Nonaktifkan"</string>
@@ -299,6 +308,7 @@ Alasan: %1$s."</string>
<string name="common_please_wait">"Mohon tunggu…"</string>
<string name="common_poll_end_confirmation">"Apakah Anda yakin ingin mengakhiri jajak pendapat ini?"</string>
<string name="common_poll_summary">"Jajak pendapat: %1$s"</string>
<string name="common_poll_summary_prefix">"Polling"</string>
<string name="common_poll_total_votes">"Total suara: %1$s"</string>
<string name="common_poll_undisclosed_text">"Hasil akan terlihat setelah jajak pendapat berakhir."</string>
<plurals name="common_poll_votes_count">
@@ -530,4 +540,6 @@ Apakah Anda yakin ingin melanjutkan?"</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"Anda tidak memiliki akses ke pesan ini"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Tidak dapat mendekripsi pesan"</string>
<string name="timeline_decryption_failure_withheld_unverified">"Pesan ini diblokir karena Anda tidak memverifikasi perangkat Anda atau karena pengirim perlu memverifikasi identitas Anda."</string>
<string name="vulkan_not_supported_dialog_content_android">"Perangkat Anda terlalu lama, diperlukan perangkat dengan Android 8 atau yang lebih baru."</string>
<string name="vulkan_not_supported_dialog_title_android">"Rendering peta tidak didukung."</string>
</resources>
@@ -8,6 +8,7 @@
<string name="a11y_collapse_message_text_field">"Riduci al minimo il campo di testo del messaggio"</string>
<string name="a11y_create_poll_votes_allowed_decrease">"Riduci il numero di voti consentiti per persona"</string>
<string name="a11y_create_poll_votes_allowed_increase">"Aumenta il numero di voti consentiti per persona"</string>
<string name="a11y_custom_emoji">"Emoji personalizzata"</string>
<string name="a11y_delete">"Elimina"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d cifra inserita"</item>
@@ -18,11 +19,21 @@
<string name="a11y_edit_room_address_hint">"L\'indirizzo completo sarà %1$s"</string>
<string name="a11y_encryption_details">"Dettagli sulla crittografia"</string>
<string name="a11y_expand_message_text_field">"Allarga il campo di testo del messaggio"</string>
<string name="a11y_gallery_attachment">"Galleria, allegato %1$d"</string>
<string name="a11y_gallery_failed_scan">"Galleria, scansione non riuscita %1$d"</string>
<string name="a11y_gallery_image">"Galleria, immagine %1$d"</string>
<plurals name="a11y_gallery_more_media">
<item quantity="one">"Galleria, %1$d altro media"</item>
<item quantity="other">"Galleria, %1$d altri media"</item>
</plurals>
<string name="a11y_gallery_scanning">"Galleria, scansione %1$d"</string>
<string name="a11y_gallery_video">"Galleria, video %1$d"</string>
<string name="a11y_hide_password">"Nascondi password"</string>
<string name="a11y_info">"Info"</string>
<string name="a11y_join_call">"Entra in chiamata"</string>
<string name="a11y_jump_to_bottom">"Vai alla fine"</string>
<string name="a11y_jump_to_unread">"Vai ai messaggi non letti"</string>
<string name="a11y_jump_to_unread_messages">"Vai al primo messaggio non letto"</string>
<string name="a11y_move_the_map_to_my_location">"Sposta la mappa sulla mia posizione"</string>
<string name="a11y_notifications_mentions_only">"Solo menzioni"</string>
<string name="a11y_notifications_muted">"Silenziato"</string>
@@ -63,6 +74,7 @@
<string name="a11y_start_call">"Avvia una chiamata"</string>
<string name="a11y_start_video_call">"Avvia una videochiamata"</string>
<string name="a11y_start_voice_call">"Avvia una chiamata vocale"</string>
<string name="a11y_status">"Stato"</string>
<string name="a11y_thread_in_room">"Discussione in %1$s"</string>
<string name="a11y_threads_in_room">"Discussioni in %1$s"</string>
<string name="a11y_tombstoned_room">"Stanza obsoleta"</string>
@@ -143,6 +155,7 @@
<string name="action_manage_account_and_devices">"Gestisci account &amp; dispositivi"</string>
<string name="action_manage_devices">"Gestisci dispositivi"</string>
<string name="action_manage_rooms">"Gestisci le stanze"</string>
<string name="action_mark_as_read">"Segna come letto"</string>
<string name="action_message">"Invia messaggio"</string>
<string name="action_minimize">"Riduci"</string>
<string name="action_next">"Avanti"</string>
@@ -267,6 +280,14 @@ Motivo:. %1$s"</string>
<string name="common_forward_message">"Inoltra messaggio"</string>
<string name="common_frequently_used">"Usati di frequente"</string>
<string name="common_gallery">"Galleria"</string>
<plurals name="common_gallery_reply_attachments">
<item quantity="one">"%1$d allegato…"</item>
<item quantity="other">"%1$d allegati…"</item>
</plurals>
<plurals name="common_gallery_reply_media_items">
<item quantity="one">"%1$d elemento multimediale…"</item>
<item quantity="other">"%1$d elementi multimediali…"</item>
</plurals>
<string name="common_gif">"GIF"</string>
<string name="common_group_call_in_progress">"Chiamata di gruppo in corso"</string>
<string name="common_image">"Immagine"</string>
@@ -304,6 +325,7 @@ Motivo:. %1$s"</string>
<string name="common_no_space_name">"Spazio senza nome"</string>
<string name="common_not_encrypted">"Non cifrato"</string>
<string name="common_offline">"Non in linea"</string>
<string name="common_on_a_call">"In chiamata"</string>
<string name="common_open_source_licenses">"Licenze open source"</string>
<string name="common_or">"o"</string>
<string name="common_other_options">"Altre opzioni"</string>
@@ -435,6 +457,7 @@ Motivo:. %1$s"</string>
<string name="common_you">"Tu"</string>
<string name="content_scanner_not_found">"Download non disponibile."</string>
<string name="content_scanner_not_found_title">"File non trovato"</string>
<string name="content_scanner_scanning">"Scansione…"</string>
<string name="content_scanner_unsafe_message">"L\'anteprima e il download sono stati disabilitati."</string>
<string name="content_scanner_unsafe_title">"Il file non è sicuro"</string>
<string name="crypto_event_key_forwarded_known_profile_dialog_content">"%1$s (%2$s) ha condiviso questo messaggio poiché non eri nella stanza quando è stato inviato."</string>
@@ -468,7 +491,7 @@ Sei sicuro di voler continuare?"</string>
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Seleziona la qualità del video che vuoi caricare."</string>
<string name="dialog_video_quality_selector_title">"Seleziona la qualità di caricamento del video"</string>
<string name="error_account_already_logged_in">"Hai già effettuato l\'accesso su questo dispositivo come %1$s ."</string>
<string name="error_account_creation_not_possible">"Il tuo homeserver deve essere aggiornato per supportare il Matrix Authentication Service e la creazione di account."</string>
<string name="error_account_creation_not_possible">"Il tuo gestore dell\'account deve essere aggiornato per supportare il servizio di autenticazione Matrix e la creazione di account."</string>
<string name="error_failed_creating_the_permalink">"Impossibile creare il collegamento permanente"</string>
<string name="error_failed_loading_map">"%1$s non è riuscito a caricare la mappa. Riprova più tardi."</string>
<string name="error_failed_loading_messages">"Caricamento dei messaggi non riuscito"</string>
@@ -505,6 +528,8 @@ Sei sicuro di voler continuare?"</string>
<item quantity="other">"%1$d persone"</item>
</plurals>
<string name="screen_live_location_sheet_title">"Sulla mappa"</string>
<string name="screen_local_network_opt_in_subtitle">"Il tuo homeserver si trova sulla tua rete locale. Per connettersi, %1$s necessita dell\'autorizzazione per raggiungere i dispositivi su questa rete."</string>
<string name="screen_local_network_opt_in_title">"Consenti l\'accesso alla rete locale"</string>
<string name="screen_media_picker_error_failed_selection">"Selezione del file multimediale fallita, riprova."</string>
<string name="screen_pinned_timeline_empty_state_description">"Premi su un messaggio e scegli “%1$s” per includerlo qui."</string>
<string name="screen_pinned_timeline_empty_state_headline">"Fissa i messaggi importanti così che possano essere trovati facilmente"</string>
@@ -550,6 +575,10 @@ Sei sicuro di voler continuare?"</string>
<string name="screen_space_list_title">"Spazi"</string>
<string name="screen_static_location_sheet_timestamp_description">"Condivisa %1$s"</string>
<string name="screen_static_location_sheet_title">"Sulla mappa"</string>
<plurals name="screen_timeline_active_call_joined_count">
<item quantity="one">"%1$d si è unito"</item>
<item quantity="other">"%1$d si sono uniti"</item>
</plurals>
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Il messaggio non è stato inviato perché l\'identità digitale verificata di %1$sè stata reimpostata."</string>
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Messaggio non inviato perché %1$s non ha verificato tutti i dispositivi."</string>
<string name="screen_timeline_item_menu_send_failure_you_unsigned_device">"Messaggio non inviato perché non hai verificato uno o più dispositivi."</string>
@@ -4,9 +4,11 @@
<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>
@@ -16,11 +18,20 @@
<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>
<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>
@@ -47,6 +58,7 @@
<item quantity="other">"既読: %1$s ほか %2$d 人"</item>
</plurals>
<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>
@@ -59,6 +71,7 @@
<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>
@@ -139,6 +152,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>
@@ -263,6 +277,12 @@
<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>
@@ -298,6 +318,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>
@@ -422,6 +443,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>
@@ -489,6 +515,8 @@
<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>
@@ -533,6 +561,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 の検証済みのデジタルIDがリセットされたため、メッセージは送信されませんでした。"</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>
@@ -206,10 +206,10 @@
<string name="action_start_chat">"Rozpocznij chat"</string>
<string name="action_start_over">"Zacznij od nowa"</string>
<string name="action_start_verification">"Rozpocznij weryfikację"</string>
<string name="action_static_map_load">"Stuknij, aby załadować mapę"</string>
<string name="action_static_map_load">"Dotknij, aby załadować mapę"</string>
<string name="action_stop">"Zatrzymaj"</string>
<string name="action_take_photo">"Zrób zdjęcie"</string>
<string name="action_tap_for_options">"Stuknij, by wyświetlić opcje"</string>
<string name="action_tap_for_options">"Dotknij, by wyświetlić opcje"</string>
<string name="action_translate">"Przetłumacz"</string>
<string name="action_try_again">"Spróbuj ponownie"</string>
<string name="action_unpin">"Odepnij"</string>
@@ -503,7 +503,7 @@ Czy na pewno chcesz kontynuować?"</string>
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Wybierz jakość filmu, który chcesz przesłać."</string>
<string name="dialog_video_quality_selector_title">"Wybierz jakość przesyłania wideo"</string>
<string name="error_account_already_logged_in">"Jesteś już zalogowany na tym urządzeniu jako %1$s."</string>
<string name="error_account_creation_not_possible">"Twój serwer domowy wymaga aktualizacji, aby uzyskać wsparcie usługi Matrix Authentication Service i tworzenia kont."</string>
<string name="error_account_creation_not_possible">"Twój dostawca konta wymaga aktualizacji, aby uzyskać wsparcie usługi Matrix Authentication Service i tworzenia kont."</string>
<string name="error_failed_creating_the_permalink">"Nie udało się utworzyć linku bezpośredniego"</string>
<string name="error_failed_loading_map">"%1$s nie mogło wczytać mapy. Spróbuj ponownie później."</string>
<string name="error_failed_loading_messages">"Nie udało się załadować wiadomości"</string>
@@ -503,7 +503,7 @@
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Виберіть якість відео, яке ви хочете вивантажити."</string>
<string name="dialog_video_quality_selector_title">"Виберіть якість вивантажуваного відео"</string>
<string name="error_account_already_logged_in">"Ви вже ввійшли на цьому пристрої як %1$s."</string>
<string name="error_account_creation_not_possible">"Ваш домашній сервер потрібно оновити, щоб він підтримував службу автентифікації Matrix і створення облікових записів."</string>
<string name="error_account_creation_not_possible">"Постачальника послуг облікового запису потрібно оновити для підтримки Matrix Authentication Service та створення облікових записів."</string>
<string name="error_failed_creating_the_permalink">"Не вдалося створити постійне посилання"</string>
<string name="error_failed_loading_map">"%1$s не може завантажити мапу. Повторіть спробу пізніше."</string>
<string name="error_failed_loading_messages">"Не вдалося завантажити повідомлення"</string>
@@ -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>
@@ -55,7 +55,7 @@
<string name="a11y_react_with_other_emojis">"使用其它 Emoji 做出反应"</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_tap_to_show_all_android">"显示所有已读回执"</string>
@@ -479,7 +479,7 @@
<string name="dialog_video_quality_selector_subtitle_no_file_size">"选择你要上传的视频的质量。"</string>
<string name="dialog_video_quality_selector_title">"选择视频上传质量"</string>
<string name="error_account_already_logged_in">"你已在此设备以 %1$s 的身份登录。"</string>
<string name="error_account_creation_not_possible">"你的主服务器需要升级,以支持 Matrix 认证服务和账户创建。"</string>
<string name="error_account_creation_not_possible">"你的账户提供者需要升级以支持 Matrix 认证服务和账户创建。"</string>
<string name="error_failed_creating_the_permalink">"永久链接创建失败"</string>
<string name="error_failed_loading_map">"%1$s 无法加载地图,请稍后再试。"</string>
<string name="error_failed_loading_messages">"消息加载失败"</string>
@@ -491,7 +491,7 @@ Are you sure you want to continue?"</string>
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Select the quality of the video you want to upload."</string>
<string name="dialog_video_quality_selector_title">"Select video upload quality"</string>
<string name="error_account_already_logged_in">"You\'re already logged in on this device as %1$s."</string>
<string name="error_account_creation_not_possible">"Your homeserver needs to be upgraded to support Matrix Authentication Service and account creation."</string>
<string name="error_account_creation_not_possible">"Your account provider needs to be upgraded to support Matrix Authentication Service and account creation."</string>
<string name="error_failed_creating_the_permalink">"Failed creating the permalink"</string>
<string name="error_failed_loading_map">"%1$s could not load the map. Please try again later."</string>
<string name="error_failed_loading_messages">"Failed loading messages"</string>