Store the user avatar thumbnail in the session database

Add a `userAvatarData` column to the session database, containing the base64 encoded
thumbnail of the user avatar. It will allow rendering the avatar of an account without
having to use its Matrix client, for instance on the signed out screen, or for other
accounts when the homeservers are not federated.

The thumbnail is downloaded when the user profile is retrieved or updated, and only if
the avatar URL has changed. The profile is now also persisted when received from the
own profile subscription.

Part of #5571

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-09-28 11:00:42 +02:00
parent 35d8d1197f
commit db23023eda
24 changed files with 255 additions and 61 deletions
@@ -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 {
@@ -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)
}
}
}
@@ -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
}
}
@@ -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,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()
}