Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-09-29 17:15:40 +02:00
1583 changed files with 15749 additions and 7431 deletions
@@ -326,6 +326,11 @@ interface MatrixClient : ClientUrlContentFetcher {
*/
suspend fun uploadMedia(mimeType: String, data: ByteArray): Result<String>
/**
* Returns the latest [RoomInfo] of a room, if known to the client, or `null` if the room is unknown.
*/
suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?>
/**
* Get a room info flow for a given room ID.
* The flow will emit a new value whenever the room info is updated, skipping duplicates, and an empty [Optional] if the room is not found.
@@ -0,0 +1,73 @@
/*
* 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.accountprovider
import androidx.compose.runtime.Immutable
@Immutable
sealed interface AccountProvider {
/**
* An account provider that was input by the user.
*/
data class Generic(
val serverName: String,
) : AccountProvider
/**
* An account provider that is provided by the app.
*/
data class Managed(
val serverName: String,
val baseUrl: String,
val canUseServerName: Boolean,
) : AccountProvider
fun serverNameOrBaseUrl(): String {
return when (this) {
is Generic -> serverName
is Managed -> if (canUseServerName) serverName else baseUrl
}.sanitized()
}
/**
* Return the server name, to be used in the UI.
*/
fun friendlyServerName(): String {
return when (this) {
is Generic -> serverName
is Managed -> serverName
}.sanitized()
}
/**
* The user may reach an allowed account provider by either of its identifiers: the server name it is known by
* (what the user types, e.g. `element.io`) or the base url its client API lives at (e.g. `https://element.ems.host`).
*/
fun matches(other: AccountProvider): Boolean {
return serverNameOrBaseUrl() == other.serverNameOrBaseUrl()
}
/**
* Sanitizes the string with the following rules:
* - Trim any whitespace.
* - Lowercase the string.
* - Removes a https scheme (treating it as implicit).
* - Remove any trailing slashes.
*/
private fun String.sanitized(): String {
return trim()
.lowercase()
// Intentionally continue to show http:// as an indicator (and for history).
.removePrefix("https://")
.removeSuffix("/")
}
}
val matrixOrgAccountProvider = AccountProvider.Generic(
serverName = "matrix.org",
)
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.api.core
import io.element.android.libraries.androidutils.metadata.isInDebug
import java.io.Serializable
@kotlinx.serialization.Serializable
@JvmInline
value class RoomId(val value: String) : Serializable {
init {
@@ -19,4 +19,6 @@ data class MatrixUser(
val avatarUrl: String? = null,
val displayedStatus: DisplayedStatus? = null,
val rawStatus: UserStatus? = null,
/** Base64 encoded thumbnail of [avatarUrl], stored locally, used as a fallback when the avatar cannot be loaded. */
val avatarThumbnail: String? = null,
) : Parcelable
@@ -0,0 +1,89 @@
/*
* 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.accountprovider
import com.google.common.truth.Truth.assertThat
import org.junit.Test
class AccountProviderTest {
@Test
fun `serverNameOrBaseUrl of a generic account provider is the server name the user input`() {
assertThat(AccountProvider.Generic("matrix.org").serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl of a managed account provider is its server name when it can be used`() {
val accountProvider = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = true,
)
assertThat(accountProvider.serverNameOrBaseUrl()).isEqualTo("element.io")
}
@Test
fun `serverNameOrBaseUrl of a managed account provider is its base url when the server name cannot be used`() {
val accountProvider = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = false,
)
assertThat(accountProvider.serverNameOrBaseUrl()).isEqualTo("element.ems.host")
}
@Test
fun `matrixOrgAccountProvider is known by its server name`() {
assertThat(matrixOrgAccountProvider.serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl trims, lowercases and drops the implicit https scheme and a trailing slash`() {
assertThat(AccountProvider.Generic(" MATRIX.org ").serverNameOrBaseUrl()).isEqualTo("matrix.org")
assertThat(AccountProvider.Generic("https://matrix.org").serverNameOrBaseUrl()).isEqualTo("matrix.org")
assertThat(AccountProvider.Generic("https://matrix.org/").serverNameOrBaseUrl()).isEqualTo("matrix.org")
}
@Test
fun `serverNameOrBaseUrl keeps an explicit http scheme as an indicator`() {
assertThat(AccountProvider.Generic("http://localhost:8080").serverNameOrBaseUrl()).isEqualTo("http://localhost:8080")
}
@Test
fun `matches ignores the scheme, the case and a trailing slash`() {
val typed = AccountProvider.Generic("Element.io/")
assertThat(typed.matches(AccountProvider.Generic("https://element.io"))).isTrue()
assertThat(
typed.matches(
AccountProvider.Managed(serverName = "element.io", baseUrl = "https://element.ems.host", canUseServerName = true)
)
).isTrue()
}
@Test
fun `matches a managed provider that cannot use its server name only through its base url`() {
val managed = AccountProvider.Managed(
serverName = "element.io",
baseUrl = "https://element.ems.host",
canUseServerName = false,
)
assertThat(AccountProvider.Generic("element.ems.host").matches(managed)).isTrue()
assertThat(AccountProvider.Generic("element.io").matches(managed)).isFalse()
}
@Test
fun `matches is false for a different account provider`() {
val typed = AccountProvider.Generic("element.io")
assertThat(typed.matches(AccountProvider.Generic("matrix.org"))).isFalse()
// A managed provider that can use its server name is not reached through its base url.
assertThat(
typed.matches(
AccountProvider.Managed(serverName = "other.io", baseUrl = "https://element.io", canUseServerName = true)
)
).isFalse()
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ dependencies {
} else {
debugImplementation(libs.matrix.sdk)
}
implementation(projects.libraries.rustlsTls)
implementation(variantOf(libs.rustls.platform.verifier) { artifactType("aar") })
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
@@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.createroom.RoomPreset
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.oauth.AccountManagementAction
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.api.room.BaseRoom
@@ -136,6 +137,7 @@ import timber.log.Timber
import java.io.File
import java.util.Optional
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.io.encoding.Base64
import kotlin.jvm.optionals.getOrNull
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@@ -143,6 +145,9 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
// Same value as AVATAR_THUMBNAIL_SIZE_IN_PIXEL in the matrixmedia module
private const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L
@Suppress("LargeClass")
class RustMatrixClient(
override val sessionPaths: SessionPaths,
@@ -289,7 +294,11 @@ class RustMatrixClient(
private val ownProfileListener = object : ProfileListener {
override fun onUpdate(profile: UserProfile) {
_userProfile.tryEmit(profile.map())
val matrixUser = profile.map()
_userProfile.tryEmit(matrixUser)
sessionCoroutineScope.launch {
storeUserProfile(matrixUser)
}
}
}
@@ -485,13 +494,37 @@ class RustMatrixClient(
.onSuccess { matrixUser ->
// Also update our session storage
_userProfile.emit(matrixUser)
sessionStore.updateUserProfile(
sessionId = sessionId.value,
displayName = matrixUser.displayName,
avatarUrl = matrixUser.avatarUrl,
)
storeUserProfile(matrixUser)
}
/**
* Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar,
* so that the avatar can be rendered without having to use this client.
*/
private suspend fun storeUserProfile(matrixUser: MatrixUser) {
val avatarUrl = matrixUser.avatarUrl
val storedSession = sessionStore.getSession(sessionId.value)
val avatarData = when {
avatarUrl == null -> null
// Avatar has not changed, no need to download it again
avatarUrl == storedSession?.userAvatarUrl && storedSession.userAvatarData != null -> storedSession.userAvatarData
else -> matrixMediaLoader.loadMediaThumbnail(
source = MediaSource(avatarUrl),
width = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
height = AVATAR_THUMBNAIL_SIZE_IN_PIXEL,
)
.map { Base64.encode(it) }
.onFailure { Timber.w(it, "Unable to load the avatar thumbnail of the user") }
.getOrNull()
}
sessionStore.updateUserProfile(
sessionId = sessionId.value,
displayName = matrixUser.displayName,
avatarUrl = avatarUrl,
avatarData = avatarData,
)
}
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(sessionDispatcher) {
runCatchingExceptions {
@@ -779,6 +812,14 @@ class RustMatrixClient(
}
}
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
runCatchingExceptions {
innerRoomListService.roomOrNull(roomId.value)?.use { room ->
roomInfoMapper.map(room.roomInfo())
}
}
}
override fun getRoomInfoFlow(roomId: RoomId): Flow<Optional<RoomInfo>> {
return mxCallbackFlow {
val roomNotFound = innerRoomListService.roomOrNull(roomId.value).use { it == null }
@@ -28,7 +28,6 @@ fun Throwable.mapAuthenticationException(): AuthenticationException {
}
is ClientBuildException.WellKnownLookupFailed -> AuthenticationException.Generic(message)
is ClientBuildException.EventCache -> AuthenticationException.Generic(message)
is ClientBuildException.InvalidRawKey -> AuthenticationException.Generic(message)
is ClientBuildException.WellKnownLookupDisabled -> AuthenticationException.Generic(message)
}
is OAuthException -> when (this) {
@@ -381,6 +381,9 @@ class RustMatrixAuthenticationService(
// Apply enterprise hooks to the newly created client as soon as possible
clientEnterpriseHook(matrixClient)
// Start the sync service to ensure that the client receives the encryption updates we'll wait for next.
matrixClient.syncService.startSync()
matrixClient.waitForKnownVerificationState()
newMatrixClientObservers.forEach { it.invoke(matrixClient) }
@@ -38,4 +38,5 @@ internal fun Session.toSessionData(
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
userAvatarData = null,
)
@@ -8,10 +8,27 @@
package io.element.android.libraries.matrix.impl.media
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaSource
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.use
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
fun RustMediaSource.map(): MediaSource = use {
MediaSource(it.url(), it.toJson())
}
internal fun MediaSource.toRustMediaSource(): RustMediaSource {
val json = this.json
return try {
if (json != null) {
RustMediaSource.fromJson(json)
} else {
RustMediaSource.fromUrl(safeUrl)
}
} catch (e: LinkageError) {
// Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
if (isTest) RustMediaSource(NoHandle) else throw e
}
}
@@ -20,7 +20,6 @@ import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.use
import java.io.File
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustMediaLoader(
private val baseCacheDirectory: File,
@@ -80,13 +79,4 @@ class RustMediaLoader(
}
}
}
private fun MediaSource.toRustMediaSource(): RustMediaSource {
val json = this.json
return if (json != null) {
RustMediaSource.fromJson(json)
} else {
RustMediaSource.fromUrl(safeUrl)
}
}
}
@@ -478,7 +478,7 @@ class JoinedRustRoom(
widgetSettings = widgetSettings,
room = innerRoom,
widgetCapabilitiesProvider = object : WidgetCapabilitiesProvider {
override fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
override suspend fun acquireCapabilities(capabilities: WidgetCapabilities): WidgetCapabilities {
return getElementCallRequiredPermissions(sessionId.value, baseRoom.deviceId.value)
}
},
@@ -12,10 +12,9 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.scanner.ContentScanner
import io.element.android.libraries.matrix.impl.exception.mapClientException
import io.element.android.libraries.matrix.impl.media.toRustMediaSource
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.ContentScanner as RustScanner
import org.matrix.rustcomponents.sdk.MediaSource as RustMediaSource
class RustContentScanner(
private val client: Client,
@@ -29,18 +28,3 @@ class RustContentScanner(
}
}
}
private fun MediaSource.toRustMediaSource(): RustMediaSource {
val json = this.json
return try {
if (json != null) {
RustMediaSource.fromJson(json)
} else {
RustMediaSource.fromUrl(safeUrl)
}
} catch (e: LinkageError) {
// Used for tests, since we can't instantiate an actual `RustMediaSource` because the native library can't be loaded
val isTest = runCatchingExceptions { Class.forName("org.junit.Test") }.isSuccess
if (isTest) RustMediaSource(NoHandle) else throw e
}
}
@@ -7,11 +7,13 @@
package io.element.android.libraries.matrix.impl.storage
import android.util.Base64
import io.element.android.libraries.androidutils.crypto.ClientSecret
import io.element.android.libraries.core.data.ByteUnit
import io.element.android.libraries.core.data.megaBytes
import io.element.android.libraries.matrix.api.paths.SessionPaths
import org.matrix.rustcomponents.sdk.ClientBuilder
import uniffi.matrix_sdk_sqlite.Base64Variant
import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder
/**
@@ -40,10 +42,15 @@ class RustSqliteStoreBuilder(
override fun secret(clientSecret: ClientSecret?): SqliteStoreBuilder {
when (clientSecret) {
null -> Unit
is ClientSecret.Passphrase -> inner = inner.passphrase(clientSecret.value)
is ClientSecret.Passphrase -> {
// Use high entropy passphrase to migrate a passphrase to a raw key.
val base64Passphrase = Base64.decode(clientSecret.value, Base64.NO_WRAP or Base64.NO_PADDING)
inner = inner.highEntropyPassphrase(base64Passphrase, Base64Variant.UNPADDED)
// Once the passphrase is migrated, we can derive a raw key from it and use that for the SDK, replacing the line above.
// inner = inner.key(base64Passphrase)
}
is ClientSecret.RawKey -> {
// Ensure the key is 32 bytes long, as required by the SDK
inner = inner.key(clientSecret.keyOfSize(32))
inner = inner.key(clientSecret.bytes)
}
}
return this
@@ -53,15 +60,3 @@ class RustSqliteStoreBuilder(
return clientBuilder.sqliteStore(this.inner)
}
}
private fun ClientSecret.RawKey.keyOfSize(size: Int): ByteArray {
return if (bytes.size == 32) {
bytes
} else if (bytes.size < 32) {
// If the key is shorter than 32 bytes, pad it with zeros
bytes + ByteArray(32 - bytes.size)
} else {
// Otherwise, take the first 32 bytes of the key
bytes.copyOfRange(0, 32)
}
}
@@ -31,7 +31,6 @@ import org.matrix.rustcomponents.sdk.EventOrTransactionId
import org.matrix.rustcomponents.sdk.QueueWedgeError
import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.ShieldState
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.TimelineEventShieldStateCode
import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem
@@ -52,7 +51,7 @@ class EventTimelineItemMapper(
isOwn = isOwn,
isRemote = isRemote,
localSendState = localSendState?.map(),
reactions = (content as? TimelineItemContent.MsgLike)?.content?.reactions.map(),
reactions = reactions.map(),
receipts = readReceipts.map(),
sender = UserId(sender),
senderProfile = senderProfile.map(),
@@ -0,0 +1,22 @@
/*
* 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 org.matrix.rustsdk
import android.content.Context
/**
* This class is used to initialize the Rust SDK on Android and give it access to an Android [Context].
*/
object Android {
init {
System.loadLibrary("matrix_sdk_ffi")
}
@JvmStatic
external fun init(context: Context)
}
@@ -49,7 +49,7 @@ class RustMatrixClientFactoryTest {
fun TestScope.createRustMatrixClientFactory(
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
updateUserProfileResult = { _, _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
@@ -14,6 +14,7 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.data.bytes
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.paths.SessionPaths
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustUserProfile
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient
import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService
import io.element.android.libraries.matrix.impl.room.FakeTimelineEventFilterFactory
@@ -29,6 +30,7 @@ import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.any
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers
@@ -39,9 +41,9 @@ import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.CreateRoomParameters
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.RoomHistoryVisibility
import org.matrix.rustcomponents.sdk.StoreSizes
import org.matrix.rustcomponents.sdk.UserProfile
import java.io.File
private const val AN_ACCOUNT_DATA_EVENT_TYPE = "org.example.custom"
@@ -76,7 +78,8 @@ class RustMatrixClientTest {
@Test
fun `retrieving the UserProfile updates the database`() = runTest {
val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, Unit> { _, _, _ -> profilePersisted.complete(Unit) }
val updateUserProfileResult = lambdaRecorder<String, String?, String?, String?, Unit> { _, _, _, _ -> profilePersisted.complete(Unit) }
val getMediaThumbnailResult = lambdaRecorder<MediaSource, ULong, ULong, ByteArray> { _, _, _ -> byteArrayOf(1, 2, 3) }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
@@ -89,24 +92,160 @@ class RustMatrixClientTest {
)
val client = createRustMatrixClient(
client = FakeFfiClient(
getProfileResult = { userId ->
UserProfile(
userId = userId,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
status = null,
call = null,
)
},
getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
getMediaThumbnailResult = getMediaThumbnailResult,
),
sessionStore = sessionStore,
)
profilePersisted.await()
getMediaThumbnailResult.assertions().isCalledOnce()
.with(any(), value(240uL), value(240uL))
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(AN_AVATAR_URL),
value("AQID"),
)
client.destroy()
}
@Test
fun `retrieving the UserProfile does not download the avatar again if it has not changed`() = runTest {
val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, String?, Unit> { _, _, _, _ -> profilePersisted.complete(Unit) }
val getMediaThumbnailResult = lambdaRecorder<MediaSource, ULong, ULong, ByteArray> { _, _, _ -> byteArrayOf(1, 2, 3) }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
userDisplayName = A_USER_NAME,
userAvatarUrl = AN_AVATAR_URL,
userAvatarData = "storedData",
)
),
updateUserProfileResult = updateUserProfileResult,
)
val client = createRustMatrixClient(
client = FakeFfiClient(
getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
getMediaThumbnailResult = getMediaThumbnailResult,
),
sessionStore = sessionStore,
)
profilePersisted.await()
getMediaThumbnailResult.assertions().isNeverCalled()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(AN_AVATAR_URL),
value("storedData"),
)
client.destroy()
}
@Test
fun `retrieving the UserProfile downloads the avatar again if the previous download failed`() = runTest {
val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, String?, Unit> { _, _, _, _ -> profilePersisted.complete(Unit) }
val getMediaThumbnailResult = lambdaRecorder<MediaSource, ULong, ULong, ByteArray> { _, _, _ -> byteArrayOf(1, 2, 3) }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
userDisplayName = A_USER_NAME,
userAvatarUrl = AN_AVATAR_URL,
userAvatarData = null,
)
),
updateUserProfileResult = updateUserProfileResult,
)
val client = createRustMatrixClient(
client = FakeFfiClient(
getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
getMediaThumbnailResult = getMediaThumbnailResult,
),
sessionStore = sessionStore,
)
profilePersisted.await()
getMediaThumbnailResult.assertions().isCalledOnce()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(AN_AVATAR_URL),
value("AQID"),
)
client.destroy()
}
@Test
fun `retrieving the UserProfile clears the avatar data if the avatar download fails`() = runTest {
val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, String?, Unit> { _, _, _, _ -> profilePersisted.complete(Unit) }
val getMediaThumbnailResult = lambdaRecorder<MediaSource, ULong, ULong, ByteArray> { _, _, _ -> error("Download failed") }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
userDisplayName = A_USER_NAME,
userAvatarUrl = "mxc://server.org/previousAvatar",
userAvatarData = "previousData",
)
),
updateUserProfileResult = updateUserProfileResult,
)
val client = createRustMatrixClient(
client = FakeFfiClient(
getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = AN_AVATAR_URL) },
getMediaThumbnailResult = getMediaThumbnailResult,
),
sessionStore = sessionStore,
)
profilePersisted.await()
getMediaThumbnailResult.assertions().isCalledOnce()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(AN_AVATAR_URL),
value(null),
)
client.destroy()
}
@Test
fun `retrieving the UserProfile without avatar clears the avatar data`() = runTest {
val profilePersisted = CompletableDeferred<Unit>()
val updateUserProfileResult = lambdaRecorder<String, String?, String?, String?, Unit> { _, _, _, _ -> profilePersisted.complete(Unit) }
val getMediaThumbnailResult = lambdaRecorder<MediaSource, ULong, ULong, ByteArray> { _, _, _ -> byteArrayOf(1, 2, 3) }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(
sessionId = A_USER_ID.value,
userDisplayName = A_USER_NAME,
userAvatarUrl = AN_AVATAR_URL,
userAvatarData = "storedData",
)
),
updateUserProfileResult = updateUserProfileResult,
)
val client = createRustMatrixClient(
client = FakeFfiClient(
getProfileResult = { userId -> aRustUserProfile(userId = userId, displayName = A_USER_NAME, avatarUrl = null) },
getMediaThumbnailResult = getMediaThumbnailResult,
),
sessionStore = sessionStore,
)
profilePersisted.await()
getMediaThumbnailResult.assertions().isNeverCalled()
updateUserProfileResult.assertions().isCalledOnce()
.with(
value(A_USER_ID.value),
value(A_USER_NAME),
value(null),
value(null),
)
client.destroy()
}
@@ -169,7 +308,7 @@ class RustMatrixClientTest {
private fun TestScope.createRustMatrixClient(
client: Client = FakeFfiClient(),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
updateUserProfileResult = { _, _, _, _ -> },
),
) = RustMatrixClient(
innerClient = client,
@@ -60,7 +60,7 @@ class RustTemporaryMatrixClientFactoryTest {
private fun TestScope.createRustMatrixClientFactory(
cacheDirectory: File = File("/cache"),
sessionStore: SessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
updateUserProfileResult = { _, _, _, _ -> },
),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
@@ -255,7 +255,7 @@ class RustMatrixAuthenticationServiceTest {
}
private fun TestScope.createRustMatrixAuthenticationService(
sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _ -> }),
sessionStore: SessionStore = InMemorySessionStore(updateUserProfileResult = { _, _, _, _ -> }),
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
enterpriseService: EnterpriseService = FakeEnterpriseService(),
sessionPathsFactory: SessionPathsFactory = SessionPathsFactory(File("/base"), File("/cache")),
@@ -16,6 +16,7 @@ import org.matrix.rustcomponents.sdk.EventSendState
import org.matrix.rustcomponents.sdk.EventTimelineItem
import org.matrix.rustcomponents.sdk.LazyTimelineItemProvider
import org.matrix.rustcomponents.sdk.ProfileDetails
import org.matrix.rustcomponents.sdk.Reaction
import org.matrix.rustcomponents.sdk.Receipt
import org.matrix.rustcomponents.sdk.TimelineItemContent
import uniffi.matrix_sdk_ui.EventItemOrigin
@@ -37,7 +38,10 @@ internal fun aRustEventTimelineItem(
readReceipts: Map<String, Receipt> = emptyMap(),
origin: EventItemOrigin? = EventItemOrigin.SYNC,
canBeRepliedTo: Boolean = true,
reactions: List<Reaction> = emptyList(),
lazyProvider: LazyTimelineItemProvider = FakeFfiLazyTimelineItemProvider(),
editSendState: EventSendState? = null,
redactionSendState: EventSendState? = null,
) = EventTimelineItem(
isRemote = isRemote,
eventOrTransactionId = eventOrTransactionId,
@@ -55,5 +59,8 @@ internal fun aRustEventTimelineItem(
readReceipts = readReceipts,
origin = origin,
canBeRepliedTo = canBeRepliedTo,
reactions = reactions,
lazyProvider = lazyProvider,
editSendState = editSendState,
redactionSendState = redactionSendState,
)
@@ -27,7 +27,6 @@ internal fun aRustTimelineItemContentMsgLike(
mentions = null,
)
),
reactions = emptyList(),
threadRoot = null,
inReplyTo = null,
threadSummary = null,
@@ -16,7 +16,7 @@ import org.matrix.rustcomponents.sdk.UserStatus
internal fun aRustUserProfile(
userId: String = A_USER_ID.value,
displayName: String = "displayName",
avatarUrl: String = "avatarUrl",
avatarUrl: String? = "avatarUrl",
status: UserStatus? = null,
call: UserCall? = null,
) = UserProfile(
@@ -26,6 +26,7 @@ import org.matrix.rustcomponents.sdk.HomeserverCapabilities
import org.matrix.rustcomponents.sdk.HomeserverLoginDetails
import org.matrix.rustcomponents.sdk.IgnoredUsersListener
import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler
import org.matrix.rustcomponents.sdk.MediaSource
import org.matrix.rustcomponents.sdk.NoHandle
import org.matrix.rustcomponents.sdk.NotificationClient
import org.matrix.rustcomponents.sdk.NotificationProcessSetup
@@ -70,6 +71,7 @@ class FakeFfiClient(
private val isProfilesSlidingSyncExtensionSupportedResult: () -> Boolean = { false },
private val subscribeToOwnProfileResult: (ProfileListener) -> Unit = {},
private val getUrlResult: (String) -> ByteArray = { lambdaError() },
private val getMediaThumbnailResult: (MediaSource, ULong, ULong) -> ByteArray = { _, _, _ -> byteArrayOf() },
private val contentScannerResult: () -> ContentScanner = { FakeFfiContentScanner() },
private val closeResult: () -> Unit = {},
) : Client(NoHandle) {
@@ -122,6 +124,10 @@ class FakeFfiClient(
return getProfileResult(userId)
}
override suspend fun getMediaThumbnail(mediaSource: MediaSource, width: ULong, height: ULong): ByteArray {
return getMediaThumbnailResult(mediaSource, width, height)
}
override suspend fun homeserverLoginDetails(): HomeserverLoginDetails {
return homeserverLoginDetailsResult()
}
@@ -74,6 +74,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import java.io.File
import java.util.Optional
import kotlin.jvm.optionals.getOrNull
class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
@@ -173,6 +174,7 @@ class FakeMatrixClient(
var knockRoomLambda: (RoomIdOrAlias, String, List<String>) -> Result<RoomInfo?> = { _, _, _ ->
Result.success(null)
}
var getRoomInfoLambda = { _: RoomId -> Result.success<RoomInfo?>(null) }
var getRoomInfoFlowLambda = { _: RoomId ->
flowOf<Optional<RoomInfo>>(Optional.empty())
}
@@ -379,6 +381,8 @@ class FakeMatrixClient(
return Result.success(visitedRoomsId)
}
override suspend fun getRoomInfo(roomId: RoomId): Result<RoomInfo?> = getRoomInfoLambda(roomId)
override fun getRoomInfoFlow(roomId: RoomId) = getRoomInfoFlowLambda(roomId)
var setAllSendQueuesEnabledLambda = lambdaRecorder(ensureNeverCalled = true) { _: Boolean ->
@@ -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.test.accountprovider
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER
import io.element.android.libraries.matrix.test.AN_ACCOUNT_PROVIDER_URL
fun anAccountProviderGeneric(
serverName: String = AN_ACCOUNT_PROVIDER,
) = AccountProvider.Generic(
serverName = serverName,
)
fun anAccountProviderManaged(
baseUrl: String = AN_ACCOUNT_PROVIDER_URL,
serverName: String = AN_ACCOUNT_PROVIDER,
canUseServerName: Boolean = true,
) = AccountProvider.Managed(
serverName = serverName,
baseUrl = baseUrl,
canUseServerName = canUseServerName,
)