Merge remote-tracking branch 'origin/develop' into feature/bma/protectLinkNewDevice

This commit is contained in:
Benoit Marty
2026-06-17 11:12:36 +02:00
147 changed files with 1662 additions and 1757 deletions
@@ -27,7 +27,7 @@
<string name="state_event_room_invite_accepted">"%1$s принял(а) приглашение"</string>
<string name="state_event_room_invite_accepted_by_you">"Вы приняли приглашение"</string>
<string name="state_event_room_invite_by_you">"Вы пригласили %1$s"</string>
<string name="state_event_room_invite_you">"%1$s пригласил(а) вас"</string>
<string name="state_event_room_invite_you">"%1$s пригласил(а) Вас"</string>
<string name="state_event_room_join">"%1$s присоединился(ась)"</string>
<string name="state_event_room_join_by_you">"Вы присоединились к комнате"</string>
<string name="state_event_room_knock">"%1$s хочет присоединиться"</string>
@@ -47,8 +47,8 @@
<string name="state_event_room_name_removed_by_you">"Вы удалили название"</string>
<string name="state_event_room_none">"%1$s не внес(ла) изменений"</string>
<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_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 закрепил(а) сообщение"</string>
<string name="state_event_room_pinned_events_pinned_by_you">"Вы закрепили сообщение"</string>
<string name="state_event_room_pinned_events_unpinned">"%1$s открепил(а) сообщение"</string>
@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -53,6 +54,7 @@ import java.util.Optional
interface MatrixClient {
val sessionId: SessionId
val deviceId: DeviceId
val sessionPaths: SessionPaths
val userProfile: StateFlow<MatrixUser>
val roomListService: RoomListService
val spaceService: SpaceService
@@ -10,5 +10,5 @@ package io.element.android.libraries.matrix.api.analytics
import io.element.android.libraries.matrix.api.core.SessionId
fun interface GetDatabaseSizesUseCase {
suspend operator fun invoke(sessionId: SessionId): Result<SdkStoreSizes>
operator fun invoke(sessionId: SessionId): Result<SdkStoreSizes>
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.paths
import java.io.File
data class SessionPaths(
val fileDirectory: File,
val cacheDirectory: File,
) {
fun deleteRecursively() {
fileDirectory.deleteRecursively()
cacheDirectory.deleteRecursively()
}
}
@@ -32,6 +32,7 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -81,7 +82,6 @@ import io.element.android.libraries.matrix.impl.spaces.RustSpaceService
import io.element.android.libraries.matrix.impl.sync.RustSyncService
import io.element.android.libraries.matrix.impl.sync.map
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.util.SessionPathsProvider
import io.element.android.libraries.matrix.impl.util.cancelAndDestroy
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
@@ -137,6 +137,7 @@ import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
class RustMatrixClient(
override val sessionPaths: SessionPaths,
private val innerClient: Client,
private val sessionStore: SessionStore,
private val sessionDelegate: RustClientSessionDelegate,
@@ -187,8 +188,6 @@ class RustMatrixClient(
sessionDispatcher = sessionDispatcher,
)
private val sessionPathsProvider = SessionPathsProvider(sessionStore)
private val roomSyncSubscriber: RoomSyncSubscriber = RoomSyncSubscriber(innerRoomListService, dispatchers)
override val roomListService: RoomListService = RustRoomListService(
@@ -824,17 +823,16 @@ class RustMatrixClient(
private suspend fun getCacheSize(
includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) {
val sessionDirectory = sessionPathsProvider.provides(sessionId) ?: return@withContext 0L
val cacheSize = sessionDirectory.cacheDirectory.getSizeOfFiles()
val cacheSize = sessionPaths.cacheDirectory.getSizeOfFiles()
if (includeCryptoDb) {
cacheSize + sessionDirectory.fileDirectory.getSizeOfFiles()
cacheSize + sessionPaths.fileDirectory.getSizeOfFiles()
} else {
cacheSize + listOf(
"matrix-sdk-state.sqlite3",
"matrix-sdk-state.sqlite3-shm",
"matrix-sdk-state.sqlite3-wal",
).map { fileName ->
File(sessionDirectory.fileDirectory, fileName)
File(sessionPaths.fileDirectory, fileName)
}.sumOf { file ->
file.length()
}
@@ -843,7 +841,7 @@ class RustMatrixClient(
private suspend fun deleteSessionDirectory() = withContext(sessionDispatcher) {
// Delete all the files for this session
sessionPathsProvider.provides(sessionId)?.deleteRecursively()
sessionPaths.deleteRecursively()
}
private fun scheduleDatabaseVacuum() {
@@ -17,9 +17,9 @@ import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.analytics.UtdTracker
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
import io.element.android.libraries.matrix.impl.room.TimelineEventFilterFactory
@@ -102,10 +102,10 @@ class RustMatrixClientFactory(
client.restoreSession(sessionData.toSession())
create(client)
create(client, sessionData)
}
suspend fun create(client: Client): RustMatrixClient {
suspend fun create(client: Client, sessionData: SessionData): RustMatrixClient {
val (anonymizedAccessToken, anonymizedRefreshToken) = client.session().anonymizedTokens()
// Must be called before creating the sync service, timelines etc.
@@ -121,6 +121,7 @@ class RustMatrixClientFactory(
.finish()
return RustMatrixClient(
sessionPaths = sessionData.getSessionPaths(),
innerClient = client,
sessionStore = sessionStore,
appCoroutineScope = appCoroutineScope,
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* 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.
@@ -9,19 +9,53 @@ package io.element.android.libraries.matrix.impl.analytics
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.data.bytes
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.SessionId
import java.io.File
import kotlin.math.max
/**
* An implementation of [GetDatabaseSizesUseCase] that manually calculates the database sizes by checking the file system.
* This is not as accurate as the SDK's internal method, but is used as a fallback for Sentry's non-suspendable hook where
* calling the SDK's suspendable method blocks the main thread and causes ANRs.
*/
@ContributesBinding(AppScope::class)
class DefaultGetDatabaseSizesUseCase(
private val clientProvider: Lazy<MatrixClientProvider>,
) : GetDatabaseSizesUseCase {
override suspend fun invoke(sessionId: SessionId): Result<SdkStoreSizes> {
override fun invoke(sessionId: SessionId): Result<SdkStoreSizes> {
val client = clientProvider.value.getOrNull(sessionId)
?: return Result.failure(IllegalArgumentException("No MatrixClient for session $sessionId"))
return client.getDatabaseSizes()
val fileDir = client.sessionPaths.fileDirectory
val cacheDir = client.sessionPaths.cacheDirectory
val cryptoSize = getDatabaseSize(fileDir, "matrix-sdk-crypto")
val stateSize = getDatabaseSize(fileDir, "matrix-sdk-state")
val eventCacheSize = getDatabaseSize(cacheDir, "matrix-sdk-event-cache")
val mediaCacheSize = getDatabaseSize(cacheDir, "matrix-sdk-media")
return Result.success(SdkStoreSizes(
stateStore = stateSize.bytes,
cryptoStore = cryptoSize.bytes,
eventCacheStore = eventCacheSize.bytes,
mediaStore = mediaCacheSize.bytes,
))
}
private fun getDatabaseSize(baseDirectory: File, databaseFileName: String): Long {
// Check the sizes of both the main database file and the -wal file, and return the max of the two:
// The -wal file can contain additional data not yet merged into the main file, becoming larger than it.
val databaseFile = File(baseDirectory, "$databaseFileName.sqlite3")
val walFile = File(baseDirectory, "$databaseFileName.sqlite3-wal")
val databaseSize = if (databaseFile.exists()) databaseFile.length() else 0L
val walSize = if (walFile.exists()) walFile.length() else 0L
return max(databaseSize, walSize)
}
}
@@ -29,6 +29,7 @@ import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginDat
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
@@ -38,7 +39,6 @@ import io.element.android.libraries.matrix.impl.auth.qrlogin.toStep
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.SessionPaths
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
@@ -160,7 +160,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
val matrixClient = rustMatrixClientFactory.create(client, sessionData)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@@ -235,7 +235,7 @@ class RustMatrixAuthenticationService(
// We restore the client using the just retrieved session data
client.restoreSession(sessionData.toSession())
val matrixClient = rustMatrixClientFactory.create(client)
val matrixClient = rustMatrixClientFactory.create(client, sessionData)
// We wait for the verification state to be known
matrixClient.waitForKnownVerificationState()
@@ -325,7 +325,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = currentSessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
val matrixClient = rustMatrixClientFactory.create(client, sessionData)
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
@@ -390,7 +390,7 @@ class RustMatrixAuthenticationService(
passphrase = pendingKey.formattedAsString(),
sessionPaths = emptySessionPaths,
)
val matrixClient = rustMatrixClientFactory.create(client)
val matrixClient = rustMatrixClientFactory.create(client, sessionData)
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
sessionStore.addSession(sessionData)
@@ -9,7 +9,7 @@
package io.element.android.libraries.matrix.impl.mapper
import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.impl.paths.SessionPaths
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
import org.matrix.rustcomponents.sdk.Session
@@ -8,19 +8,10 @@
package io.element.android.libraries.matrix.impl.paths
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.sessionstorage.api.SessionData
import java.io.File
data class SessionPaths(
val fileDirectory: File,
val cacheDirectory: File,
) {
fun deleteRecursively() {
fileDirectory.deleteRecursively()
cacheDirectory.deleteRecursively()
}
}
internal fun SessionData.getSessionPaths(): SessionPaths {
return SessionPaths(
fileDirectory = File(sessionPath),
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl.paths
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.BaseDirectory
import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.matrix.api.paths.SessionPaths
import java.io.File
import java.util.UUID
@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.appconfig.TimelineConfig
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.coroutine.childScope
import io.element.android.libraries.core.extensions.mapFailure
@@ -222,7 +223,13 @@ class JoinedRustRoom(
)
is CreateTimelineParams.Focused,
CreateTimelineParams.PinnedOnly,
is CreateTimelineParams.Threaded -> TimelineFilter.All
is CreateTimelineParams.Threaded -> {
RustTimelineEventFilterFactory().create(
joinRule = roomInfoFlow.value.joinRule,
isEncrypted = roomInfoFlow.value.isEncrypted,
excludedStateTypes = TimelineConfig.excludedEvents,
)?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All
}
}
val internalIdPrefix = when (createTimelineParams) {
@@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.awaitLoaded
import io.element.android.libraries.matrix.impl.room.join.map
import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapper
import io.element.android.libraries.matrix.impl.roomlist.roomOrNull
import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction
@@ -41,6 +42,7 @@ import org.matrix.rustcomponents.sdk.TimelineConfiguration
import org.matrix.rustcomponents.sdk.TimelineFilter
import org.matrix.rustcomponents.sdk.TimelineFocus
import timber.log.Timber
import uniffi.matrix_sdk_base.EncryptionState
import uniffi.matrix_sdk_ui.TimelineReadReceiptTracking
import java.util.concurrent.atomic.AtomicBoolean
import org.matrix.rustcomponents.sdk.RoomListService as InnerRoomListService
@@ -66,12 +68,6 @@ class RustRoomFactory(
private val mutex = Mutex()
private val isDestroyed: AtomicBoolean = AtomicBoolean(false)
private val eventFilters = TimelineConfig.excludedEvents
.takeIf { it.isNotEmpty() }
?.let { listStateEventType ->
timelineEventFilterFactory.create(listStateEventType)
}
suspend fun destroy() {
withContext(NonCancellable + dispatcher) {
mutex.withLock {
@@ -128,10 +124,20 @@ class RustRoomFactory(
operation = "sdkRoom.timelineWithConfiguration",
description = "Get timeline from the SDK",
) {
val isEncrypted = when (roomInfo.encryptionState) {
EncryptionState.ENCRYPTED -> true
EncryptionState.NOT_ENCRYPTED -> false
EncryptionState.UNKNOWN -> null
}
val timelineFilter = timelineEventFilterFactory.create(
joinRule = roomInfo.joinRule?.map(),
isEncrypted = isEncrypted,
excludedStateTypes = TimelineConfig.excludedEvents,
)
sdkRoom.timelineWithConfiguration(
TimelineConfiguration(
focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents),
filter = eventFilters?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All,
filter = timelineFilter?.let(TimelineFilter::EventFilter) ?: TimelineFilter.All,
internalIdPrefix = "live",
dateDividerMode = DateDividerMode.DAILY,
trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS,
@@ -11,20 +11,45 @@ package io.element.android.libraries.matrix.impl.room
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import org.matrix.rustcomponents.sdk.FilterTimelineEventCondition
import org.matrix.rustcomponents.sdk.FilterTimelineEventType
import org.matrix.rustcomponents.sdk.TimelineEventFilter
import uniffi.matrix_sdk_ui.MembershipChangeFilter
interface TimelineEventFilterFactory {
fun create(listStateEventType: List<StateEventType>): TimelineEventFilter
fun create(
joinRule: JoinRule?,
isEncrypted: Boolean?,
excludedStateTypes: List<StateEventType>
): TimelineEventFilter?
}
@ContributesBinding(AppScope::class)
class RustTimelineEventFilterFactory : TimelineEventFilterFactory {
override fun create(listStateEventType: List<StateEventType>): TimelineEventFilter {
return TimelineEventFilter.excludeEventTypes(
listStateEventType.map { stateEventType ->
FilterTimelineEventType.State(stateEventType.map())
}
)
override fun create(
joinRule: JoinRule?,
isEncrypted: Boolean?,
excludedStateTypes: List<StateEventType>
): TimelineEventFilter? {
val excludedEventTypes = excludedStateTypes.map {
FilterTimelineEventCondition.EventType(FilterTimelineEventType.State(it.map()))
}
// If the room is publicly joinable and not encrypted, we also want to exclude membership changes and profile changes,
// as they will pollute the timelines since they're quite common and not add much value.
val excludedMembershipChanges = if (joinRule !is JoinRule.Invite && isEncrypted == false) {
listOf(
FilterTimelineEventCondition.MembershipChange(MembershipChangeFilter.JOIN),
FilterTimelineEventCondition.MembershipChange(MembershipChangeFilter.LEAVE),
FilterTimelineEventCondition.ProfileChange,
)
} else {
emptyList()
}
return if (excludedEventTypes.isNotEmpty() || excludedMembershipChanges.isNotEmpty()) {
TimelineEventFilter.exclude(excludedEventTypes + excludedMembershipChanges)
} else {
null
}
}
}
@@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.impl.storage
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.data.megaBytes
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.api.paths.SessionPaths
import org.matrix.rustcomponents.sdk.ClientBuilder
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder
@@ -9,7 +9,7 @@ package io.element.android.libraries.matrix.impl.storage
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.api.paths.SessionPaths
interface SqliteStoreBuilderProvider {
fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder
@@ -38,8 +38,6 @@ import io.element.android.libraries.matrix.impl.room.location.into
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.virtual.VirtualTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.postprocessor.FilterEmptyDayPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.FilterPublicMembershipChangesPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LastForwardIndicatorsPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.LoadingIndicatorsPostProcessor
import io.element.android.libraries.matrix.impl.timeline.postprocessor.RoomBeginningPostProcessor
@@ -125,8 +123,6 @@ class RustTimeline(
private val loadingIndicatorsPostProcessor = LoadingIndicatorsPostProcessor(systemClock)
private val lastForwardIndicatorsPostProcessor = LastForwardIndicatorsPostProcessor(mode)
private val typingNotificationPostProcessor = TypingNotificationPostProcessor(mode)
private val publicMembershipChangesPostProcessor = FilterPublicMembershipChangesPostProcessor()
private val emptyDayPostProcessor = FilterEmptyDayPostProcessor()
private data class RoomTimelineInfo(
val roomCreators: ImmutableList<UserId>,
@@ -252,18 +248,6 @@ class RustTimeline(
hasMoreToLoadBackwards = backwardPaginationStatus.hasMoreToLoad,
)
}
// This should be the first post processor after room beginning.
.let { items ->
publicMembershipChangesPostProcessor.process(
items = items,
joinRule = joinRule,
isEncrypted = isEncrypted,
)
}
// After removing public membership changes, we might end up with empty days, so we need to filter them out.
.let { items ->
emptyDayPostProcessor.process(items)
}
.let { items ->
loadingIndicatorsPostProcessor.process(
items = items,
@@ -1,40 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
/**
* Post-processor to filter out day separators for days that don't contain any events.
*/
class FilterEmptyDayPostProcessor {
/**
* Filters out day separators from [items] for days that don't contain any events.
*/
fun process(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> = buildList {
// The timeline is ordered by ascending timestamp, so events that happened during a day appear after the day separator for that day.
// We can use this to determine if a day separator should be kept or not by traversing the list in reverse.
var hasEvent = false
for (item in items.asReversed()) {
if (item is MatrixTimelineItem.Event) {
hasEvent = true
add(item)
} else if (item is MatrixTimelineItem.Virtual && item.virtual is VirtualTimelineItem.DayDivider) {
if (hasEvent) {
add(item)
hasEvent = false
}
} else {
add(item)
}
}
}
// Then reverse the result to restore the original order, minus the empty day separators.
.asReversed()
}
@@ -1,43 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileChangeContent
import io.element.android.libraries.matrix.api.timeline.item.event.RoomMembershipContent
/**
* Post-processor to filter out public membership changes for non-encrypted, publicly joinable rooms.
*/
class FilterPublicMembershipChangesPostProcessor {
/**
* Filters out public membership changes from [items] if the room is publicly joinable and not encrypted.
*/
fun process(
items: List<MatrixTimelineItem>,
joinRule: JoinRule?,
isEncrypted: Boolean?,
): List<MatrixTimelineItem> {
return if (joinRule !is JoinRule.Invite && isEncrypted == false) {
filterMembershipEvents(items)
} else {
items
}
}
private fun filterMembershipEvents(items: List<MatrixTimelineItem>): List<MatrixTimelineItem> = items.filter { item ->
val eventContent = (item as? MatrixTimelineItem.Event)?.event?.content ?: return@filter true
when (eventContent) {
is RoomMembershipContent -> eventContent.change != null && eventContent.change !in listOf(MembershipChange.JOINED, MembershipChange.LEFT)
is ProfileChangeContent -> false
else -> true
}
}
}
@@ -1,23 +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.impl.util
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.sessionstorage.api.SessionStore
class SessionPathsProvider(
private val sessionStore: SessionStore,
) {
suspend fun provides(sessionId: SessionId): SessionPaths? {
val sessionData = sessionStore.getSession(sessionId.value) ?: return null
return sessionData.getSessionPaths()
}
}
@@ -13,6 +13,7 @@ package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.data.bytes
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
@@ -141,6 +142,7 @@ class RustMatrixClientTest {
),
) = RustMatrixClient(
innerClient = client,
sessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")),
sessionStore = sessionStore,
appCoroutineScope = backgroundScope,
sessionDelegate = aRustClientSessionDelegate(
@@ -10,8 +10,8 @@ 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.impl.paths.SessionPaths
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL_2
@@ -9,11 +9,12 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.join.JoinRule
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEventFilter
import org.matrix.rustcomponents.sdk.TimelineEventFilter
class FakeTimelineEventFilterFactory : TimelineEventFilterFactory {
override fun create(listStateEventType: List<StateEventType>): TimelineEventFilter {
override fun create(joinRule: JoinRule?, isEncrypted: Boolean?, excludedStateTypes: List<StateEventType>): TimelineEventFilter {
return FakeFfiTimelineEventFilter()
}
}
@@ -7,7 +7,7 @@
package io.element.android.libraries.matrix.impl.storage
import io.element.android.libraries.matrix.impl.paths.SessionPaths
import io.element.android.libraries.matrix.api.paths.SessionPaths
class FakeSqliteStoreBuilderProvider : SqliteStoreBuilderProvider {
override fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder {
@@ -1,118 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UniqueId
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem
import org.junit.Test
private const val TODAY = 1_779_779_967_000
private const val YESTERDAY = TODAY - 24 * 60 * 60 * 1000
private const val DAY_BEFORE_YESTERDAY = YESTERDAY - 24 * 60 * 60 * 1000
class FilterEmptyDayPostProcessorTest {
private val anEvent = MatrixTimelineItem.Event(
uniqueId = UniqueId("event"),
event = anEventTimelineItem(),
)
private fun aDaySeparator(timestmap: Long) = MatrixTimelineItem.Virtual(
uniqueId = UniqueId("day_$timestmap"),
virtual = VirtualTimelineItem.DayDivider(timestmap)
)
@Test
fun `filterEmptyDaySeparators keeps day separator with events after it`() {
val items = listOf(
aDaySeparator(TODAY),
anEvent,
)
val result = FilterEmptyDayPostProcessor().process(items)
assertThat(result).hasSize(2)
assertThat(result[0]).isEqualTo(aDaySeparator(TODAY))
assertThat(result[1]).isEqualTo(anEvent)
}
@Test
fun `filterEmptyDaySeparators removes day separator with no events after it`() {
val items = listOf(
aDaySeparator(YESTERDAY),
aDaySeparator(TODAY),
)
val result = FilterEmptyDayPostProcessor().process(items)
assertThat(result).isEmpty()
}
@Test
fun `filterEmptyDaySeparators removes second day separator and keeps first when only first has events`() {
val items = listOf(
aDaySeparator(YESTERDAY),
anEvent,
aDaySeparator(TODAY),
)
val result = FilterEmptyDayPostProcessor().process(items)
assertThat(result).hasSize(2)
assertThat(result[0]).isEqualTo(aDaySeparator(YESTERDAY))
assertThat(result[1]).isEqualTo(anEvent)
}
@Test
fun `filterEmptyDaySeparators handles multiple day separators in a row with no events`() {
val items = listOf(
aDaySeparator(TODAY),
aDaySeparator(YESTERDAY),
aDaySeparator(DAY_BEFORE_YESTERDAY),
)
val result = FilterEmptyDayPostProcessor().process(items)
assertThat(result).isEmpty()
}
@Test
fun `filterEmptyDaySeparators keeps all items when no day separators`() {
val items = listOf(
anEvent.copy(uniqueId = UniqueId("event2")),
anEvent,
)
val result = FilterEmptyDayPostProcessor().process(items)
assertThat(result).hasSize(2)
}
@Test
fun `filterEmptyDaySeparators removes day separator preceded by non-event virtual item`() {
val readMarker = MatrixTimelineItem.Virtual(
uniqueId = UniqueId("readMarker"),
virtual = VirtualTimelineItem.ReadMarker
)
val items = listOf(
aDaySeparator(TODAY),
readMarker,
)
val result = FilterEmptyDayPostProcessor().process(items)
assertThat(result).hasSize(1)
assertThat(result[0]).isEqualTo(readMarker)
}
@Test
fun `filterEmptyDaySeparators keeps day separator when non-event virtual items are between separator and event`() {
val readMarker = MatrixTimelineItem.Virtual(
uniqueId = UniqueId("readMarker"),
virtual = VirtualTimelineItem.ReadMarker
)
val items = listOf(
aDaySeparator(TODAY),
readMarker,
anEvent,
)
val result = FilterEmptyDayPostProcessor().process(items)
assertThat(result).hasSize(3)
assertThat(result[0]).isEqualTo(aDaySeparator(TODAY))
}
}
@@ -1,75 +0,0 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.timeline.postprocessor
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.join.JoinRule
import org.junit.Test
class FilterPublicMembershipChangesPostProcessorTest {
@Test
fun `processor removes join, leave, and profile events in unencrypted public rooms`() {
val timelineItems = listOf(
roomCreateEvent,
roomCreatorJoinEvent,
otherMemberJoinEvent,
messageEvent,
otherMemberLeaveEvent,
profileChangeEvent,
)
val expected = listOf(
roomCreateEvent,
messageEvent,
)
val processor = FilterPublicMembershipChangesPostProcessor()
val processedItems = processor.process(
timelineItems,
joinRule = JoinRule.Public,
isEncrypted = false,
)
assertThat(processedItems).isEqualTo(expected)
}
@Test
fun `processor keeps all events in encrypted public rooms`() {
val timelineItems = listOf(
roomCreateEvent,
roomCreatorJoinEvent,
otherMemberJoinEvent,
messageEvent,
otherMemberLeaveEvent,
profileChangeEvent,
)
val processor = FilterPublicMembershipChangesPostProcessor()
val processedItems = processor.process(
timelineItems,
joinRule = JoinRule.Public,
isEncrypted = true,
)
assertThat(processedItems).isEqualTo(timelineItems)
}
@Test
fun `processor keeps membership events in invite-only rooms`() {
val timelineItems = listOf(
roomCreateEvent,
roomCreatorJoinEvent,
otherMemberJoinEvent,
messageEvent,
otherMemberLeaveEvent,
profileChangeEvent,
)
val processor = FilterPublicMembershipChangesPostProcessor()
val processedItems = processor.process(
timelineItems,
joinRule = JoinRule.Invite,
isEncrypted = null,
)
assertThat(processedItems).isEqualTo(timelineItems)
}
}
@@ -1,41 +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.impl.util
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SessionPathsProviderTest {
@Test
fun `if session is not found, provides returns null`() = runTest {
val sut = SessionPathsProvider(InMemorySessionStore())
val result = sut.provides(A_SESSION_ID)
assertThat(result).isNull()
}
@Test
fun `if session is found, provides returns the data`() = runTest {
val store = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionPath = "/a/path/to/a/session",
cachePath = "/a/path/to/a/cache",
)
)
)
val sut = SessionPathsProvider(store)
val result = sut.provides(A_SESSION_ID)!!
assertThat(result.fileDirectory.absolutePath).isEqualTo("/a/path/to/a/session")
assertThat(result.cacheDirectory.absolutePath).isEqualTo("/a/path/to/a/cache")
}
}
@@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.media.MediaPreviewService
import io.element.android.libraries.matrix.api.notification.NotificationService
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.BaseRoom
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -66,10 +67,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import java.io.File
import java.util.Optional
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")),
override val deviceId: DeviceId = A_DEVICE_ID,
override val sessionCoroutineScope: CoroutineScope = TestScope(),
private val userDisplayName: String? = A_USER_NAME,
@@ -5,5 +5,5 @@
<string name="screen_bottom_sheet_create_dm_title">"Отправить приглашение?"</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_content">"У Вас нет других чатов с этим пользователем. Подтвердите, что это действительно кого Вы хотите пригласить, прежде чем продолжить."</string>
<string name="screen_bottom_sheet_create_dm_unknown_user_title">"Начать чат с этим новым контактом?"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) пригласил(а) вас"</string>
<string name="screen_invites_invited_you">"%1$s (%2$s) пригласил(а) Вас"</string>
</resources>
@@ -100,7 +100,11 @@ class AndroidLocalMediaActions(
override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatchingExceptions {
val shareableUri = localMedia.toShareableUri()
// Make a copy of the shared file in the cache directory, otherwise the original file will be gone once this screen is dismissed
// and will prevent sharing the media to another room inside the app.
val copiedFile = localMedia.uri.toFile()
.copyTo(File(context.cacheDir, "temp/media/" + (localMedia.uri.lastPathSegment ?: "shared_file")), true)
val shareableUri = copiedFile.toShareableUri()
val shareMediaIntent = Intent(Intent.ACTION_SEND)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_STREAM, shareableUri)
@@ -157,10 +161,13 @@ class AndroidLocalMediaActions(
}
}
private fun LocalMedia.toShareableUri(): Uri {
val mediaAsFile = this.toFile()
private fun File.toShareableUri(): Uri {
val authority = "${buildMeta.applicationId}.fileprovider"
return FileProvider.getUriForFile(context, authority, mediaAsFile).normalizeScheme()
return FileProvider.getUriForFile(context, authority, this).normalizeScheme()
}
private fun LocalMedia.toShareableUri(): Uri {
return this.toFile().toShareableUri()
}
@RequiresApi(Build.VERSION_CODES.Q)
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_media_details_title">"Інфармацыя пра файл"</string>
</resources>
@@ -15,8 +15,8 @@
<item quantity="few">"%d уведомления"</item>
<item quantity="many">"%d уведомлений"</item>
</plurals>
<string name="notification_error_unified_push_unregistered_android">"Не удалось зарегистрировать дистрибьютора уведомлений UnifiedPush, поэтому вы больше не будете получать уведомления. Проверьте настройки уведомлений в приложении и статус дистрибьютора push-уведомлений."</string>
<string name="notification_fallback_content">"У вас есть новые сообщения."</string>
<string name="notification_error_unified_push_unregistered_android">"Не удалось зарегистрировать дистрибьютора уведомлений UnifiedPush, поэтому Вы больше не будете получать уведомления. Проверьте настройки уведомлений в приложении и статус дистрибьютора push-уведомлений."</string>
<string name="notification_fallback_content">"У Вас есть новые сообщения."</string>
<plurals name="notification_fallback_n_content">
<item quantity="one">"У Вас %d новое сообщение."</item>
<item quantity="few">"У Вас %d новых сообщения."</item>
@@ -32,8 +32,8 @@
<item quantity="few">"%d приглашения"</item>
<item quantity="many">"%d приглашений"</item>
</plurals>
<string name="notification_invite_body">"Пригласил(а) вас в чат"</string>
<string name="notification_invite_body_with_sender">"%1$s пригласил(а) вас в чат"</string>
<string name="notification_invite_body">"Пригласил(а) Вас в чат"</string>
<string name="notification_invite_body_with_sender">"%1$s пригласил(а) Вас в чат"</string>
<string name="notification_mentioned_you_body">"Упомянул(а) вас: %1$s"</string>
<string name="notification_new_messages">"Новые сообщения"</string>
<plurals name="notification_new_messages_for_room">
@@ -44,12 +44,12 @@
<string name="notification_reaction_body">"Отреагировал(а) на %1$s"</string>
<string name="notification_room_action_mark_as_read">"Пометить как прочитанное"</string>
<string name="notification_room_action_quick_reply">"Быстрый ответ"</string>
<string name="notification_room_invite_body">"Пригласил(а) вас в комнату"</string>
<string name="notification_room_invite_body_with_sender">"%1$s пригласил(а) вас в комнату"</string>
<string name="notification_room_invite_body">"Пригласил(а) Вас в комнату"</string>
<string name="notification_room_invite_body_with_sender">"%1$s пригласил(а) Вас в комнату"</string>
<string name="notification_sender_me">"Я"</string>
<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_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_thread_in_room">"Обсуждение в %1$s"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
@@ -4,7 +4,9 @@
<string name="rich_text_editor_bullet_list">"Пераключыць маркіраваны спіс"</string>
<string name="rich_text_editor_close_formatting_options">"Закрыць параметры фарматавання"</string>
<string name="rich_text_editor_code_block">"Пераключыць блок кода"</string>
<string name="rich_text_editor_composer_encrypted_placeholder">"Зашыфраванае паведамленне…"</string>
<string name="rich_text_editor_composer_placeholder">"Паведамленне…"</string>
<string name="rich_text_editor_composer_unencrypted_placeholder">"Незашыфраванае паведамленне…"</string>
<string name="rich_text_editor_create_link">"Стварыць спасылку"</string>
<string name="rich_text_editor_edit_link">"Рэдагаваць спасылку"</string>
<string name="rich_text_editor_format_bold">"Ужыць тоўсты шрыфт"</string>
@@ -7,6 +7,6 @@
<string name="troubleshoot_notifications_screen_notice">"Выполните тесты, чтобы выявить проблемы в конфигурации, из-за которой уведомления могут работать не так, как ожидалось."</string>
<string name="troubleshoot_notifications_screen_quick_fix_action">"Попытаться исправить"</string>
<string name="troubleshoot_notifications_screen_success">"Все тесты прошли успешно."</string>
<string name="troubleshoot_notifications_screen_title">"Уведомления об устранении неполадок"</string>
<string name="troubleshoot_notifications_screen_title">"Устранение неполадок с уведомлениями"</string>
<string name="troubleshoot_notifications_screen_waiting">"Некоторые тесты требуют вашего внимания. Пожалуйста, проверьте подробности."</string>
</resources>
@@ -13,6 +13,7 @@
<string name="a11y_edit_room_address_hint">"Поўны адрас будзе %1$s"</string>
<string name="a11y_encryption_details">"Дэталі шыфравання"</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_notifications_mentions_only">"Толькі згадкі"</string>
@@ -72,12 +73,16 @@
<string name="action_deactivate_account">"Дэактываваць уліковы запіс"</string>
<string name="action_decline">"Адхіліць"</string>
<string name="action_decline_and_block">"Адхіліць і заблакіраваць"</string>
<string name="action_delete">"Выдаліць"</string>
<string name="action_delete_account">"Выдаліць уліковы запіс"</string>
<string name="action_delete_file">"Выдаліць файл"</string>
<string name="action_delete_poll">"Выдаліць апытанне"</string>
<string name="action_deselect_all">"Зняць выбар з усіх"</string>
<string name="action_disable">"Адключыць"</string>
<string name="action_discard">"Адмяніць"</string>
<string name="action_dismiss">"Aдхіліць"</string>
<string name="action_done">"Гатова"</string>
<string name="action_download">"Спампаваць"</string>
<string name="action_edit">"Рэдагаваць"</string>
<string name="action_edit_caption">"Рэдагаваць подпіс"</string>
<string name="action_edit_poll">"Рэдагаваць апытанне"</string>
@@ -119,6 +124,7 @@
<string name="action_react">"Рэакцыя"</string>
<string name="action_reject">"Адхіліць"</string>
<string name="action_remove">"Выдаліць"</string>
<string name="action_remove_message">"Выдаліць паведамленне"</string>
<string name="action_reply">"Адказаць"</string>
<string name="action_reply_in_thread">"Адказаць у гутаркі"</string>
<string name="action_report">"Паскардзіцца"</string>
@@ -133,7 +139,7 @@
<string name="action_select_all">"Выбраць усё"</string>
<string name="action_send">"Адправіць"</string>
<string name="action_send_message">"Адправіць паведамленне"</string>
<string name="action_share">"Падзяліцца"</string>
<string name="action_share">"Абагуліць"</string>
<string name="action_share_link">"Абагуліць спасылку"</string>
<string name="action_show">"Паказаць"</string>
<string name="action_sign_in_again">"Увайдзіце яшчэ раз"</string>
@@ -214,8 +220,10 @@
<string name="common_message_removed">"Паведамленне выдалена"</string>
<string name="common_modern">"Сучасны"</string>
<string name="common_mute">"Адкл. гук"</string>
<string name="common_name">"Імя"</string>
<string name="common_no_results">"Вынікаў няма"</string>
<string name="common_no_room_name">"Няма назвы пакоя"</string>
<string name="common_not_encrypted">"Не зашыфравана"</string>
<string name="common_offline">"Па-за сеткай"</string>
<string name="common_open_source_licenses">"Ліцэнзіі з адкрытым зыходным кодам"</string>
<string name="common_or">"або"</string>
@@ -227,6 +235,7 @@
<string name="common_please_wait">"Калі ласка, пачакайце…"</string>
<string name="common_poll_end_confirmation">"Вы ўпэўнены, што хочаце скончыць гэтае апытанне?"</string>
<string name="common_poll_summary">"Апытанне: %1$s"</string>
<string name="common_poll_summary_prefix">"Апытанне"</string>
<string name="common_poll_total_votes">"Усяго галасоў: %1$s"</string>
<string name="common_poll_undisclosed_text">"Вынікі будуць паказаны пасля завяршэння апытання"</string>
<plurals name="common_poll_votes_count">
@@ -241,6 +250,7 @@
<string name="common_reactions">"Рэакцыі"</string>
<string name="common_recovery_key">"Ключ аднаўлення"</string>
<string name="common_refreshing">"Абнаўленне…"</string>
<string name="common_removing">"Выдаленне…"</string>
<string name="common_replying_to">"Адказвае на %1$s"</string>
<string name="common_report_a_problem">"Паведаміць аб праблеме"</string>
<string name="common_report_submitted">"Скарга прынята"</string>
@@ -262,6 +272,7 @@
<string name="common_server_not_supported">"Сервер не падтрымліваецца"</string>
<string name="common_server_url">"URL-адрас сервера"</string>
<string name="common_settings">"Налады"</string>
<string name="common_share_space">"Абагуліць прастору"</string>
<string name="common_shared_location">"Абагулена месцазнаходжанне"</string>
<string name="common_signing_out">"Выхад"</string>
<string name="common_something_went_wrong">"Нешта пайшло не так"</string>
@@ -315,6 +326,11 @@
<string name="invite_friends_text">"Гэй, пагавары са мной у %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Паведаміць аб памылцы з дапамогай Rageshake"</string>
<string name="screen_bug_report_a11y_screenshot">"Здымак экрана"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Параметры"</string>
<string name="screen_create_poll_remove_accessibility_label">"Выдаліць %1$s"</string>
<string name="screen_create_poll_settings_section_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>
@@ -335,8 +351,8 @@
<string name="screen_room_pinned_banner_loading_description">"Загрузка паведамлення…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Паглядзець усе"</string>
<string name="screen_room_title">"Чат"</string>
<string name="screen_share_location_title">"Падзяліцца месцазнаходжаннем"</string>
<string name="screen_share_my_location_action">"Падзяліцца маім месцазнаходжаннем"</string>
<string name="screen_share_location_title">"Абагуліць месцазнаходжанне"</string>
<string name="screen_share_my_location_action">"Абагуліць мае месцазнаходжанне"</string>
<string name="screen_share_open_apple_maps">"Адкрыць у Apple Maps"</string>
<string name="screen_share_open_google_maps">"Адкрыць у Google Maps"</string>
<string name="screen_share_open_osm_maps">"Адкрыць у OpenStreetMap"</string>
@@ -54,7 +54,7 @@
<string name="a11y_send_files">"Отправить файлы"</string>
<string name="a11y_sender_location">"Местоположение отправителя"</string>
<string name="a11y_sent_by_sender_at_date">"Отправлено пользователем %1$s в %2$s"</string>
<string name="a11y_session_verification_time_limited_action_required">"Требуется действие, на которое есть ограничение по времени, у вас есть одна минута для проверки"</string>
<string name="a11y_session_verification_time_limited_action_required">"Требуется действие, на которое есть ограничение по времени, у Вас есть одна минута для проверки"</string>
<string name="a11y_settings_with_required_action">"Настройки, требуется действие"</string>
<string name="a11y_show_password">"Показать пароль"</string>
<string name="a11y_start_call">"Начать звонок"</string>
@@ -101,6 +101,7 @@
<string name="action_decline_and_block">"Отклонить и заблокировать"</string>
<string name="action_delete">"Удалить"</string>
<string name="action_delete_account">"Удалить аккаунт"</string>
<string name="action_delete_file">"Удалить файл"</string>
<string name="action_delete_poll">"Удалить опрос"</string>
<string name="action_deselect_all">"Отменить выбор"</string>
<string name="action_disable">"Отключить"</string>
@@ -133,7 +134,7 @@
<string name="action_leave_conversation">"Покинуть беседу"</string>
<string name="action_leave_room">"Покинуть комнату"</string>
<string name="action_leave_space">"Покинуть пространство"</string>
<string name="action_load_more">"Загрузить еще"</string>
<string name="action_load_more">"Загрузить ещё"</string>
<string name="action_manage_account">"Настройки аккаунта"</string>
<string name="action_manage_account_and_devices">"Управление учетной записью и устройствами"</string>
<string name="action_manage_devices">"Управление устройствами"</string>
@@ -179,7 +180,7 @@
<string name="action_show">"Показать"</string>
<string name="action_sign_in_again">"Повторить вход"</string>
<string name="action_signout">"Удалить это устройство"</string>
<string name="action_signout_anyway">"Все равно удалить это устройство"</string>
<string name="action_signout_anyway">"Всё равно удалить это устройство"</string>
<string name="action_skip">"Пропустить"</string>
<string name="action_start">"Начать"</string>
<string name="action_start_chat">"Начать чат"</string>
@@ -196,8 +197,8 @@
<string name="action_view_in_timeline">"Просмотреть в хронологии"</string>
<string name="action_view_source">"Показать источник"</string>
<string name="action_yes">"Да"</string>
<string name="action_yes_try_again">"Да, попробовать еще раз"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Теперь ваш сервер поддерживает новый, более быстрый протокол. Чтобы обновить его прямо сейчас, выйдите и войдите в свою учётную запись снова. Сделав это сейчас, вы сможете избежать принудительного выхода из системы при последующем удалении старого протокола."</string>
<string name="action_yes_try_again">"Да, попробовать ещё раз"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Теперь Ваш сервер поддерживает новый, более быстрый протокол. Чтобы обновить его прямо сейчас, выйдите и войдите в свою учётную запись снова. Сделав это сейчас, Вы сможете избежать принудительного выхода из системы при последующем удалении старого протокола."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Доступно обновление"</string>
<string name="common_about">"О приложении"</string>
<string name="common_acceptable_use_policy">"Политика допустимого использования"</string>
@@ -377,7 +378,7 @@
<string name="common_shared_space">"Общее пространство"</string>
<string name="common_signing_out">"Удаление устройства"</string>
<string name="common_something_went_wrong">"Что-то пошло не так"</string>
<string name="common_something_went_wrong_message">"Мы столкнулись с проблемой. Пожалуйста, попробуйте еще раз."</string>
<string name="common_something_went_wrong_message">"Мы столкнулись с проблемой. Пожалуйста, попробуйте ещё раз."</string>
<string name="common_space">"Пространство"</string>
<string name="common_space_members">"Участники пространства"</string>
<string name="common_space_topic_placeholder">"О чём это пространство?"</string>
@@ -429,8 +430,8 @@
<string name="common_waiting_live_location">"Ожидание трансляции местоположения…"</string>
<string name="common_world_readable_history">"Любой может видеть историю"</string>
<string name="common_you">"Вы"</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_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>
<string name="crypto_identity_change_pin_violation">"Личность %1$s была сброшена. %2$s"</string>
<string name="crypto_identity_change_pin_violation_new">"Личность %1$s %2$s была сброшена. %3$s"</string>
@@ -453,11 +454,11 @@
<string name="dialog_title_error">"Ошибка"</string>
<string name="dialog_title_success">"Успешно"</string>
<string name="dialog_title_warning">"Предупреждение"</string>
<string name="dialog_unsaved_changes_description">"У вас есть несохраненные изменения."</string>
<string name="dialog_unsaved_changes_description">"У Вас есть несохраненные изменения."</string>
<string name="dialog_unsaved_changes_description_android">"Изменения не сохранены. Вы действительно хотите вернуться?"</string>
<string name="dialog_unsaved_changes_title">"Сохранить изменения?"</string>
<string name="dialog_video_quality_selector_subtitle_file_size">"Максимально допустимый размер файла: %1$s"</string>
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Выберите качество для видео, которое вы хотите загрузить."</string>
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Выберите качество видео, которое Вы хотите загрузить."</string>
<string name="dialog_video_quality_selector_title">"Выберите качество загружаемого видео"</string>
<string name="emoji_picker_search_placeholder">"Поиск эмодзи"</string>
<string name="error_account_already_logged_in">"Вы уже вошли на данном устройстве как %1$s."</string>
@@ -498,7 +499,7 @@
<item quantity="many">"%1$d людей"</item>
</plurals>
<string name="screen_live_location_sheet_title">"На карте"</string>
<string name="screen_media_picker_error_failed_selection">"Не удалось выбрать медиа, попробуйте еще раз."</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>
<plurals name="screen_pinned_timeline_screen_title">
@@ -506,19 +507,19 @@
<item quantity="few">"%1$d закреплённых сообщения"</item>
<item quantity="many">"%1$d закреплённых сообщений"</item>
</plurals>
<string name="screen_pinned_timeline_screen_title_empty">"Закрепленные сообщения"</string>
<string name="screen_pinned_timeline_screen_title_empty">"Закреплённые сообщения"</string>
<string name="screen_reset_identity_confirmation_subtitle">"Вы собираетесь перейти в свой аккаунт %1$s, чтобы сбросить личность. После этого Вы вернётесь в приложение."</string>
<string name="screen_reset_identity_confirmation_title">"Не можете подтвердить? Перейдите в свой аккаунт, чтобы сбросить свою личность."</string>
<string name="screen_resolve_send_failure_changed_identity_primary_button_title">"Сбросить верификацию и отправить"</string>
<string name="screen_resolve_send_failure_changed_identity_subtitle">"Вы можете либо сбросить подтверждение и всё равно отправить это сообщение, либо отменить его сейчас и повторить попытку после повторного подтверждения %1$s."</string>
<string name="screen_resolve_send_failure_changed_identity_title">"Ваше сообщение не было отправлено, потому что подтвержденная идентификации %1$s была сброшена"</string>
<string name="screen_resolve_send_failure_unsigned_device_primary_button_title">"Все равно отправить сообщение"</string>
<string name="screen_resolve_send_failure_unsigned_device_subtitle">"У %1$s есть одно или несколько неподтвержденных устройств. Вы все равно можете отправить сообщение или отменить его пока и повторить попытку позже, когда %2$s подтвердить все свои устройства."</string>
<string name="screen_resolve_send_failure_unsigned_device_primary_button_title">"Всё равно отправить сообщение"</string>
<string name="screen_resolve_send_failure_unsigned_device_subtitle">"У %1$s есть одно или несколько неподтвержденных устройств. Вы всё равно можете отправить сообщение или отменить его и повторить попытку позже, когда %2$s подтвердит все свои устройства."</string>
<string name="screen_resolve_send_failure_unsigned_device_title">"Ваше сообщение не было отправлено, потому что %1$s имеет неподтвержденные устройства"</string>
<string name="screen_resolve_send_failure_you_unsigned_device_subtitle">"Одно или несколько ваших устройств не подтверждены. Вы можете отправить сообщение в любом случае или отменить его пока и повторить попытку позже, подтвердив все свои устройства."</string>
<string name="screen_resolve_send_failure_you_unsigned_device_title">"Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств."</string>
<string name="screen_room_change_role_administrators_or_owners_title">"Редактировать владельцев и администраторов"</string>
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_room_error_failed_processing_media">"Не удалось обработать медиафайл для загрузки, попробуйте ещё раз."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
<string name="screen_room_event_pill">"Сообщение в %1$s"</string>
<string name="screen_room_grouped_state_events_expand">"Развернуть"</string>
@@ -526,9 +527,9 @@
<string name="screen_room_live_location_banner">"Поделиться текущим местоположением…"</string>
<string name="screen_room_permalink_same_room_android">"Эта комната уже просматривается!"</string>
<string name="screen_room_pinned_banner_indicator">"%1$s из %2$s"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s закрепленные сообщения"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Закреплённые сообщения"</string>
<string name="screen_room_pinned_banner_loading_description">"Загрузка сообщения…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Посмотреть все"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Посмотреть Все"</string>
<string name="screen_room_title">"Чат"</string>
<string name="screen_share_location_title">"Поделиться местоположением"</string>
<string name="screen_share_my_location_action">"Поделиться моим местоположением"</string>
@@ -555,4 +556,6 @@
<string name="timeline_decryption_failure_historical_event_user_not_joined">"Вы не имеете доступа к этому сообщению"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Не удалось расшифровать сообщение"</string>
<string name="timeline_decryption_failure_withheld_unverified">"Это сообщение было заблокировано либо потому, что вы не подтвердили свое устройство, либо потому, что отправителю необходимо подтвердить вашу цифровую личность."</string>
<string name="vulkan_not_supported_dialog_content_android">"Ваше устройство слишком старое, требуется устройство с Android 8 или более новой версией."</string>
<string name="vulkan_not_supported_dialog_title_android">"Отображение карт не поддерживается"</string>
</resources>
@@ -10,6 +10,7 @@
<item quantity="few">"Введено %1$d цифри"</item>
<item quantity="many">"Введено %1$d цифр"</item>
</plurals>
<string name="a11y_duration">"Тривалість: %1$s"</string>
<string name="a11y_edit_avatar">"Редагувати аватар"</string>
<string name="a11y_edit_room_address_hint">"Повна адреса буде %1$s"</string>
<string name="a11y_encryption_details">"Подробиці шифрування"</string>
@@ -18,6 +19,7 @@
<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_move_the_map_to_my_location">"Перемістити карту до мого місця перебування"</string>
<string name="a11y_notifications_mentions_only">"Тільки згадки"</string>
<string name="a11y_notifications_muted">"Звук вимкнено"</string>
@@ -34,6 +36,7 @@
<string name="a11y_playback_speed">"Швидкість програвання"</string>
<string name="a11y_poll">"Опитування"</string>
<string name="a11y_poll_end">"Опитування завершено"</string>
<string name="a11y_position">"Позиція: %1$s"</string>
<string name="a11y_qr_code">"QR-код"</string>
<string name="a11y_react_with">"Реагувати з%1$s"</string>
<string name="a11y_react_with_other_emojis">"Відреагувати іншими смайликами"</string>
@@ -50,12 +53,15 @@
<string name="a11y_room_avatar">"Аватар кімнати"</string>
<string name="a11y_send_files">"Надіслати файли"</string>
<string name="a11y_sender_location">"Локація відправника"</string>
<string name="a11y_sent_by_sender_at_date">"Надіслано %1$s о %2$s"</string>
<string name="a11y_session_verification_time_limited_action_required">"Необхідно виконати дію, обмежену в часі, у вас є одна хвилина для верифікації"</string>
<string name="a11y_settings_with_required_action">"Налаштування, потрібні дії"</string>
<string name="a11y_show_password">"Показати пароль"</string>
<string name="a11y_start_call">"Розпочати виклик"</string>
<string name="a11y_start_video_call">"Розпочати відеодзвінок"</string>
<string name="a11y_start_voice_call">"Розпочати голосовий дзвінок"</string>
<string name="a11y_thread_in_room">"Гілка в %1$s"</string>
<string name="a11y_threads_in_room">"Гілки в %1$s"</string>
<string name="a11y_tombstoned_room">"Кімната більше не використовується"</string>
<string name="a11y_user_avatar">"Аватар користувача"</string>
<string name="a11y_user_menu">"Меню користувача"</string>
@@ -95,6 +101,7 @@
<string name="action_decline_and_block">"Відхилити та заблокувати"</string>
<string name="action_delete">"Видалити"</string>
<string name="action_delete_account">"Видалити обліковий запис"</string>
<string name="action_delete_file">"Видалити файл"</string>
<string name="action_delete_poll">"Видалити опитування"</string>
<string name="action_deselect_all">"Скасувати вибір усіх"</string>
<string name="action_disable">"Вимкнути"</string>
@@ -302,6 +309,7 @@
<string name="common_please_wait">"Будь ласка, зачекайте…"</string>
<string name="common_poll_end_confirmation">"Ви впевнені, що хочете закінчити це опитування?"</string>
<string name="common_poll_summary">"Опитування: %1$s"</string>
<string name="common_poll_summary_prefix">"Опитування"</string>
<string name="common_poll_total_votes">"Всього голосів: %1$s"</string>
<string name="common_poll_undisclosed_text">"Результати будуть показані після завершення опитування"</string>
<plurals name="common_poll_votes_count">
@@ -473,6 +481,9 @@
<string name="error_unknown">"Вибачте, сталася помилка"</string>
<string name="invite_friends_rich_title">"🔐️ Приєднуйтеся до мене в %1$s"</string>
<string name="invite_friends_text">"Вітаю, поспілкуйтеся зі мною в %1$s: %2$s"</string>
<string name="live_location_sharing_foreground_service_channel_title_android">"Обмін місцезнаходженням у реальному часі"</string>
<string name="live_location_sharing_foreground_service_message_android">"Триває обмін місцезнаходженням"</string>
<string name="live_location_sharing_foreground_service_title_android">"%1$s Місцезнаходження в реальному часі"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Повідомити про ваду за допомогою Rageshake"</string>
<string name="screen_bug_report_a11y_screenshot">"Знімок екрана"</string>