Merge branch 'develop' into feature/bma/multipleForward

This commit is contained in:
Benoit Marty
2026-06-17 10:31:44 +02:00
committed by GitHub
153 changed files with 1770 additions and 1774 deletions
@@ -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,6 +9,7 @@
package io.element.android.libraries.matrix.impl.exception
import io.element.android.libraries.matrix.api.exception.ClientException
import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason
import org.matrix.rustcomponents.sdk.ClientException as RustClientException
fun Throwable.mapClientException(): ClientException {
@@ -23,6 +24,10 @@ fun Throwable.mapClientException(): ClientException {
details = details,
cause = this,
)
is RustClientException.ContentScanner -> ClientException.ContentScanner(
message = message,
reason = ContentScannerErrorReason.fromRust(reason),
)
}
}
else -> ClientException.Other(message ?: "Unknown error", this)
@@ -0,0 +1,34 @@
/*
* 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.exception
import io.element.android.libraries.matrix.api.exception.ContentScannerErrorReason
import uniffi.matrix_sdk_contentscanner.ErrorReason
import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_BAD_DECRYPTION
import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MALFORMED_JSON
import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MEDIA_FAILED_TO_DECRYPT
import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MEDIA_NOT_CLEAN
import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MEDIA_REQUEST_FAILED
import uniffi.matrix_sdk_contentscanner.ErrorReason.MCS_MIME_TYPE_FORBIDDEN
import uniffi.matrix_sdk_contentscanner.ErrorReason.M_MISSING_TOKEN
import uniffi.matrix_sdk_contentscanner.ErrorReason.M_NOT_FOUND
import uniffi.matrix_sdk_contentscanner.ErrorReason.M_UNKNOWN
import uniffi.matrix_sdk_contentscanner.ErrorReason.M_UNKNOWN_TOKEN
fun ContentScannerErrorReason.Companion.fromRust(reason: ErrorReason) = when (reason) {
M_UNKNOWN -> ContentScannerErrorReason.M_UNKNOWN
M_MISSING_TOKEN -> ContentScannerErrorReason.M_MISSING_TOKEN
M_UNKNOWN_TOKEN -> ContentScannerErrorReason.M_UNKNOWN_TOKEN
MCS_MALFORMED_JSON -> ContentScannerErrorReason.MCS_MALFORMED_JSON
MCS_MEDIA_FAILED_TO_DECRYPT -> ContentScannerErrorReason.MCS_MEDIA_FAILED_TO_DECRYPT
M_NOT_FOUND -> ContentScannerErrorReason.M_NOT_FOUND
MCS_MEDIA_NOT_CLEAN -> ContentScannerErrorReason.MCS_MEDIA_NOT_CLEAN
MCS_MIME_TYPE_FORBIDDEN -> ContentScannerErrorReason.MCS_MIME_TYPE_FORBIDDEN
MCS_BAD_DECRYPTION -> ContentScannerErrorReason.MCS_BAD_DECRYPTION
MCS_MEDIA_REQUEST_FAILED -> ContentScannerErrorReason.MCS_MEDIA_REQUEST_FAILED
}
@@ -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")
}
}