Merge branch 'develop' into feature/fga/live_location_rendering

This commit is contained in:
ganfra 2026-04-17 15:13:13 +02:00
commit 3b813f137b
334 changed files with 8886 additions and 2214 deletions

View file

@ -0,0 +1,28 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
class RustHomeserverCapabilitiesProvider(
private val homeserverCapabilities: HomeserverCapabilities,
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = runCatchingExceptions {
homeserverCapabilities.refresh()
}
override suspend fun canChangeDisplayName(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeDisplayname()
}
override suspend fun canChangeAvatarUrl(): Result<Boolean> = runCatchingExceptions {
homeserverCapabilities.canChangeAvatar()
}
}

View file

@ -17,6 +17,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes
import io.element.android.libraries.matrix.api.core.DeviceId
@ -835,6 +836,10 @@ class RustMatrixClient(
val request = PerformDatabaseVacuumRequestBuilder(sessionId)
sessionCoroutineScope.launch { workManagerScheduler.submit(request) }
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
}
}
private fun defaultRoomCreationPowerLevels(isPublic: Boolean, isSpace: Boolean) = PowerLevels(

View file

@ -16,6 +16,7 @@ import io.element.android.libraries.core.extensions.mapFailure
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.matrix.api.auth.ElementClassicSession
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
@ -25,6 +26,7 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
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
@ -50,6 +52,7 @@ import org.matrix.rustcomponents.sdk.QrCodeData
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
import org.matrix.rustcomponents.sdk.QrLoginProgress
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
import org.matrix.rustcomponents.sdk.SecretsBundleWithUserId
import timber.log.Timber
import uniffi.matrix_sdk.OAuthAuthorizationData
import kotlin.time.Duration.Companion.seconds
@ -64,6 +67,9 @@ class RustMatrixAuthenticationService(
private val passphraseGenerator: PassphraseGenerator,
private val oidcConfigurationProvider: OidcConfigurationProvider,
) : MatrixAuthenticationService {
// Any existing Element Classic session that we want to try to import secrets from during login.
private var elementClassicSession: ElementClassicSession? = null
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
// stored in the SessionData.
private val pendingPassphrase = getDatabasePassphrase()
@ -138,9 +144,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.login(username, password, "Element X Android", null)
client.login(
username = username,
password = password,
initialDeviceName = "Element X Android",
deviceId = null,
)
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session()
.toSessionData(
isTokenValid = true,
@ -162,6 +174,53 @@ class RustMatrixAuthenticationService(
}
}
private suspend fun tryToImportSecretForElementClassicSession(client: Client) {
elementClassicSession
?.takeIf {
// Note: the SDK will also do this check
it.userId.value == client.userId()
}
?.let {
val secrets = it.secrets
val roomKeysVersion = it.roomKeysVersion
if (secrets == null || roomKeysVersion == null) {
Timber.d("No secrets or roomKeysVersion found for Element Classic session ${it.userId}, skipping import")
} else {
Timber.d("Trying to import secrets for Element Classic session ${it.userId}")
runCatchingExceptions {
SecretsBundleWithUserId.fromStr(
userId = it.userId.value,
bundle = secrets,
backupInfo = roomKeysVersion,
).use { secretsBundle ->
client.encryption().importSecretsBundle(secretsBundle)
}
}.onFailure { failure ->
Timber.e(failure, "Failed to import secrets for Element Classic session ${it.userId}")
}
}
}
}
override fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean {
return try {
SecretsBundleWithUserId.fromStr(
userId = userId.value,
bundle = secrets,
backupInfo = backupInfo,
).use { secretsBundle ->
secretsBundle.containsBackupKey()
}
} catch (failure: Exception) {
Timber.e(failure, "Failed to parse secrets for Element Classic session $userId")
false
}
}
override suspend fun importCreatedSession(externalSession: ExternalSession): Result<SessionId> =
withContext(coroutineDispatchers.io) {
runCatchingExceptions {
@ -233,6 +292,10 @@ class RustMatrixAuthenticationService(
}
}
override fun setElementClassicSession(session: ElementClassicSession?) {
elementClassicSession = session
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/
@ -241,14 +304,15 @@ class RustMatrixAuthenticationService(
runCatchingExceptions {
val client = currentClient ?: error("You need to call `setHomeserver()` first")
val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first")
client.loginWithOidcCallback(callbackUrl)
client.loginWithOidcCallback(
callbackUrl = callbackUrl,
)
// Free the pending data since we won't use it to abort the flow anymore
pendingOAuthAuthorizationData?.close()
pendingOAuthAuthorizationData = null
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
tryToImportSecretForElementClassicSession(client)
val sessionData = client.session().toSessionData(
isTokenValid = true,
loginType = LoginType.OIDC,

View file

@ -13,6 +13,7 @@ import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.Provides
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.encryption.EncryptionService
@ -90,4 +91,9 @@ object SessionMatrixModule {
fun providesSpaceService(matrixClient: MatrixClient): SpaceService {
return matrixClient.spaceService
}
@Provides
fun providesHomeserverCapabilitiesProvider(matrixClient: MatrixClient): HomeserverCapabilitiesProvider {
return matrixClient.homeserverCapabilities()
}
}

View file

@ -34,6 +34,7 @@ import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues
import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange
import io.element.android.libraries.matrix.api.room.roomNotificationSettings
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
@ -46,8 +47,10 @@ import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
import io.element.android.libraries.matrix.impl.room.location.liveLocationSharesFlow
import io.element.android.libraries.matrix.impl.room.location.timedByExpiry
import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher
import io.element.android.libraries.matrix.impl.room.threads.RustThreadsListService
import io.element.android.libraries.matrix.impl.roomdirectory.map
import io.element.android.libraries.matrix.impl.timeline.RustTimeline
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.util.MessageEventContent
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import io.element.android.libraries.matrix.impl.widget.RustWidgetDriver
@ -147,6 +150,12 @@ class JoinedRustRoom(
override val liveTimeline = liveInnerTimeline.map(mode = Timeline.Mode.Live)
override val threadsListService: ThreadsListService = RustThreadsListService(
inner = innerRoom.threadListService(),
contentMapper = TimelineEventContentMapper(),
roomCoroutineScope = roomCoroutineScope,
)
override val syncUpdateFlow = flow {
var counter = 0L
liveTimeline.onSyncedEventReceived.collect {
@ -530,6 +539,7 @@ class JoinedRustRoom(
override fun destroy() {
baseRoom.destroy()
liveInnerTimeline.destroy()
threadsListService.destroy()
Timber.d("Room $roomId destroyed")
}

View file

@ -0,0 +1,157 @@
/*
* 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.room.threads
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListItemEvent
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.map
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
class RustThreadsListService(
private val inner: InnerThreadListService,
private val roomCoroutineScope: CoroutineScope,
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
) : ThreadsListService {
private var itemSubscriptionJob: Job? = null
private val items = MutableStateFlow<List<ThreadListItem>>(emptyList())
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
if (itemSubscriptionJob?.isActive != true) {
itemSubscriptionJob = doSubscribeToItemUpdates()
}
return items
}
private fun doSubscribeToItemUpdates(): Job {
val updatesFlow = mxCallbackFlow {
inner.subscribeToItemsUpdates(object : ThreadListEntriesListener {
override fun onUpdate(diff: List<ThreadListUpdate>) {
trySend(diff)
}
})
}
return updatesFlow
.onStart { items.value = inner.items().map { it.map(contentMapper) } }
.onEach { diff ->
val updated = items.value.toMutableList()
updated.apply(diff, contentMapper)
items.value = updated
}
.launchIn(roomCoroutineScope)
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return mxCallbackFlow {
inner.subscribeToPaginationStateUpdates(object : ThreadListPaginationStateListener {
override fun onUpdate(state: ThreadListPaginationState) {
trySend(state.map())
}
}).also {
// Send the initial state
trySend(inner.paginationState().map())
}
}
}
override suspend fun paginate(): Result<Unit> = runCatchingExceptions {
inner.paginate()
}
override suspend fun reset(): Result<Unit> = runCatchingExceptions {
inner.reset()
}
override fun destroy() {
itemSubscriptionJob?.cancel()
inner.destroy()
}
}
private fun MutableList<ThreadListItem>.apply(
diff: List<ThreadListUpdate>,
contentMapper: TimelineEventContentMapper
) {
for (diffItem in diff) {
when (diffItem) {
is ThreadListUpdate.Append -> {
val newItems = diffItem.values.map { it.map(contentMapper) }
addAll(newItems)
}
ThreadListUpdate.Clear -> clear()
is ThreadListUpdate.Insert -> {
add(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
ThreadListUpdate.PopBack -> {
removeAt(lastIndex)
}
ThreadListUpdate.PopFront -> {
removeAt(0)
}
is ThreadListUpdate.PushBack -> {
add(diffItem.value.map(contentMapper))
}
is ThreadListUpdate.PushFront -> {
add(0, diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Remove -> {
removeAt(diffItem.index.toInt())
}
is ThreadListUpdate.Reset -> {
clear()
addAll(diffItem.values.map { it.map(contentMapper) })
}
is ThreadListUpdate.Set -> {
set(diffItem.index.toInt(), diffItem.value.map(contentMapper))
}
is ThreadListUpdate.Truncate -> {
subList(diffItem.length.toInt(), size).clear()
}
}
}
}
fun org.matrix.rustcomponents.sdk.ThreadListItem.map(contentMapper: TimelineEventContentMapper): ThreadListItem = ThreadListItem(
rootEvent = rootEvent.map(contentMapper),
latestEvent = latestEvent?.map(contentMapper),
numberOfReplies = numReplies.toLong(),
)
fun org.matrix.rustcomponents.sdk.ThreadListItemEvent.map(contentMapper: TimelineEventContentMapper): ThreadListItemEvent = ThreadListItemEvent(
eventId = EventId(eventId),
senderId = UserId(sender),
isOwn = isOwn,
senderProfile = senderProfile.map(),
content = content?.let(contentMapper::map),
timestamp = timestamp.toLong(),
)
fun ThreadListPaginationState.map(): ThreadListPaginationStatus = when (this) {
is ThreadListPaginationState.Idle -> ThreadListPaginationStatus.Idle(hasMoreToLoad = !endReached)
ThreadListPaginationState.Loading -> ThreadListPaginationStatus.Loading
}

View file

@ -130,6 +130,8 @@ class RustTimeline(
Timeline.PaginationStatus(isPaginating = false, hasMoreToLoad = mode is Timeline.Mode.FocusedOnEvent)
)
private val loggerTag = "Timeline($mode)"
init {
when (mode) {
is Timeline.Mode.Live, is Timeline.Mode.FocusedOnEvent -> coroutineScope.fetchMembers()
@ -177,10 +179,11 @@ class RustTimeline(
}
private fun updatePaginationStatus(direction: Timeline.PaginationDirection, update: (Timeline.PaginationStatus) -> Timeline.PaginationStatus) {
when (direction) {
val result = when (direction) {
Timeline.PaginationDirection.BACKWARDS -> backwardPaginationStatus.getAndUpdate(update)
Timeline.PaginationDirection.FORWARDS -> forwardPaginationStatus.getAndUpdate(update)
}
Timber.tag(loggerTag).d("updatePaginationStatus $direction: $result")
}
// Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled.
@ -195,12 +198,13 @@ class RustTimeline(
}
}.onFailure { error ->
if (error is TimelineException.CannotPaginate) {
Timber.d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
Timber.tag(loggerTag).d("Can't paginate $direction on room ${joinedRoom.roomId} with paginationStatus: ${backwardPaginationStatus.value}")
} else {
updatePaginationStatus(direction) { it.copy(isPaginating = false) }
Timber.e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(error, "Error paginating $direction on room ${joinedRoom.roomId}")
}
}.onSuccess { hasReachedEnd ->
Timber.tag(loggerTag).d("Finished paginating $direction on room ${joinedRoom.roomId}, hasReachedEnd: $hasReachedEnd")
updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) }
}
}
@ -264,7 +268,7 @@ class RustTimeline(
try {
inner.fetchMembers()
} catch (exception: Exception) {
Timber.e(exception, "Error fetching members for room ${joinedRoom.roomId}")
Timber.tag(loggerTag).e(exception, "Error fetching members for room ${joinedRoom.roomId}")
}
}
@ -370,7 +374,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending image ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending image ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendImage(
params = UploadParameters(
@ -396,7 +400,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending video ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending video ${file.path.hash()}")
return sendAttachment(listOfNotNull(file, thumbnailFile)) {
inner.sendVideo(
params = UploadParameters(
@ -421,7 +425,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending audio ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending audio ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendAudio(
params = UploadParameters(
@ -445,7 +449,7 @@ class RustTimeline(
formattedCaption: String?,
inReplyToEventId: EventId?,
): Result<MediaUploadHandler> {
Timber.d("Sending file ${file.path.hash()}")
Timber.tag(loggerTag).d("Sending file ${file.path.hash()}")
return sendAttachment(listOf(file)) {
inner.sendFile(
params = UploadParameters(
@ -475,7 +479,7 @@ class RustTimeline(
runCatchingExceptions {
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
}.onFailure {
Timber.e(it)
Timber.tag(loggerTag).e(it)
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverCapabilities
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RustHomeserverCapabilitiesProviderTest {
@Test
fun `refresh calls client refresh`() = runTest {
val refreshLambda = lambdaRecorder<Unit> {}
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isSuccess).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `refresh fails when client refresh does`() = runTest {
val refreshLambda = lambdaRecorder<Unit> { throw IllegalStateException("Failed to refresh capabilities") }
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(refresh = refreshLambda),
)
assertThat(provider.refresh().isFailure).isTrue()
refreshLambda.assertions().isCalledOnce()
}
@Test
fun `canChangeDisplayName returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { true }),
)
assertThat(provider.canChangeDisplayName().getOrNull()).isTrue()
}
@Test
fun `canChangeAvatarUrl returns expected value`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeAvatar = { true }),
)
assertThat(provider.canChangeAvatarUrl().getOrNull()).isTrue()
}
@Test
fun `canChangeDisplayName returns failure when client throws`() = runTest {
val provider = createCapabilitiesProvider(
capabilities = FakeFfiHomeserverCapabilities(canChangeDisplayName = { throw IllegalStateException("Failed to get display name capability") }),
)
assert(provider.canChangeDisplayName().isFailure)
}
private fun createCapabilitiesProvider(
capabilities: FakeFfiHomeserverCapabilities = FakeFfiHomeserverCapabilities(),
) = RustHomeserverCapabilitiesProvider(capabilities)
}

View file

@ -17,6 +17,7 @@ import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.Encryption
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.NoHandle
@ -50,6 +51,7 @@ class FakeFfiClient(
private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() },
private val getStoreSizesResult: () -> StoreSizes = { lambdaError() },
private val createRoomResult: (CreateRoomParameters) -> String = { lambdaError() },
private val homeserverCapabilities: HomeserverCapabilities = FakeFfiHomeserverCapabilities(),
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
override fun userId(): String = userId
@ -103,5 +105,9 @@ class FakeFfiClient(
return createRoomResult(request)
}
override fun homeserverCapabilities(): HomeserverCapabilities {
return homeserverCapabilities
}
override fun close() = closeResult()
}

View file

@ -0,0 +1,33 @@
/*
* 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 io.element.android.tests.testutils.lambda.lambdaError
import org.matrix.rustcomponents.sdk.ExtendedProfileFields
import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.NoHandle
class FakeFfiHomeserverCapabilities(
private val refresh: () -> Unit = { lambdaError() },
private val canChangeDisplayName: () -> Boolean = { lambdaError() },
private val canChangeAvatar: () -> Boolean = { lambdaError() },
private val canChangePassword: () -> Boolean = { lambdaError() },
private val canChangeThirdPartyIds: () -> Boolean = { lambdaError() },
private val canGetLoginToken: () -> Boolean = { lambdaError() },
private val forgetsRoomWhenLeaving: () -> Boolean = { lambdaError() },
private val extendedProfileFields: () -> ExtendedProfileFields = { lambdaError() },
) : HomeserverCapabilities(NoHandle) {
override suspend fun refresh() = refresh.invoke()
override suspend fun canChangeDisplayname(): Boolean = canChangeDisplayName.invoke()
override suspend fun canChangeAvatar(): Boolean = canChangeAvatar.invoke()
override suspend fun canChangePassword(): Boolean = canChangePassword.invoke()
override suspend fun canChangeThirdpartyIds(): Boolean = canChangeThirdPartyIds.invoke()
override suspend fun canGetLoginToken(): Boolean = canGetLoginToken.invoke()
override suspend fun forgetsRoomWhenLeaving(): Boolean = forgetsRoomWhenLeaving.invoke()
override suspend fun extendedProfileFields(): ExtendedProfileFields = extendedProfileFields.invoke()
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.fixtures.fakes
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListService
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
class FakeFfiThreadListService(
private val subscribeToItemsUpdates: (ThreadListEntriesListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val subscribeToPaginationStateUpdates: (ThreadListPaginationStateListener) -> TaskHandle = { FakeFfiTaskHandle() },
private val items: () -> List<ThreadListItem> = { emptyList() },
private val paginationState: () -> ThreadListPaginationState = { ThreadListPaginationState.Idle(endReached = false) },
private val paginate: suspend () -> Unit = {},
private val reset: suspend () -> Unit = {},
private val destroy: () -> Unit = {},
) : ThreadListService(NoHandle) {
private var itemsListener: ThreadListEntriesListener? = null
private var paginationStateListener: ThreadListPaginationStateListener? = null
override fun subscribeToItemsUpdates(listener: ThreadListEntriesListener): TaskHandle {
itemsListener = listener
return subscribeToItemsUpdates.invoke(listener)
}
override fun subscribeToPaginationStateUpdates(listener: ThreadListPaginationStateListener): TaskHandle {
paginationStateListener = listener
return subscribeToPaginationStateUpdates.invoke(listener)
}
override fun items(): List<ThreadListItem> = items.invoke()
override fun paginationState(): ThreadListPaginationState = paginationState.invoke()
override suspend fun paginate() = paginate.invoke()
override suspend fun reset() = reset.invoke()
override fun destroy() = destroy.invoke()
fun emitUpdates(updates: List<ThreadListUpdate>) {
itemsListener?.onUpdate(updates)
}
fun emitPaginationState(state: ThreadListPaginationState) {
paginationStateListener?.onUpdate(state)
}
}

View file

@ -0,0 +1,143 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.impl.room.threads
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineItemContentMsgLike
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTaskHandle
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiThreadListService
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.TaskHandle
import org.matrix.rustcomponents.sdk.ThreadListEntriesListener
import org.matrix.rustcomponents.sdk.ThreadListItem
import org.matrix.rustcomponents.sdk.ThreadListItemEvent
import org.matrix.rustcomponents.sdk.ThreadListPaginationStateListener
import org.matrix.rustcomponents.sdk.ThreadListUpdate
import uniffi.matrix_sdk_ui.ThreadListPaginationState
@OptIn(ExperimentalCoroutinesApi::class)
class RustThreadsListServiceTest {
@Test
fun `subscribing to item updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates().test {
assertThat(awaitItem()).isEmpty()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
inner.emitUpdates(listOf(aRustThreadListUpdate()))
assertThat(awaitItem()).isNotEmpty()
}
}
@Suppress("UnusedFlow")
@Test
fun `subscribing to item updates twice only calls the FFI method once`() = runTest {
val subscribeToItemsUpdatesRecorder = lambdaRecorder<ThreadListEntriesListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToItemsUpdates = subscribeToItemsUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToItemUpdates()
service.subscribeToItemUpdates()
runCurrent()
subscribeToItemsUpdatesRecorder.assertions().isCalledOnce()
}
@Test
fun `subscribing to pagination updates calls the FFI method and allows retrieving new items`() = runTest {
val subscribeToPaginationUpdatesRecorder = lambdaRecorder<ThreadListPaginationStateListener, TaskHandle> { FakeFfiTaskHandle() }
val inner = FakeFfiThreadListService(subscribeToPaginationStateUpdates = subscribeToPaginationUpdatesRecorder)
val service = createThreadsListService(inner = inner)
service.subscribeToPaginationUpdates().test {
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Idle(hasMoreToLoad = true))
runCurrent()
subscribeToPaginationUpdatesRecorder.assertions().isCalledOnce()
inner.emitPaginationState(ThreadListPaginationState.Loading)
assertThat(awaitItem()).isEqualTo(ThreadListPaginationStatus.Loading)
}
}
@Test
fun `paginate calls the FFI method`() = runTest {
val paginateRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(paginate = paginateRecorder)
val service = createThreadsListService(inner = inner)
service.paginate()
paginateRecorder.assertions().isCalledOnce()
}
@Test
fun `reset calls the FFI method`() = runTest {
val resetRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(reset = resetRecorder)
val service = createThreadsListService(inner = inner)
service.reset()
resetRecorder.assertions().isCalledOnce()
}
@Test
fun `destroy calls the FFI method`() = runTest {
val destroyRecorder = lambdaRecorder<Unit> {}
val inner = FakeFfiThreadListService(destroy = destroyRecorder)
val service = createThreadsListService(inner = inner)
service.destroy()
destroyRecorder.assertions().isCalledOnce()
}
private fun TestScope.createThreadsListService(
inner: FakeFfiThreadListService = FakeFfiThreadListService(),
) = RustThreadsListService(
inner = inner,
roomCoroutineScope = backgroundScope,
)
private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
values = listOf(
ThreadListItem(
rootEvent = ThreadListItemEvent(
eventId = AN_EVENT_ID.value,
timestamp = A_TIMESTAMP.toULong(),
sender = A_USER_ID.value,
senderProfile = ProfileDetails.Pending,
isOwn = true,
content = aRustTimelineItemContentMsgLike(),
),
numReplies = 0u,
latestEvent = null,
)
),
)
}