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
@@ -46,4 +46,6 @@ data class SessionData(
val userDisplayName: String?,
/** The optional avatar URL of the user. */
val userAvatarUrl: String?,
/** The optional thumbnail of the avatar of the user ([userAvatarUrl]), base64 encoded. */
val userAvatarData: String?,
)
@@ -51,8 +51,9 @@ interface SessionStore {
* @param sessionId the session to update.
* @param displayName the new display name, or `null` when the user has none.
* @param avatarUrl the new avatar, or `null` when the user has none.
* @param avatarData the thumbnail of the new avatar, base64 encoded, or `null` when the user has none or when it could not be loaded.
*/
suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?)
suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?)
/**
* Get the session data matching the userId, or null if not found.
@@ -84,12 +84,13 @@ class DatabaseSessionStore(
lastUsageIndex = result.lastUsageIndex,
userDisplayName = result.userDisplayName,
userAvatarUrl = result.userAvatarUrl,
userAvatarData = result.userAvatarData,
).toDbModel()
)
}
}
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) {
sessionDataMutex.withLock {
val result = database.sessionDataQueries.selectByUserId(sessionId)
.executeAsOneOrNull()
@@ -102,6 +103,7 @@ class DatabaseSessionStore(
result.copy(
userDisplayName = displayName,
userAvatarUrl = avatarUrl,
userAvatarData = avatarData,
).toDbModel()
)
}
@@ -31,6 +31,7 @@ internal fun SessionData.toDbModel(): DbSessionData {
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
userAvatarData = userAvatarData,
)
}
@@ -52,5 +53,6 @@ internal fun DbSessionData.toApiModel(): SessionData {
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
userAvatarData = userAvatarData,
)
}
@@ -36,7 +36,10 @@ CREATE TABLE SessionData (
-- user display name
userDisplayName TEXT,
-- user avatar url
userAvatarUrl TEXT
userAvatarUrl TEXT,
-- added in version 13
-- user avatar thumbnail data, base64 encoded
userAvatarData TEXT
);
@@ -0,0 +1,4 @@
-- Migrate DB from version 12
-- Add the thumbnail of the user avatar, base64 encoded, so that the avatar can be rendered without a Matrix client.
ALTER TABLE SessionData ADD COLUMN userAvatarData TEXT;
@@ -134,17 +134,18 @@ class DatabaseSessionStoreTest {
@Test
fun `updateUserProfile does nothing if the session is not found`() = runTest {
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl", "userAvatarData")
assertThat(database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOneOrNull()).isNull()
}
@Test
fun `updateUserProfile update the data`() = runTest {
database.sessionDataQueries.insertSessionData(aSessionData)
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl")
databaseSessionStore.updateUserProfile(aSessionData.userId, "userDisplayName", "userAvatarUrl", "userAvatarData")
val updatedSession = database.sessionDataQueries.selectByUserId(aSessionData.userId).executeAsOne()
assertThat(updatedSession.userDisplayName).isEqualTo("userDisplayName")
assertThat(updatedSession.userAvatarUrl).isEqualTo("userAvatarUrl")
assertThat(updatedSession.userAvatarData).isEqualTo("userAvatarData")
}
@Test
@@ -231,6 +232,7 @@ class DatabaseSessionStoreTest {
lastUsageIndex = 0,
userDisplayName = "userDisplayName",
userAvatarUrl = "userAvatarUrl",
userAvatarData = "userAvatarData",
)
val secondSessionData = SessionData(
userId = "userId",
@@ -249,6 +251,7 @@ class DatabaseSessionStoreTest {
lastUsageIndex = 1,
userDisplayName = "userDisplayNameAltered",
userAvatarUrl = "userAvatarUrlAltered",
userAvatarData = "userAvatarDataAltered",
)
assertThat(firstSessionData.userId).isEqualTo(secondSessionData.userId)
assertThat(firstSessionData.loginTimestamp).isNotEqualTo(secondSessionData.loginTimestamp)
@@ -273,6 +276,7 @@ class DatabaseSessionStoreTest {
assertThat(alteredSession.lastUsageIndex).isEqualTo(firstSessionData.lastUsageIndex)
assertThat(alteredSession.userDisplayName).isEqualTo(firstSessionData.userDisplayName)
assertThat(alteredSession.userAvatarUrl).isEqualTo(firstSessionData.userAvatarUrl)
assertThat(alteredSession.userAvatarData).isEqualTo(firstSessionData.userAvatarData)
}
@Test
@@ -294,6 +298,7 @@ class DatabaseSessionStoreTest {
lastUsageIndex = 0,
userDisplayName = "userDisplayName",
userAvatarUrl = "userAvatarUrl",
userAvatarData = "userAvatarData",
)
val secondSessionData = SessionData(
userId = "userIdUnknown",
@@ -312,6 +317,7 @@ class DatabaseSessionStoreTest {
lastUsageIndex = 1,
userDisplayName = "userDisplayNameAltered",
userAvatarUrl = "userAvatarUrlAltered",
userAvatarData = "userAvatarDataAltered",
)
assertThat(firstSessionData.userId).isNotEqualTo(secondSessionData.userId)
@@ -30,4 +30,5 @@ internal fun aDbSessionData(
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
userAvatarData = null,
)
@@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.map
class InMemorySessionStore(
initialList: List<SessionData> = emptyList(),
private val updateUserProfileResult: (String, String?, String?) -> Unit = { _, _, _ -> error("Not implemented") },
private val updateUserProfileResult: (String, String?, String?, String?) -> Unit = { _, _, _, _ -> error("Not implemented") },
private val setLatestSessionResult: (String) -> Unit = { error("Not implemented") },
) : SessionStore {
private val sessionDataListFlow = MutableStateFlow(initialList)
@@ -56,8 +56,8 @@ class InMemorySessionStore(
}
}
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?) {
updateUserProfileResult(sessionId, displayName, avatarUrl)
override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) {
updateUserProfileResult(sessionId, displayName, avatarUrl, avatarData)
}
override suspend fun getSession(sessionId: String): SessionData? {
@@ -23,6 +23,7 @@ fun aSessionData(
lastUsageIndex: Long = 0,
userDisplayName: String? = null,
userAvatarUrl: String? = null,
userAvatarData: String? = null,
): SessionData {
return SessionData(
userId = sessionId,
@@ -41,5 +42,6 @@ fun aSessionData(
lastUsageIndex = lastUsageIndex,
userDisplayName = userDisplayName,
userAvatarUrl = userAvatarUrl,
userAvatarData = userAvatarData,
)
}