Merge branch 'develop' into feature/fga/live_location_rendering

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

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api
/**
* Provides information about the capabilities of the homeserver.
*
* Spec: https://spec.matrix.org/latest/client-server-api/#capabilities-negotiation
*/
interface HomeserverCapabilitiesProvider {
/**
* Manually refresh the capabilities of the homeserver performing a network request.
*/
suspend fun refresh(): Result<Unit>
/**
* Indicates whether the homeserver allows the user to change their display name.
*/
suspend fun canChangeDisplayName(): Result<Boolean>
/**
* Indicates whether the homeserver allows the user to change their avatar URL.
*/
suspend fun canChangeAvatarUrl(): Result<Boolean>
}

View file

@ -223,6 +223,8 @@ interface MatrixClient {
* Resets the cached client `well-known` config by the SDK.
*/
suspend fun resetWellKnownConfig(): Result<Unit>
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
}
/**

View file

@ -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.api.auth
import io.element.android.libraries.matrix.api.core.UserId
data class ElementClassicSession(
val userId: UserId,
val homeserverUrl: String?,
val secrets: String?,
val roomKeysVersion: String?,
val doesContainBackupKey: Boolean,
)

View file

@ -14,6 +14,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
interface MatrixAuthenticationService {
/**
@ -52,6 +53,20 @@ interface MatrixAuthenticationService {
*/
suspend fun cancelOidcLogin(): Result<Unit>
/**
* Set the existing data about Element Classic session, if any.
*/
fun setElementClassicSession(session: ElementClassicSession?)
/**
* Check if the provided secrets from Element Classic session contain a key backup.
*/
fun doSecretsContainBackupKey(
userId: UserId,
secrets: String,
backupInfo: String,
): Boolean
/**
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.room.knock.KnockRequest
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.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
@ -44,6 +45,8 @@ interface JoinedRoom : BaseRoom {
*/
val liveTimeline: Timeline
val threadsListService: ThreadsListService
/**
* Create a new timeline.
* @param createTimelineParams contains parameters about how to filter the timeline. Will also configure the date separators.

View file

@ -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.api.room.threads
import androidx.compose.runtime.Immutable
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.core.toThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
@Immutable
data class ThreadListItem(
val rootEvent: ThreadListItemEvent,
val latestEvent: ThreadListItemEvent?,
val numberOfReplies: Long,
) {
val threadId = rootEvent.eventId.toThreadId()
}
@Immutable
data class ThreadListItemEvent(
val eventId: EventId,
val senderId: UserId,
val senderProfile: ProfileDetails,
val isOwn: Boolean,
val content: EventContent?,
val timestamp: Long,
)

View file

@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.room.threads
sealed interface ThreadListPaginationStatus {
data class Idle(
val hasMoreToLoad: Boolean,
) : ThreadListPaginationStatus
data object Loading : ThreadListPaginationStatus
}

View file

@ -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.api.room.threads
import kotlinx.coroutines.flow.Flow
interface ThreadsListService {
fun subscribeToItemUpdates(): Flow<List<ThreadListItem>>
fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus>
suspend fun paginate(): Result<Unit>
suspend fun reset(): Result<Unit>
fun destroy()
}

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.core
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class UserIdTest {
@Test
fun `valid user id`() {
val userId = UserId("@alice:example.org")
assertThat(userId.extractedDisplayName).isEqualTo("alice")
assertThat(userId.domainName).isEqualTo("example.org")
}
}

View file

@ -28,7 +28,7 @@ dependencies {
} else {
debugImplementation(libs.matrix.sdk)
}
implementation(files("libs/rustls-platform-verifier-android.aar"))
implementation(projects.libraries.rustlsTls)
implementation(projects.appconfig)
implementation(projects.libraries.androidutils)

View file

@ -1 +0,0 @@
Updated rustls-platform-verifier-android.aar using `rustls-platform-verifier-0.1.1.aar`

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,
)
),
)
}

View file

@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.test
import io.element.android.libraries.matrix.api.HomeserverCapabilitiesProvider
class FakeHomeserverCapabilitiesProvider(
private val refresh: () -> Result<Unit> = { Result.success(Unit) },
private val canChangeDisplayName: () -> Result<Boolean> = { Result.success(true) },
private val canChangeAvatarUrl: () -> Result<Boolean> = { Result.success(true) },
) : HomeserverCapabilitiesProvider {
override suspend fun refresh(): Result<Unit> = refresh.invoke()
override suspend fun canChangeDisplayName(): Result<Boolean> = canChangeDisplayName.invoke()
override suspend fun canChangeAvatarUrl(): Result<Boolean> = canChangeAvatarUrl.invoke()
}

View file

@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.test
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
@ -84,6 +85,7 @@ class FakeMatrixClient(
override val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(),
override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(),
override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(),
private val accountManagementUrlResult: (AccountManagementAction?) -> Result<String?> = { lambdaError() },
private val resolveRoomAliasResult: (RoomAlias) -> Result<Optional<ResolvedRoomAlias>> = {
Result.success(
@ -384,4 +386,8 @@ class FakeMatrixClient(
override suspend fun resetWellKnownConfig(): Result<Unit> {
return resetWellKnownConfigLambda()
}
override fun homeserverCapabilities(): HomeserverCapabilitiesProvider {
return homeserverCapabilitiesProvider
}
}

View file

@ -9,6 +9,7 @@
package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
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
@ -17,6 +18,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.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@ -32,6 +34,8 @@ class FakeMatrixAuthenticationService(
lambdaRecorder<MatrixQrCodeLoginData, (QrCodeLoginStep) -> Unit, Result<SessionId>> { _, _ -> Result.success(A_SESSION_ID) },
private val importCreatedSessionLambda: (ExternalSession) -> Result<SessionId> = { lambdaError() },
private val setHomeserverResult: (String) -> Result<MatrixHomeServerDetails> = { lambdaError() },
private val setElementClassicSessionResult: (ElementClassicSession?) -> Unit = { lambdaError() },
private val doSecretsContainBackupKeyResult: (UserId, String, String) -> Boolean = { _, _, _ -> lambdaError() },
) : MatrixAuthenticationService {
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
@ -108,4 +112,12 @@ class FakeMatrixAuthenticationService(
fun givenMatrixClient(matrixClient: MatrixClient) {
this.matrixClient = matrixClient
}
override fun setElementClassicSession(session: ElementClassicSession?) {
setElementClassicSessionResult(session)
}
override fun doSecretsContainBackupKey(userId: UserId, secrets: String, backupInfo: String): Boolean {
return doSecretsContainBackupKeyResult(userId, secrets, backupInfo)
}
}

View file

@ -35,6 +35,7 @@ import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
import io.element.android.libraries.matrix.api.widget.MatrixWidgetSettings
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListService
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@ -56,6 +57,7 @@ class FakeJoinedRoom(
override val roomNotificationSettingsStateFlow: StateFlow<RoomNotificationSettingsState> =
MutableStateFlow(RoomNotificationSettingsState.Unknown),
override val knockRequestsFlow: Flow<List<KnockRequest>> = MutableStateFlow(emptyList()),
override val threadsListService: FakeThreadsListService = FakeThreadsListService(),
private val roomNotificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
private var createTimelineResult: (CreateTimelineParams) -> Result<Timeline> = { lambdaError() },
private val editMessageLambda: (EventId, String, String?, List<IntentionalMention>) -> Result<Unit> = { _, _, _, _ -> lambdaError() },

View file

@ -0,0 +1,48 @@
/*
* 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.test.room.threads
import io.element.android.libraries.matrix.api.room.threads.ThreadListItem
import io.element.android.libraries.matrix.api.room.threads.ThreadListPaginationStatus
import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeThreadsListService(
private val items: MutableStateFlow<List<ThreadListItem>> = MutableStateFlow(emptyList()),
private val paginationStatus: MutableStateFlow<ThreadListPaginationStatus> = MutableStateFlow(ThreadListPaginationStatus.Idle(hasMoreToLoad = true)),
private val subscribeToItemUpdates: () -> Flow<List<ThreadListItem>> = { items },
private val subscribeToPaginationUpdates: () -> Flow<ThreadListPaginationStatus> = { paginationStatus },
private val paginate: suspend () -> Result<Unit> = { Result.success(Unit) },
private val reset: suspend () -> Result<Unit> = { Result.success(Unit) },
private val destroy: () -> Unit = {},
) : ThreadsListService {
override fun subscribeToItemUpdates(): Flow<List<ThreadListItem>> {
return subscribeToItemUpdates.invoke()
}
override fun subscribeToPaginationUpdates(): Flow<ThreadListPaginationStatus> {
return subscribeToPaginationUpdates.invoke()
}
override suspend fun paginate(): Result<Unit> {
return paginate.invoke()
}
override suspend fun reset(): Result<Unit> {
return reset.invoke()
}
override fun destroy() {
return destroy.invoke()
}
suspend fun emit(items: List<ThreadListItem>) {
this.items.emit(items)
}
}