Merge branch 'main' into wallet
# Conflicts: # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsPickerView.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt # features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/topbars/MessagesViewTopBar.kt # libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedSuggestion.kt
This commit is contained in:
commit
0ef6b69a79
912 changed files with 17051 additions and 4425 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -225,6 +225,8 @@ interface MatrixClient {
|
|||
* Resets the cached client `well-known` config by the SDK.
|
||||
*/
|
||||
suspend fun resetWellKnownConfig(): Result<Unit>
|
||||
|
||||
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ sealed interface RoomIdOrAlias : Parcelable {
|
|||
is Id -> roomId.value
|
||||
is Alias -> roomAlias.value
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(id: String): RoomIdOrAlias? {
|
||||
return when {
|
||||
MatrixPatterns.isRoomId(id) -> Id(RoomId(id))
|
||||
MatrixPatterns.isRoomAlias(id) -> Alias(RoomAlias(id))
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun RoomId.toRoomIdOrAlias() = RoomIdOrAlias.Id(this)
|
||||
|
|
|
|||
|
|
@ -17,3 +17,18 @@ interface MxcTools {
|
|||
*/
|
||||
fun mxcUri2FilePath(mxcUri: String): String?
|
||||
}
|
||||
|
||||
/**
|
||||
* "mxc" scheme, including "://". So "mxc://".
|
||||
*/
|
||||
const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
|
||||
|
||||
/**
|
||||
* Return true if the String starts with "mxc://".
|
||||
*/
|
||||
fun String.isMxcUrl() = startsWith(MATRIX_CONTENT_URI_SCHEME)
|
||||
|
||||
/**
|
||||
* Remove the "mxc://" prefix. No op if the String is not a Mxc URL.
|
||||
*/
|
||||
fun String.removeMxcPrefix() = removePrefix(MATRIX_CONTENT_URI_SCHEME)
|
||||
|
|
|
|||
|
|
@ -95,7 +95,6 @@ sealed interface NotificationContent {
|
|||
data object PolicyRuleRoom : StateEvent
|
||||
data object PolicyRuleServer : StateEvent
|
||||
data object PolicyRuleUser : StateEvent
|
||||
data object RoomAliases : StateEvent
|
||||
data object RoomAvatar : StateEvent
|
||||
data object RoomCanonicalAlias : StateEvent
|
||||
data object RoomCreate : StateEvent
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ sealed interface StateEventType {
|
|||
data object PolicyRuleServer : StateEventType
|
||||
data object PolicyRuleUser : StateEventType
|
||||
data object CallMember : StateEventType
|
||||
data object RoomAliases : StateEventType
|
||||
data object RoomAvatar : StateEventType
|
||||
data object RoomCanonicalAlias : StateEventType
|
||||
data object RoomCreate : StateEventType
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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.timeline
|
||||
|
||||
enum class MsgType {
|
||||
MSG_TYPE_TEXT,
|
||||
MSG_TYPE_EMOTE,
|
||||
|
||||
// For future support
|
||||
MSG_TYPE_SNOW,
|
||||
|
||||
// For future support
|
||||
MSG_TYPE_CONFETTI,
|
||||
}
|
||||
|
|
@ -69,6 +69,8 @@ interface Timeline : AutoCloseable {
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||
asPlainText: Boolean = false,
|
||||
): Result<Unit>
|
||||
|
||||
/**
|
||||
|
|
@ -102,6 +104,7 @@ interface Timeline : AutoCloseable {
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean = false,
|
||||
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||
): Result<Unit>
|
||||
|
||||
suspend fun sendImage(
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ sealed interface OtherState {
|
|||
data object PolicyRuleRoom : OtherState
|
||||
data object PolicyRuleServer : OtherState
|
||||
data object PolicyRuleUser : OtherState
|
||||
data object RoomAliases : OtherState
|
||||
data class RoomAvatar(val url: String?) : OtherState
|
||||
data object RoomCanonicalAlias : OtherState
|
||||
data object RoomCreate : OtherState
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -838,6 +839,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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ private fun StateEventContent.toContent(): NotificationContent.StateEvent {
|
|||
StateEventContent.PolicyRuleRoom -> NotificationContent.StateEvent.PolicyRuleRoom
|
||||
StateEventContent.PolicyRuleServer -> NotificationContent.StateEvent.PolicyRuleServer
|
||||
StateEventContent.PolicyRuleUser -> NotificationContent.StateEvent.PolicyRuleUser
|
||||
StateEventContent.RoomAliases -> NotificationContent.StateEvent.RoomAliases
|
||||
StateEventContent.RoomAvatar -> NotificationContent.StateEvent.RoomAvatar
|
||||
StateEventContent.RoomCanonicalAlias -> NotificationContent.StateEvent.RoomCanonicalAlias
|
||||
StateEventContent.RoomCreate -> NotificationContent.StateEvent.RoomCreate
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -43,10 +44,11 @@ import io.element.android.libraries.matrix.impl.mapper.map
|
|||
import io.element.android.libraries.matrix.impl.room.history.map
|
||||
import io.element.android.libraries.matrix.impl.room.join.map
|
||||
import io.element.android.libraries.matrix.impl.room.knock.RustKnockRequest
|
||||
import io.element.android.libraries.matrix.impl.room.location.map
|
||||
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
|
||||
|
|
@ -68,7 +70,6 @@ import kotlinx.coroutines.withContext
|
|||
import org.matrix.rustcomponents.sdk.DateDividerMode
|
||||
import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener
|
||||
import org.matrix.rustcomponents.sdk.KnockRequestsListener
|
||||
import org.matrix.rustcomponents.sdk.LiveLocationShareListener
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType
|
||||
import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate
|
||||
import org.matrix.rustcomponents.sdk.SendQueueListener
|
||||
|
|
@ -147,6 +148,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 {
|
||||
|
|
@ -504,13 +511,7 @@ class JoinedRustRoom(
|
|||
}
|
||||
|
||||
override fun subscribeToLiveLocationShares(): Flow<List<LiveLocationShare>> {
|
||||
return mxCallbackFlow {
|
||||
innerRoom.subscribeToLiveLocationShares(object : LiveLocationShareListener {
|
||||
override fun call(liveLocationShares: List<org.matrix.rustcomponents.sdk.LiveLocationShare>) {
|
||||
trySend(liveLocationShares.map { it.map() })
|
||||
}
|
||||
})
|
||||
}
|
||||
TODO("Not implemented yet")
|
||||
}
|
||||
|
||||
override suspend fun startLiveLocationShare(durationMillis: Long): Result<Unit> = withContext(roomDispatcher) {
|
||||
|
|
@ -536,6 +537,7 @@ class JoinedRustRoom(
|
|||
override fun destroy() {
|
||||
baseRoom.destroy()
|
||||
liveInnerTimeline.destroy()
|
||||
threadsListService.destroy()
|
||||
Timber.d("Room $roomId destroyed")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ fun StateEventType.map(): RustStateEventType = when (this) {
|
|||
StateEventType.PolicyRuleServer -> RustStateEventType.PolicyRuleServer
|
||||
StateEventType.PolicyRuleUser -> RustStateEventType.PolicyRuleUser
|
||||
StateEventType.CallMember -> RustStateEventType.CallMember
|
||||
StateEventType.RoomAliases -> RustStateEventType.RoomAliases
|
||||
StateEventType.RoomAvatar -> RustStateEventType.RoomAvatar
|
||||
StateEventType.RoomCanonicalAlias -> RustStateEventType.RoomCanonicalAlias
|
||||
StateEventType.RoomCreate -> RustStateEventType.RoomCreate
|
||||
|
|
@ -46,7 +45,6 @@ fun RustStateEventType.map(): StateEventType = when (this) {
|
|||
RustStateEventType.PolicyRuleServer -> StateEventType.PolicyRuleServer
|
||||
RustStateEventType.PolicyRuleUser -> StateEventType.PolicyRuleUser
|
||||
RustStateEventType.CallMember -> StateEventType.CallMember
|
||||
RustStateEventType.RoomAliases -> StateEventType.RoomAliases
|
||||
RustStateEventType.RoomAvatar -> StateEventType.RoomAvatar
|
||||
RustStateEventType.RoomCanonicalAlias -> StateEventType.RoomCanonicalAlias
|
||||
RustStateEventType.RoomCreate -> StateEventType.RoomCreate
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2025 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.location
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
|
||||
import org.matrix.rustcomponents.sdk.LiveLocationShare as RustLiveLocationShare
|
||||
|
||||
fun RustLiveLocationShare.map(): LiveLocationShare {
|
||||
return LiveLocationShare(
|
||||
userId = UserId(userId),
|
||||
lastGeoUri = lastLocation.location.geoUri,
|
||||
lastTimestamp = lastLocation.ts.toLong(),
|
||||
isLive = isLive,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
|
|||
import io.element.android.libraries.matrix.api.room.isDm
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.TimelineException
|
||||
|
|
@ -129,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()
|
||||
|
|
@ -176,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.
|
||||
|
|
@ -194,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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -263,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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -271,8 +276,16 @@ class RustTimeline(
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType,
|
||||
asPlainText: Boolean,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
MessageEventContent.from(body, htmlBody, intentionalMentions).use { content ->
|
||||
MessageEventContent.from(
|
||||
body = body,
|
||||
htmlBody = htmlBody,
|
||||
intentionalMentions = intentionalMentions,
|
||||
msgType = msgType,
|
||||
asPlainText = asPlainText,
|
||||
).use { content ->
|
||||
runCatchingExceptions<Unit> {
|
||||
inner.send(content)
|
||||
}
|
||||
|
|
@ -354,9 +367,15 @@ class RustTimeline(
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean,
|
||||
msgType: MsgType,
|
||||
): Result<Unit> = withContext(dispatcher) {
|
||||
runCatchingExceptions {
|
||||
val msg = MessageEventContent.from(body, htmlBody, intentionalMentions)
|
||||
val msg = MessageEventContent.from(
|
||||
body = body,
|
||||
htmlBody = htmlBody,
|
||||
intentionalMentions = intentionalMentions,
|
||||
msgType = msgType,
|
||||
)
|
||||
inner.sendReply(
|
||||
msg = msg,
|
||||
eventId = repliedToEventId.value,
|
||||
|
|
@ -372,7 +391,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(
|
||||
|
|
@ -398,7 +417,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(
|
||||
|
|
@ -423,7 +442,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(
|
||||
|
|
@ -447,7 +466,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(
|
||||
|
|
@ -477,7 +496,7 @@ class RustTimeline(
|
|||
runCatchingExceptions {
|
||||
roomContentForwarder.forward(fromTimeline = inner, eventId = eventId, toRoomIds = roomIds)
|
||||
}.onFailure {
|
||||
Timber.e(it)
|
||||
Timber.tag(loggerTag).e(it)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -232,7 +232,6 @@ private fun RustOtherState.map(): OtherState {
|
|||
RustOtherState.PolicyRuleRoom -> OtherState.PolicyRuleRoom
|
||||
RustOtherState.PolicyRuleServer -> OtherState.PolicyRuleServer
|
||||
RustOtherState.PolicyRuleUser -> OtherState.PolicyRuleUser
|
||||
RustOtherState.RoomAliases -> OtherState.RoomAliases
|
||||
is RustOtherState.RoomAvatar -> OtherState.RoomAvatar(url)
|
||||
RustOtherState.RoomCanonicalAlias -> OtherState.RoomCanonicalAlias
|
||||
RustOtherState.RoomCreate -> OtherState.RoomCreate
|
||||
|
|
|
|||
|
|
@ -9,20 +9,54 @@
|
|||
package io.element.android.libraries.matrix.impl.util
|
||||
|
||||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.impl.room.map
|
||||
import org.matrix.rustcomponents.sdk.MessageContent
|
||||
import org.matrix.rustcomponents.sdk.MessageType
|
||||
import org.matrix.rustcomponents.sdk.RoomMessageEventContentWithoutRelation
|
||||
import org.matrix.rustcomponents.sdk.TextMessageContent
|
||||
import org.matrix.rustcomponents.sdk.contentWithoutRelationFromMessage
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtml
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromHtmlAsEmote
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
|
||||
import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdownAsEmote
|
||||
|
||||
/**
|
||||
* Creates a [RoomMessageEventContentWithoutRelation] from a body, an html body and a list of mentions.
|
||||
*/
|
||||
object MessageEventContent {
|
||||
fun from(body: String, htmlBody: String?, intentionalMentions: List<IntentionalMention>): RoomMessageEventContentWithoutRelation {
|
||||
return if (htmlBody != null) {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}.withMentions(intentionalMentions.map())
|
||||
fun from(
|
||||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType = MsgType.MSG_TYPE_TEXT,
|
||||
asPlainText: Boolean = false,
|
||||
): RoomMessageEventContentWithoutRelation {
|
||||
return when {
|
||||
asPlainText -> contentWithoutRelationFromMessage(
|
||||
MessageContent(
|
||||
msgType = MessageType.Text(
|
||||
TextMessageContent(
|
||||
body = body,
|
||||
formatted = null,
|
||||
)
|
||||
),
|
||||
body = body,
|
||||
isEdited = false,
|
||||
mentions = null,
|
||||
)
|
||||
)
|
||||
htmlBody != null -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
|
||||
messageEventContentFromHtmlAsEmote(body, htmlBody)
|
||||
} else {
|
||||
messageEventContentFromHtml(body, htmlBody)
|
||||
}
|
||||
else -> if (msgType == MsgType.MSG_TYPE_EMOTE) {
|
||||
messageEventContentFromMarkdownAsEmote(body)
|
||||
} else {
|
||||
messageEventContentFromMarkdown(body)
|
||||
}
|
||||
}
|
||||
.withMentions(intentionalMentions.map())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -14,10 +14,9 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
|||
import org.matrix.rustcomponents.sdk.EventOrTransactionId
|
||||
import org.matrix.rustcomponents.sdk.EventSendState
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItem
|
||||
import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo
|
||||
import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider
|
||||
import org.matrix.rustcomponents.sdk.ProfileDetails
|
||||
import org.matrix.rustcomponents.sdk.Receipt
|
||||
import org.matrix.rustcomponents.sdk.ShieldState
|
||||
import org.matrix.rustcomponents.sdk.TimelineItemContent
|
||||
import uniffi.matrix_sdk_ui.EventItemOrigin
|
||||
|
||||
|
|
@ -26,37 +25,35 @@ internal fun aRustEventTimelineItem(
|
|||
eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value),
|
||||
sender: String = A_USER_ID.value,
|
||||
senderProfile: ProfileDetails = ProfileDetails.Unavailable,
|
||||
forwarder: String? = null,
|
||||
forwarderProfile: ProfileDetails? = null,
|
||||
isOwn: Boolean = true,
|
||||
isEditable: Boolean = true,
|
||||
content: TimelineItemContent = aRustTimelineItemContentMsgLike(),
|
||||
eventTypeRaw: String? = null,
|
||||
timestamp: ULong = 0uL,
|
||||
debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(),
|
||||
localSendState: EventSendState? = null,
|
||||
localCreatedAt: ULong? = null,
|
||||
readReceipts: Map<String, Receipt> = emptyMap(),
|
||||
origin: EventItemOrigin? = EventItemOrigin.SYNC,
|
||||
canBeRepliedTo: Boolean = true,
|
||||
shieldsState: ShieldState = ShieldState.None,
|
||||
localCreatedAt: ULong? = null,
|
||||
forwarder: String? = null,
|
||||
forwarderProfile: ProfileDetails? = null,
|
||||
lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(),
|
||||
) = EventTimelineItem(
|
||||
isRemote = isRemote,
|
||||
eventOrTransactionId = eventOrTransactionId,
|
||||
sender = sender,
|
||||
senderProfile = senderProfile,
|
||||
timestamp = timestamp,
|
||||
isOwn = isOwn,
|
||||
isEditable = isEditable,
|
||||
canBeRepliedTo = canBeRepliedTo,
|
||||
content = content,
|
||||
localSendState = localSendState,
|
||||
readReceipts = readReceipts,
|
||||
origin = origin,
|
||||
localCreatedAt = localCreatedAt,
|
||||
lazyProvider = FakeFfiLazyTimelineItemProvider(
|
||||
debugInfo = debugInfo,
|
||||
shieldsState = shieldsState,
|
||||
),
|
||||
forwarder = forwarder,
|
||||
forwarderProfile = forwarderProfile,
|
||||
isOwn = isOwn,
|
||||
isEditable = isEditable,
|
||||
content = content,
|
||||
eventTypeRaw = eventTypeRaw,
|
||||
timestamp = timestamp,
|
||||
localSendState = localSendState,
|
||||
localCreatedAt = localCreatedAt,
|
||||
readReceipts = readReceipts,
|
||||
origin = origin,
|
||||
canBeRepliedTo = canBeRepliedTo,
|
||||
lazyProvider = lazyProvider,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,6 @@ class StateEventTypeTest {
|
|||
assertThat(RustStateEventType.PolicyRuleRoom.map()).isEqualTo(StateEventType.PolicyRuleRoom)
|
||||
assertThat(RustStateEventType.PolicyRuleServer.map()).isEqualTo(StateEventType.PolicyRuleServer)
|
||||
assertThat(RustStateEventType.PolicyRuleUser.map()).isEqualTo(StateEventType.PolicyRuleUser)
|
||||
assertThat(RustStateEventType.RoomAliases.map()).isEqualTo(StateEventType.RoomAliases)
|
||||
assertThat(RustStateEventType.RoomAvatar.map()).isEqualTo(StateEventType.RoomAvatar)
|
||||
assertThat(RustStateEventType.RoomCanonicalAlias.map()).isEqualTo(StateEventType.RoomCanonicalAlias)
|
||||
assertThat(RustStateEventType.RoomCreate.map()).isEqualTo(StateEventType.RoomCreate)
|
||||
|
|
@ -47,7 +46,6 @@ class StateEventTypeTest {
|
|||
assertThat(StateEventType.PolicyRuleRoom.map()).isEqualTo(RustStateEventType.PolicyRuleRoom)
|
||||
assertThat(StateEventType.PolicyRuleServer.map()).isEqualTo(RustStateEventType.PolicyRuleServer)
|
||||
assertThat(StateEventType.PolicyRuleUser.map()).isEqualTo(RustStateEventType.PolicyRuleUser)
|
||||
assertThat(StateEventType.RoomAliases.map()).isEqualTo(RustStateEventType.RoomAliases)
|
||||
assertThat(StateEventType.RoomAvatar.map()).isEqualTo(RustStateEventType.RoomAvatar)
|
||||
assertThat(StateEventType.RoomCanonicalAlias.map()).isEqualTo(RustStateEventType.RoomCanonicalAlias)
|
||||
assertThat(StateEventType.RoomCreate.map()).isEqualTo(RustStateEventType.RoomCreate)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ dependencies {
|
|||
api(projects.libraries.matrix.api)
|
||||
api(libs.coroutines.core)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.libraries.matrix.impl)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.tests.testutils)
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,12 @@
|
|||
package io.element.android.libraries.matrix.test.mxc
|
||||
|
||||
import io.element.android.libraries.matrix.api.mxc.MxcTools
|
||||
import io.element.android.libraries.matrix.impl.mxc.DefaultMxcTools
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
|
||||
class FakeMxcTools(
|
||||
private val delegate: MxcTools = DefaultMxcTools()
|
||||
) : MxcTools by delegate
|
||||
private val mxcUri2FilePathResult: (String) -> String? = { lambdaError() }
|
||||
) : MxcTools {
|
||||
override fun mxcUri2FilePath(mxcUri: String): String? {
|
||||
return mxcUri2FilePathResult(mxcUri)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.poll.PollKind
|
|||
import io.element.android.libraries.matrix.api.room.IntentionalMention
|
||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
|
||||
import io.element.android.libraries.matrix.api.timeline.MsgType
|
||||
import io.element.android.libraries.matrix.api.timeline.ReceiptType
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
|
|
@ -78,7 +79,9 @@ class FakeTimeline(
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
) -> Result<Unit> = { _, _, _ ->
|
||||
msgType: MsgType,
|
||||
asPlainText: Boolean,
|
||||
) -> Result<Unit> = { _, _, _, _, _ ->
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
|
|
@ -90,8 +93,10 @@ class FakeTimeline(
|
|||
body: String,
|
||||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
msgType: MsgType,
|
||||
asPlainText: Boolean,
|
||||
): Result<Unit> = simulateLongTask {
|
||||
sendMessageLambda(body, htmlBody, intentionalMentions)
|
||||
sendMessageLambda(body, htmlBody, intentionalMentions, msgType, asPlainText)
|
||||
}
|
||||
|
||||
var redactEventLambda: (eventOrTransactionId: EventOrTransactionId, reason: String?) -> Result<Unit> = { _, _ ->
|
||||
|
|
@ -148,7 +153,8 @@ class FakeTimeline(
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean,
|
||||
) -> Result<Unit> = { _, _, _, _, _ ->
|
||||
msgType: MsgType,
|
||||
) -> Result<Unit> = { _, _, _, _, _, _ ->
|
||||
lambdaError()
|
||||
}
|
||||
|
||||
|
|
@ -158,12 +164,14 @@ class FakeTimeline(
|
|||
htmlBody: String?,
|
||||
intentionalMentions: List<IntentionalMention>,
|
||||
fromNotification: Boolean,
|
||||
msgType: MsgType,
|
||||
): Result<Unit> = replyMessageLambda(
|
||||
repliedToEventId,
|
||||
body,
|
||||
htmlBody,
|
||||
intentionalMentions,
|
||||
fromNotification,
|
||||
msgType,
|
||||
)
|
||||
|
||||
var sendImageLambda: (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue