Merge branch 'develop' into feature/bma/scroll-to-unread-messages

This commit is contained in:
Benoit Marty
2026-07-03 14:30:14 +02:00
committed by GitHub
1562 changed files with 11824 additions and 6140 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(
@@ -801,6 +800,12 @@ class RustMatrixClient(
}
}
override suspend fun markAllRoomsAsRead(): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerClient.markAllRoomsAsRead()
}
}
override suspend fun performDatabaseVacuum(): Result<Unit> = withContext(sessionDispatcher) {
runCatchingExceptions {
Timber.d("Performing database vacuuming for session $sessionId...")
@@ -824,17 +829,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 +847,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,8 @@ 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
@@ -60,7 +59,6 @@ class RustMatrixClientFactory(
private val sessionStore: SessionStore,
private val userAgentProvider: UserAgentProvider,
private val proxyProvider: ProxyProvider,
private val userCertificatesProvider: UserCertificatesProvider,
private val clock: SystemClock,
private val analyticsService: AnalyticsService,
private val featureFlagService: FeatureFlagService,
@@ -102,10 +100,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 +119,7 @@ class RustMatrixClientFactory(
.finish()
return RustMatrixClient(
sessionPaths = sessionData.getSessionPaths(),
innerClient = client,
sessionStore = sessionStore,
appCoroutineScope = appCoroutineScope,
@@ -151,7 +150,6 @@ class RustMatrixClientFactory(
}
.setSessionDelegate(sessionDelegate)
.userAgent(userAgentProvider.provide())
.addRootCertificates(userCertificatesProvider.provides())
.autoEnableBackups(true)
.autoEnableCrossSigning(true)
.roomKeyRecipientStrategy(
@@ -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)
}
}
@@ -13,19 +13,16 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.auth.HomeServerLoginCompatibilityChecker
import io.element.android.libraries.matrix.impl.ClientBuilderProvider
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
import timber.log.Timber
@ContributesBinding(AppScope::class)
class RustHomeServerLoginCompatibilityChecker(
private val clientBuilderProvider: ClientBuilderProvider,
private val userCertificatesProvider: UserCertificatesProvider,
) : HomeServerLoginCompatibilityChecker {
override suspend fun check(url: String): Result<Boolean> = runCatchingExceptions {
clientBuilderProvider.provide()
.inMemoryStore()
.serverNameOrHomeserverUrl(url)
.addRootCertificates(userCertificatesProvider.provides())
.build()
.use {
it.homeserverLoginDetails()
@@ -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)
@@ -1,76 +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.certificates
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import timber.log.Timber
import java.security.KeyStore
import java.security.KeyStoreException
@ContributesBinding(AppScope::class)
class DefaultUserCertificatesProvider : UserCertificatesProvider {
/**
* Get additional user-installed certificates from the `AndroidCAStore` `Keystore`.
*
* The Rust HTTP client doesn't include user-installed certificates in its internal certificate
* store. This means that whatever the user installs will be ignored.
*
* While most users don't need user-installed certificates some special deployments or debugging
* setups using a proxy might want to use them.
*
* @return A list of byte arrays where each byte array is a single user-installed certificate
* in encoded form.
*/
override fun provides(): List<ByteArray> {
// At least for API 34 the `AndroidCAStore` `Keystore` type contained user certificates as well.
// I have not found this to be documented anywhere.
val keyStore: KeyStore = try {
KeyStore.getInstance("AndroidCAStore")
} catch (e: KeyStoreException) {
Timber.w(e, "Failed to get AndroidCAStore keystore")
return emptyList()
}
val aliases = try {
keyStore.load(null)
keyStore.aliases()
} catch (e: Exception) {
Timber.w(e, "Failed to load and get aliases AndroidCAStore keystore")
return emptyList()
}
return aliases.toList()
.filter { alias ->
// The certificate alias always contains the prefix `system` or
// `user` and the MD5 subject hash separated by a colon.
//
// The subject hash can be calculated using openssl as such:
// openssl x509 -subject_hash_old -noout -in mycert.cer
//
// Again, I have not found this to be documented somewhere.
alias.startsWith("user")
}
.mapNotNull { alias ->
try {
keyStore.getEntry(alias, null)
} catch (e: Exception) {
Timber.w(e, "Failed to get entry for alias $alias")
null
}
}
.filterIsInstance<KeyStore.TrustedCertificateEntry>()
.map { trustedCertificateEntry ->
trustedCertificateEntry.trustedCertificate.encoded
}
.also {
// Let's at least log the number of user-installed certificates we found,
// since the alias isn't particularly useful nor does the issuer seem to
// be easily available.
Timber.i("Found ${it.size} additional user-provided certificates.")
}
}
}
@@ -1,12 +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.certificates
interface UserCertificatesProvider {
fun provides(): List<ByteArray>
}
@@ -131,19 +131,28 @@ class RustEncryptionService(
override suspend fun enableRecovery(
waitForBackupsToUpload: Boolean,
): Result<Unit> = withContext(dispatchers.io) {
passphrase: String?,
): Result<String> = withContext(dispatchers.io) {
runCatchingExceptions {
service.enableRecovery(
// The key arrives as the suspend return value (like resetRecoveryKey), avoiding a
// flow/return-value race; the listener only feeds sub-progress.
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Starting
val key = service.enableRecovery(
waitForBackupsToUpload = waitForBackupsToUpload,
progressListener = object : EnableRecoveryProgressListener {
override fun onUpdate(status: RustEnableRecoveryProgress) {
enableRecoveryProgressStateFlow.value = enableRecoveryProgressMapper.map(status)
}
},
passphrase = null,
passphrase = passphrase,
)
// enableRecovery returns the encryption key, but we read it from the state flow
.let { }
// Pin Done explicitly so observers get a coherent terminal value. For the passphrase
// path the user never sees the SDK base58 key, so keep it out of this session-scoped
// (in-memory) flow entirely; the caller still receives it as the return value and is
// responsible for scrubbing it. The auto-generated path must retain the key here since
// that is how it is surfaced to the user.
enableRecoveryProgressStateFlow.value = EnableRecoveryProgress.Done(if (passphrase != null) "" else key)
key
}.mapFailure {
it.mapRecoveryException()
}
@@ -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
@@ -0,0 +1,49 @@
/*
* 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.media
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import org.matrix.rustcomponents.sdk.GalleryItemInfo as RustGalleryItemInfo
import org.matrix.rustcomponents.sdk.UploadSource as RustUploadSource
fun GalleryItemInfo.map(): RustGalleryItemInfo = when (this) {
is GalleryItemInfo.Image -> {
RustGalleryItemInfo.Image(
imageInfo = imageInfo.map(),
source = RustUploadSource.File(file.path),
caption = null,
formattedCaption = null,
thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File),
)
}
is GalleryItemInfo.Video -> {
RustGalleryItemInfo.Video(
videoInfo = videoInfo.map(),
source = RustUploadSource.File(file.path),
caption = null,
formattedCaption = null,
thumbnailSource = thumbnailFile?.path?.let(RustUploadSource::File),
)
}
is GalleryItemInfo.Audio -> {
RustGalleryItemInfo.Audio(
audioInfo = audioInfo.map(),
source = RustUploadSource.File(file.path),
caption = null,
formattedCaption = null,
)
}
is GalleryItemInfo.MediaFile -> {
RustGalleryItemInfo.File(
fileInfo = fileInfo.map(),
source = RustUploadSource.File(file.path),
caption = null,
formattedCaption = null,
)
}
}
@@ -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.media
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import org.matrix.rustcomponents.sdk.SendGalleryJoinHandle
import java.io.File
class GalleryMediaUploadHandlerImpl(
private val filesToUpload: List<File>,
private val sendGalleryJoinHandle: SendGalleryJoinHandle,
) : MediaUploadHandler {
override suspend fun await(): Result<Unit> =
runCatchingExceptions {
sendGalleryJoinHandle.join()
}
.also { cleanUpFiles() }
override fun cancel() {
sendGalleryJoinHandle.cancel()
cleanUpFiles()
}
private fun cleanUpFiles() {
filesToUpload.forEach { file -> file.safeDelete() }
}
}
@@ -97,7 +97,10 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon
is MessageLikeEventContent.ReactionContent -> NotificationContent.MessageLike.ReactionContent(relatedEventId)
MessageLikeEventContent.RoomEncrypted -> NotificationContent.MessageLike.RoomEncrypted
is MessageLikeEventContent.RoomMessage -> {
NotificationContent.MessageLike.RoomMessage(senderId, EventMessageMapper().mapMessageType(messageType))
NotificationContent.MessageLike.RoomMessage(
senderId,
EventMessageMapper().mapMessageType(messageType)
)
}
is MessageLikeEventContent.RoomRedaction -> NotificationContent.MessageLike.RoomRedaction(
redactedEventId = redactedEventId?.let(::EventId),
@@ -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
@@ -218,11 +219,18 @@ class JoinedRustRoom(
RoomMessageEventMessageType.IMAGE,
RoomMessageEventMessageType.VIDEO,
RoomMessageEventMessageType.AUDIO,
RoomMessageEventMessageType.GALLERY,
)
)
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) {
@@ -9,7 +9,8 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.MessageEventType
import org.matrix.rustcomponents.sdk.MessageLikeEventType
import uniffi.ruma_events.MessageLikeEventType
import uniffi.ruma_events.messageLikeEventTypeFromString
fun MessageEventType.map(): MessageLikeEventType = when (this) {
MessageEventType.Audio -> MessageLikeEventType.Audio
@@ -51,7 +52,7 @@ fun MessageEventType.map(): MessageLikeEventType = when (this) {
MessageEventType.UnstablePollStart -> MessageLikeEventType.UnstablePollStart
MessageEventType.Video -> MessageLikeEventType.Video
MessageEventType.Voice -> MessageLikeEventType.Voice
is MessageEventType.Other -> MessageLikeEventType.Other(type)
is MessageEventType.Other -> messageLikeEventTypeFromString(type)
}
fun MessageLikeEventType.map(): MessageEventType = when (this) {
@@ -94,5 +95,5 @@ fun MessageLikeEventType.map(): MessageEventType = when (this) {
MessageLikeEventType.RtcDecline -> MessageEventType.RtcDecline
MessageLikeEventType.Video -> MessageEventType.Video
MessageLikeEventType.Voice -> MessageEventType.Voice
is MessageLikeEventType.Other -> MessageEventType.Other(v1)
is MessageLikeEventType.Custom -> MessageEventType.Other(type = toString())
}
@@ -49,7 +49,7 @@ class RoomContentForwarder(
val content = (messageLikeContent.kind as? MsgLikeKind.Message)?.content
?: throw ForwardEventException(toRoomIds)
val targetRooms = toRoomIds.mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val targetRooms = toRoomIds.toSet().mapNotNull { roomId -> roomListService.roomOrNull(roomId.value) }
val failedForwardingTo = mutableSetOf<RoomId>()
targetRooms.parallelMap { room ->
room.use { targetRoom ->
@@ -200,12 +200,6 @@ class RustBaseRoom(
}
}
override suspend fun clearEventCacheStorage(): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.clearEventCacheStorage()
}
}
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatchingExceptions {
innerRoom.setIsFavourite(isFavorite, null)
@@ -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,
@@ -9,7 +9,8 @@
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.StateEventType
import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType
import uniffi.ruma_events.stateEventTypeFromString
import uniffi.ruma_events.StateEventType as RustStateEventType
fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.PolicyRuleRoom -> RustStateEventType.PolicyRuleRoom
@@ -23,7 +24,7 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.RoomGuestAccess -> RustStateEventType.RoomGuestAccess
StateEventType.RoomHistoryVisibility -> RustStateEventType.RoomHistoryVisibility
StateEventType.RoomJoinRules -> RustStateEventType.RoomJoinRules
StateEventType.RoomMemberEvent -> RustStateEventType.RoomMemberEvent
StateEventType.RoomMemberEvent -> RustStateEventType.RoomMember
StateEventType.RoomName -> RustStateEventType.RoomName
StateEventType.RoomPinnedEvents -> RustStateEventType.RoomPinnedEvents
StateEventType.RoomPowerLevels -> RustStateEventType.RoomPowerLevels
@@ -37,7 +38,8 @@ fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.MemberHints -> RustStateEventType.MemberHints
StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack
StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage
is StateEventType.Custom -> RustStateEventType.Custom(type)
StateEventType.RoomPolicy -> RustStateEventType.RoomPolicy
is StateEventType.Custom -> stateEventTypeFromString(type)
}
fun RustStateEventType.map(): StateEventType = when (this) {
@@ -52,7 +54,7 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.RoomGuestAccess -> StateEventType.RoomGuestAccess
RustStateEventType.RoomHistoryVisibility -> StateEventType.RoomHistoryVisibility
RustStateEventType.RoomJoinRules -> StateEventType.RoomJoinRules
RustStateEventType.RoomMemberEvent -> StateEventType.RoomMemberEvent
RustStateEventType.RoomMember -> StateEventType.RoomMemberEvent
RustStateEventType.RoomName -> StateEventType.RoomName
RustStateEventType.RoomPinnedEvents -> StateEventType.RoomPinnedEvents
RustStateEventType.RoomPowerLevels -> StateEventType.RoomPowerLevels
@@ -66,5 +68,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.MemberHints -> StateEventType.MemberHints
RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack
RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage
is RustStateEventType.Custom -> StateEventType.Custom(value)
RustStateEventType.RoomPolicy -> StateEventType.RoomPolicy
is RustStateEventType.Custom -> StateEventType.Custom(this.toString())
}
@@ -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
@@ -15,6 +15,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.AudioInfo
import io.element.android.libraries.matrix.api.media.FileInfo
import io.element.android.libraries.matrix.api.media.GalleryItemInfo
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaUploadHandler
import io.element.android.libraries.matrix.api.media.VideoInfo
@@ -30,6 +31,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.timeline.TimelineException
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.impl.media.GalleryMediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.MediaUploadHandlerImpl
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.poll.toInner
@@ -38,8 +40,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
@@ -69,6 +69,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.EditedContent
import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.GalleryUploadParameters
import org.matrix.rustcomponents.sdk.MessageFormat
import org.matrix.rustcomponents.sdk.PollData
import org.matrix.rustcomponents.sdk.SendAttachmentJoinHandle
@@ -125,8 +126,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 +251,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,
@@ -551,6 +538,42 @@ class RustTimeline(
}
}
override suspend fun sendGallery(
items: List<GalleryItemInfo>,
caption: String?,
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending gallery with ${items.size} items")
val allFiles = items.flatMap { item ->
when (item) {
is GalleryItemInfo.Image -> listOfNotNull(item.file, item.thumbnailFile)
is GalleryItemInfo.Video -> listOfNotNull(item.file, item.thumbnailFile)
is GalleryItemInfo.Audio -> listOf(item.file)
is GalleryItemInfo.MediaFile -> listOf(item.file)
}
}
return sendGalleryAttachment(allFiles) {
inner.sendGallery(
params = GalleryUploadParameters(
caption = caption,
formattedCaption = formattedCaption?.let {
FormattedBody(body = it, format = MessageFormat.Html)
},
mentions = null,
inReplyTo = inReplyToEventId?.value,
),
itemInfos = items.map { it.map() },
)
}
}
private fun sendGalleryAttachment(files: List<File>, handle: () -> org.matrix.rustcomponents.sdk.SendGalleryJoinHandle): Result<MediaUploadHandler> {
return runCatchingExceptions {
GalleryMediaUploadHandlerImpl(files, handle())
}
}
override suspend fun createPoll(
question: String,
answers: List<String>,
@@ -13,6 +13,8 @@ import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
@@ -31,12 +33,10 @@ import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.MsgLikeKind
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.FormattedBody as RustFormattedBody
import org.matrix.rustcomponents.sdk.GalleryItemType as RustGalleryItemType
import org.matrix.rustcomponents.sdk.MessageFormat as RustMessageFormat
import org.matrix.rustcomponents.sdk.MessageType as RustMessageType
// https://github.com/Johennes/matrix-spec-proposals/blob/johannes/msgtype-galleries/proposals/4274-inline-media-galleries.md#unstable-prefix
private const val MSG_TYPE_GALLERY_UNSTABLE = "dm.filament.gallery"
class EventMessageMapper {
private val inReplyToMapper by lazy { InReplyToMapper(TimelineEventContentMapper()) }
@@ -124,8 +124,64 @@ class EventMessageMapper {
OtherMessageType(type.msgtype, type.body)
}
is MessageType.Gallery -> {
// TODO expose the GalleryType.
OtherMessageType(MSG_TYPE_GALLERY_UNSTABLE, type.content.body)
GalleryMessageType(
body = type.content.body,
formatted = type.content.formatted?.map(),
items = type.content.itemtypes.map { mapGalleryItemType(it) },
)
}
}
private fun mapGalleryItemType(type: RustGalleryItemType): GalleryItemType = when (type) {
is RustGalleryItemType.Image -> {
GalleryItemType.Image(
content = ImageMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.Audio -> {
GalleryItemType.Audio(
content = AudioMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.Video -> {
GalleryItemType.Video(
content = VideoMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.File -> {
GalleryItemType.File(
content = FileMessageType(
filename = type.content.filename,
caption = type.content.caption,
formattedCaption = type.content.formattedCaption?.map(),
source = type.content.source.map(),
info = type.content.info?.map(),
)
)
}
is RustGalleryItemType.Other -> {
GalleryItemType.Other(
itemType = type.itemtype,
body = type.body,
)
}
}
}
@@ -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()
}
}
@@ -12,7 +12,6 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider
import io.element.android.libraries.matrix.impl.auth.FakeUserCertificatesProvider
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
import io.element.android.libraries.matrix.impl.storage.FakeSqliteStoreBuilderProvider
import io.element.android.libraries.network.useragent.SimpleUserAgentProvider
@@ -58,7 +57,6 @@ fun TestScope.createRustMatrixClientFactory(
coroutineDispatchers = testCoroutineDispatchers(),
sessionStore = sessionStore,
userAgentProvider = SimpleUserAgentProvider(),
userCertificatesProvider = FakeUserCertificatesProvider(),
proxyProvider = FakeProxyProvider(),
clock = FakeSystemClock(),
analyticsService = FakeAnalyticsService(),
@@ -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(
@@ -1,16 +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.auth
import io.element.android.libraries.matrix.impl.certificates.UserCertificatesProvider
class FakeUserCertificatesProvider : UserCertificatesProvider {
override fun provides(): List<ByteArray> {
return emptyList()
}
}
@@ -49,6 +49,5 @@ class RustHomeserverLoginCompatibilityCheckerTest {
FakeFfiClient(homeserverLoginDetailsResult = result)
}
},
userCertificatesProvider = FakeUserCertificatesProvider(),
)
}
@@ -8,12 +8,26 @@
package io.element.android.libraries.matrix.impl.fixtures.factories
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiMediaSource
import io.element.android.libraries.matrix.test.A_MESSAGE
import org.matrix.rustcomponents.sdk.AudioInfo
import org.matrix.rustcomponents.sdk.AudioMessageContent
import org.matrix.rustcomponents.sdk.FileInfo
import org.matrix.rustcomponents.sdk.FileMessageContent
import org.matrix.rustcomponents.sdk.FormattedBody
import org.matrix.rustcomponents.sdk.GalleryItemType
import org.matrix.rustcomponents.sdk.GalleryMessageContent
import org.matrix.rustcomponents.sdk.ImageInfo
import org.matrix.rustcomponents.sdk.ImageMessageContent
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.TimelineEventContent
import org.matrix.rustcomponents.sdk.UnstableAudioDetailsContent
import org.matrix.rustcomponents.sdk.UnstableVoiceContent
import org.matrix.rustcomponents.sdk.VideoInfo
import org.matrix.rustcomponents.sdk.VideoMessageContent
internal fun aRustTimelineEventContentMessageLike(
content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(),
@@ -42,3 +56,94 @@ internal fun aRustTextMessageContent(
body = body,
formatted = formatted,
)
internal fun aRustMessageTypeGallery(
content: GalleryMessageContent = aRustGalleryMessageContent(),
) = MessageType.Gallery(content = content)
internal fun aRustGalleryMessageContent(
body: String = "A gallery",
formatted: FormattedBody? = null,
itemTypes: List<GalleryItemType> = listOf(aRustGalleryItemTypeImage()),
) = GalleryMessageContent(body = body, formatted = formatted, itemtypes = itemTypes)
internal fun aRustGalleryItemTypeImage(
content: ImageMessageContent = aRustImageMessageContent(),
) = GalleryItemType.Image(content = content)
internal fun aRustGalleryItemTypeAudio(
content: AudioMessageContent = aRustAudioMessageContent(),
) = GalleryItemType.Audio(content = content)
internal fun aRustGalleryItemTypeVideo(
content: VideoMessageContent = aRustVideoMessageContent(),
) = GalleryItemType.Video(content = content)
internal fun aRustGalleryItemTypeFile(
content: FileMessageContent = aRustFileMessageContent(),
) = GalleryItemType.File(content = content)
internal fun aRustGalleryItemTypeOther(
itemType: String = "m.unknown",
body: String = "unknown item",
) = GalleryItemType.Other(itemtype = itemType, body = body)
internal fun aRustImageMessageContent(
filename: String = "image.jpg",
caption: String? = null,
formattedCaption: FormattedBody? = null,
source: MediaSource = FakeFfiMediaSource("mxc://server/image"),
info: ImageInfo? = null,
) = ImageMessageContent(
filename = filename,
caption = caption,
formattedCaption = formattedCaption,
source = source,
info = info,
)
internal fun aRustAudioMessageContent(
filename: String = "audio.mp3",
caption: String? = null,
formattedCaption: FormattedBody? = null,
source: MediaSource = FakeFfiMediaSource("mxc://server/audio"),
info: AudioInfo? = null,
audio: UnstableAudioDetailsContent? = null,
voice: UnstableVoiceContent? = null,
) = AudioMessageContent(
filename = filename,
caption = caption,
formattedCaption = formattedCaption,
source = source,
info = info,
audio = audio,
voice = voice,
)
internal fun aRustVideoMessageContent(
filename: String = "video.mp4",
caption: String? = null,
formattedCaption: FormattedBody? = null,
source: MediaSource = FakeFfiMediaSource("mxc://server/video"),
info: VideoInfo? = null,
) = VideoMessageContent(
filename = filename,
caption = caption,
formattedCaption = formattedCaption,
source = source,
info = info,
)
internal fun aRustFileMessageContent(
filename: String = "document.pdf",
caption: String? = null,
formattedCaption: FormattedBody? = null,
source: MediaSource = FakeFfiMediaSource("mxc://server/file"),
info: FileInfo? = null,
) = FileMessageContent(
filename = filename,
caption = caption,
formattedCaption = formattedCaption,
source = source,
info = info,
)
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.NoHandle
internal class FakeFfiMediaSource(
private val fakeUrl: String = "mxc://server/media",
) : MediaSource(NoHandle) {
override fun url(): String = fakeUrl
override fun toJson(): String = """{"url":"$fakeUrl"}"""
}
@@ -10,12 +10,10 @@ package io.element.android.libraries.matrix.impl.keys
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultSecretGeneratorTest {
class DefaultSecretGeneratorTest : RobolectricTest() {
@Test
fun `check that generated passphrase has the expected length`() {
val secretGenerator = DefaultSecretGenerator()
@@ -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
@@ -10,12 +10,10 @@ package io.element.android.libraries.matrix.impl.permalink
import android.net.Uri
import com.google.common.truth.Truth.assertThat
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultMatrixToConverterTest {
class DefaultMatrixToConverterTest : RobolectricTest() {
@Test
fun `converting a matrix-to url does nothing`() {
val url = Uri.parse("https://matrix.to/#/#element-android:matrix.org")
@@ -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()
}
}
@@ -10,10 +10,12 @@ package io.element.android.libraries.matrix.impl.room
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.MessageEventType
import org.junit.Ignore
import org.junit.Test
import org.matrix.rustcomponents.sdk.MessageLikeEventType
import uniffi.ruma_events.MessageLikeEventType
class MessageEventTypeKtTest {
@Ignore("Can't use MessageLikeEventType as that will try accessing the Rust SDK")
@Test
fun `map Rust type should result to correct Kotlin type`() {
assertThat(MessageLikeEventType.CallAnswer.map()).isEqualTo(MessageEventType.CallAnswer)
@@ -10,10 +10,12 @@ package io.element.android.libraries.matrix.impl.room
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.StateEventType
import org.junit.Ignore
import org.junit.Test
import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType
import uniffi.ruma_events.StateEventType as RustStateEventType
class StateEventTypeTest {
@Ignore("Can't use RustStateEventType as that will try accessing the Rust SDK")
@Test
fun `mapping Rust type should work`() {
assertThat(RustStateEventType.CallMember.map()).isEqualTo(StateEventType.CallMember)
@@ -27,9 +29,10 @@ class StateEventTypeTest {
assertThat(RustStateEventType.RoomGuestAccess.map()).isEqualTo(StateEventType.RoomGuestAccess)
assertThat(RustStateEventType.RoomHistoryVisibility.map()).isEqualTo(StateEventType.RoomHistoryVisibility)
assertThat(RustStateEventType.RoomJoinRules.map()).isEqualTo(StateEventType.RoomJoinRules)
assertThat(RustStateEventType.RoomMemberEvent.map()).isEqualTo(StateEventType.RoomMemberEvent)
assertThat(RustStateEventType.RoomMember.map()).isEqualTo(StateEventType.RoomMemberEvent)
assertThat(RustStateEventType.RoomName.map()).isEqualTo(StateEventType.RoomName)
assertThat(RustStateEventType.RoomPinnedEvents.map()).isEqualTo(StateEventType.RoomPinnedEvents)
assertThat(RustStateEventType.RoomPolicy.map()).isEqualTo(StateEventType.RoomPolicy)
assertThat(RustStateEventType.RoomPowerLevels.map()).isEqualTo(StateEventType.RoomPowerLevels)
assertThat(RustStateEventType.RoomServerAcl.map()).isEqualTo(StateEventType.RoomServerAcl)
assertThat(RustStateEventType.RoomThirdPartyInvite.map()).isEqualTo(StateEventType.RoomThirdPartyInvite)
@@ -37,7 +40,8 @@ class StateEventTypeTest {
assertThat(RustStateEventType.RoomTopic.map()).isEqualTo(StateEventType.RoomTopic)
assertThat(RustStateEventType.SpaceChild.map()).isEqualTo(StateEventType.SpaceChild)
assertThat(RustStateEventType.SpaceParent.map()).isEqualTo(StateEventType.SpaceParent)
assertThat(RustStateEventType.Custom("foo").map()).isEqualTo(StateEventType.Custom("foo"))
// Can't test Custom as it will try calling the Rust code, which is not available in unit tests
// assertThat(stateEventTypeFromString("foo").map()).isEqualTo(StateEventType.Custom("foo"))
}
@Test
@@ -53,9 +57,10 @@ class StateEventTypeTest {
assertThat(StateEventType.RoomGuestAccess.map()).isEqualTo(RustStateEventType.RoomGuestAccess)
assertThat(StateEventType.RoomHistoryVisibility.map()).isEqualTo(RustStateEventType.RoomHistoryVisibility)
assertThat(StateEventType.RoomJoinRules.map()).isEqualTo(RustStateEventType.RoomJoinRules)
assertThat(StateEventType.RoomMemberEvent.map()).isEqualTo(RustStateEventType.RoomMemberEvent)
assertThat(StateEventType.RoomMemberEvent.map()).isEqualTo(RustStateEventType.RoomMember)
assertThat(StateEventType.RoomName.map()).isEqualTo(RustStateEventType.RoomName)
assertThat(StateEventType.RoomPinnedEvents.map()).isEqualTo(RustStateEventType.RoomPinnedEvents)
assertThat(StateEventType.RoomPolicy.map()).isEqualTo(RustStateEventType.RoomPolicy)
assertThat(StateEventType.RoomPowerLevels.map()).isEqualTo(RustStateEventType.RoomPowerLevels)
assertThat(StateEventType.RoomServerAcl.map()).isEqualTo(RustStateEventType.RoomServerAcl)
assertThat(StateEventType.RoomThirdPartyInvite.map()).isEqualTo(RustStateEventType.RoomThirdPartyInvite)
@@ -63,6 +68,7 @@ class StateEventTypeTest {
assertThat(StateEventType.RoomTopic.map()).isEqualTo(RustStateEventType.RoomTopic)
assertThat(StateEventType.SpaceChild.map()).isEqualTo(RustStateEventType.SpaceChild)
assertThat(StateEventType.SpaceParent.map()).isEqualTo(RustStateEventType.SpaceParent)
assertThat(StateEventType.Custom("foo").map()).isEqualTo(RustStateEventType.Custom("foo"))
// Can't test Custom as it will try calling the Rust code, which is not available in unit tests
// assertThat(StateEventType.Custom("foo").map()).isEqualTo(stateEventTypeFromString("foo"))
}
}
@@ -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 {
@@ -0,0 +1,318 @@
/*
* 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.item.event
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryItemType
import io.element.android.libraries.matrix.api.timeline.item.event.GalleryMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustAudioMessageContent
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustFileMessageContent
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeAudio
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeFile
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeImage
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeOther
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryItemTypeVideo
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustGalleryMessageContent
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustImageMessageContent
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustMessageTypeGallery
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustVideoMessageContent
import org.junit.Test
import org.matrix.rustcomponents.sdk.EmoteMessageContent
import org.matrix.rustcomponents.sdk.LocationContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.NoticeMessageContent
import org.matrix.rustcomponents.sdk.TextMessageContent
import org.matrix.rustcomponents.sdk.UnstableVoiceContent
import org.matrix.rustcomponents.sdk.AssetType as RustAssetType
class EventMessageMapperTest {
private val sut = EventMessageMapper()
@Test
fun `mapMessageType with Text returns TextMessageType`() {
val result = sut.mapMessageType(
MessageType.Text(
content = TextMessageContent(body = "Hello", formatted = null)
)
)
assertThat(result).isEqualTo(TextMessageType(body = "Hello", formatted = null))
}
@Test
fun `mapMessageType with Notice returns NoticeMessageType`() {
val result = sut.mapMessageType(
MessageType.Notice(content = NoticeMessageContent(body = "A notice", formatted = null))
)
assertThat(result).isEqualTo(NoticeMessageType(body = "A notice", formatted = null))
}
@Test
fun `mapMessageType with Emote returns EmoteMessageType`() {
val result = sut.mapMessageType(
MessageType.Emote(content = EmoteMessageContent(body = "An emote", formatted = null))
)
assertThat(result).isEqualTo(EmoteMessageType(body = "An emote", formatted = null))
}
@Test
fun `mapMessageType with Other returns OtherMessageType`() {
val result = sut.mapMessageType(
MessageType.Other(msgtype = "m.custom", body = "custom body")
)
assertThat(result).isEqualTo(OtherMessageType(msgType = "m.custom", body = "custom body"))
}
@Test
fun `mapMessageType with Location returns LocationMessageType`() {
val result = sut.mapMessageType(
MessageType.Location(
content = LocationContent(
body = "Location body",
geoUri = "geo:51.5,-0.1",
description = "London",
zoomLevel = null,
asset = RustAssetType.PIN,
)
)
)
assertThat(result).isEqualTo(
LocationMessageType(
body = "Location body",
geoUri = "geo:51.5,-0.1",
description = "London",
assetType = AssetType.PIN,
)
)
}
@Test
fun `mapMessageType with Audio without voice returns AudioMessageType`() {
val result = sut.mapMessageType(
MessageType.Audio(content = aRustAudioMessageContent(filename = "audio.mp3", voice = null))
)
assertThat(result).isEqualTo(
AudioMessageType(
filename = "audio.mp3",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""),
info = null,
)
)
}
@Test
fun `mapMessageType with Audio with voice returns VoiceMessageType`() {
val result = sut.mapMessageType(
MessageType.Audio(content = aRustAudioMessageContent(filename = "voice.ogg", voice = UnstableVoiceContent()))
)
assertThat(result).isEqualTo(
VoiceMessageType(
filename = "voice.ogg",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""),
info = null,
details = null,
)
)
}
@Test
fun `mapMessageType with File returns FileMessageType`() {
val result = sut.mapMessageType(
MessageType.File(content = aRustFileMessageContent(filename = "document.pdf"))
)
assertThat(result).isEqualTo(
FileMessageType(
filename = "document.pdf",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/file", json = """{"url":"mxc://server/file"}"""),
info = null,
)
)
}
@Test
fun `mapMessageType with Image returns ImageMessageType`() {
val result = sut.mapMessageType(
MessageType.Image(content = aRustImageMessageContent(filename = "image.jpg"))
)
assertThat(result).isEqualTo(
ImageMessageType(
filename = "image.jpg",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/image", json = """{"url":"mxc://server/image"}"""),
info = null,
)
)
}
@Test
fun `mapMessageType with Video returns VideoMessageType`() {
val result = sut.mapMessageType(
MessageType.Video(content = aRustVideoMessageContent(filename = "video.mp4"))
)
assertThat(result).isEqualTo(
VideoMessageType(
filename = "video.mp4",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/video", json = """{"url":"mxc://server/video"}"""),
info = null,
)
)
}
@Test
fun `mapMessageType with Gallery with Image item returns GalleryMessageType with Image`() {
val result = sut.mapMessageType(
aRustMessageTypeGallery(
content = aRustGalleryMessageContent(
body = "A gallery",
itemTypes = listOf(aRustGalleryItemTypeImage(aRustImageMessageContent(filename = "image.jpg"))),
)
)
)
assertThat(result).isEqualTo(
GalleryMessageType(
body = "A gallery",
formatted = null,
items = listOf(
GalleryItemType.Image(
content = ImageMessageType(
filename = "image.jpg",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/image", json = """{"url":"mxc://server/image"}"""),
info = null,
)
)
),
)
)
}
@Test
fun `mapMessageType with Gallery with Audio item returns GalleryMessageType with Audio`() {
val result = sut.mapMessageType(
aRustMessageTypeGallery(
content = aRustGalleryMessageContent(
itemTypes = listOf(aRustGalleryItemTypeAudio(aRustAudioMessageContent(filename = "audio.mp3")))
)
)
)
assertThat(result).isEqualTo(
GalleryMessageType(
body = "A gallery",
formatted = null,
items = listOf(
GalleryItemType.Audio(
content = AudioMessageType(
filename = "audio.mp3",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/audio", json = """{"url":"mxc://server/audio"}"""),
info = null,
)
)
),
)
)
}
@Test
fun `mapMessageType with Gallery with Video item returns GalleryMessageType with Video`() {
val result = sut.mapMessageType(
aRustMessageTypeGallery(
content = aRustGalleryMessageContent(
itemTypes = listOf(aRustGalleryItemTypeVideo(aRustVideoMessageContent(filename = "video.mp4")))
)
)
)
assertThat(result).isEqualTo(
GalleryMessageType(
body = "A gallery",
formatted = null,
items = listOf(
GalleryItemType.Video(
content = VideoMessageType(
filename = "video.mp4",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/video", json = """{"url":"mxc://server/video"}"""),
info = null,
)
)
),
)
)
}
@Test
fun `mapMessageType with Gallery with File item returns GalleryMessageType with File`() {
val result = sut.mapMessageType(
aRustMessageTypeGallery(
content = aRustGalleryMessageContent(
itemTypes = listOf(aRustGalleryItemTypeFile(aRustFileMessageContent(filename = "document.pdf")))
)
)
)
assertThat(result).isEqualTo(
GalleryMessageType(
body = "A gallery",
formatted = null,
items = listOf(
GalleryItemType.File(
content = FileMessageType(
filename = "document.pdf",
caption = null,
formattedCaption = null,
source = MediaSource(url = "mxc://server/file", json = """{"url":"mxc://server/file"}"""),
info = null,
)
)
),
)
)
}
@Test
fun `mapMessageType with Gallery with Other item returns GalleryMessageType with Other`() {
val result = sut.mapMessageType(
aRustMessageTypeGallery(
content = aRustGalleryMessageContent(
itemTypes = listOf(aRustGalleryItemTypeOther(itemType = "m.custom", body = "custom item"))
)
)
)
assertThat(result).isEqualTo(
GalleryMessageType(
body = "A gallery",
formatted = null,
items = listOf(
GalleryItemType.Other(itemType = "m.custom", body = "custom item")
),
)
)
}
}
@@ -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")
}
}