From db23023eda8fc1518d24ccb41ff026100cb23c44 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 28 Sep 2026 11:00:42 +0200 Subject: [PATCH 1/2] 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) --- .../features/home/impl/HomePresenterTest.kt | 8 +- .../impl/SignedOutStatePreviewParam.kt | 1 + .../libraries/matrix/impl/RustMatrixClient.kt | 45 ++++- .../libraries/matrix/impl/mapper/Session.kt | 1 + .../matrix/impl/media/MediaSource.kt | 17 ++ .../matrix/impl/media/RustMediaLoader.kt | 10 -- .../matrix/impl/scanner/RustContentScanner.kt | 18 +- .../impl/RustMatrixClientFactoryTest.kt | 2 +- .../matrix/impl/RustMatrixClientTest.kt | 163 ++++++++++++++++-- .../RustTemporaryMatrixClientFactoryTest.kt | 2 +- .../RustMatrixAuthenticationServiceTest.kt | 2 +- .../impl/fixtures/factories/UserProfile.kt | 2 +- .../impl/fixtures/fakes/FakeFfiClient.kt | 6 + .../sessionstorage/api/SessionData.kt | 2 + .../sessionstorage/api/SessionStore.kt | 3 +- .../impl/DatabaseSessionStore.kt | 4 +- .../sessionstorage/impl/SessionDataMapper.kt | 2 + .../impl/src/main/sqldelight/databases/13.db | Bin 0 -> 12288 bytes .../libraries/matrix/session/SessionData.sq | 5 +- .../src/main/sqldelight/migrations/12.sqm | 4 + .../impl/DatabaseSessionStoreTest.kt | 10 +- .../libraries/sessionstorage/impl/Fixtures.kt | 1 + .../test/InMemorySessionStore.kt | 6 +- .../sessionstorage/test/SessionData.kt | 2 + 24 files changed, 255 insertions(+), 61 deletions(-) create mode 100644 libraries/session-storage/impl/src/main/sqldelight/databases/13.db create mode 100644 libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 371a718523..58ad4eb100 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -81,7 +81,7 @@ class HomePresenterTest { val presenter = createHomePresenter( rageshakeFeatureAvailability = { flowOf(true) }, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -98,7 +98,7 @@ class HomePresenterTest { val presenter = createHomePresenter( indicatorService = indicatorService, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -120,7 +120,7 @@ class HomePresenterTest { val presenter = createHomePresenter( client = matrixClient, sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { @@ -134,7 +134,7 @@ class HomePresenterTest { fun `present - NavigationBar change`() = runTest { val presenter = createHomePresenter( sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), ) presenter.test { diff --git a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt index ae60b7aacb..e0396cba97 100644 --- a/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt +++ b/features/signedout/impl/src/main/kotlin/io/element/android/features/signedout/impl/SignedOutStatePreviewParam.kt @@ -47,5 +47,6 @@ private fun aSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 86414ed6a3..d92509b262 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -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 = withContext(sessionDispatcher) { runCatchingExceptions { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt index 69a76f2825..57f2b8c597 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/mapper/Session.kt @@ -38,4 +38,5 @@ internal fun Session.toSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt index 08bef24722..c42e9c1f02 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/MediaSource.kt @@ -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 + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt index 7b3a1d9f10..0b8229cb6b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/media/RustMediaLoader.kt @@ -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) - } - } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt index c07ca94f26..60ae423f98 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/scanner/RustContentScanner.kt @@ -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 - } -} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index b559b7d4d0..4dcb8b1df8 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -49,7 +49,7 @@ class RustMatrixClientFactoryTest { fun TestScope.createRustMatrixClientFactory( cacheDirectory: File = File("/cache"), sessionStore: SessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, + updateUserProfileResult = { _, _, _, _ -> }, ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt index ea399b39df..4fe61e4d14 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientTest.kt @@ -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() - val updateUserProfileResult = lambdaRecorder { _, _, _ -> profilePersisted.complete(Unit) } + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> 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() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> 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() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> 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() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> 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() + val updateUserProfileResult = lambdaRecorder { _, _, _, _ -> profilePersisted.complete(Unit) } + val getMediaThumbnailResult = lambdaRecorder { _, _, _ -> 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, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt index f18f633889..f4a229b30b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustTemporaryMatrixClientFactoryTest.kt @@ -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(), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index d81c6021da..9b589b2281 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -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")), diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt index 947e6e7aa1..07997e9109 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UserProfile.kt @@ -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( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt index 8c6411197a..c2080747d5 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiClient.kt @@ -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() } diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt index 88cf9558b3..fa4e22232d 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionData.kt @@ -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?, ) diff --git a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt index 641a2afdf2..a84a1f00dc 100644 --- a/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt +++ b/libraries/session-storage/api/src/main/kotlin/io/element/android/libraries/sessionstorage/api/SessionStore.kt @@ -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. diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index 9be59e4bae..f183c42225 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -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() ) } diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt index 316bf86c1c..0c55d46f66 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/SessionDataMapper.kt @@ -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, ) } diff --git a/libraries/session-storage/impl/src/main/sqldelight/databases/13.db b/libraries/session-storage/impl/src/main/sqldelight/databases/13.db new file mode 100644 index 0000000000000000000000000000000000000000..e2d2bebe6a720b6fb19e7ab627c8d892b3b6622b GIT binary patch literal 12288 zcmeH~OK;Oa5XbF;3Tjogw_Zoh1w>jk^r7}fpaw;RQW6KzQ?;?T^$Ob?cGpq!5jb$* zBk`?}xG=kp^XNm7IH77RIq~k!{AcF3vzFZb)E0)rrBoR+u;*=fzVE#P@I24Q`w`yN zWyg=J0sh%SH`&^i$y0z`la5CI}U1c(3;_#X*;U-qAD z)@uIGu3`O@ce&O=<|oXsna8C!ouC;85H?@81DIKWXDfhLp}A_s5C&gDIQ@usuib{T zPU}Ona}Mu=^SZN&`A|gsrd_$lq6j00@+;5hWy)p6FBR9xbiJHTWX3V{kgJ|b@ABK; z1{TLW29X0=+0x*pOD2_9A`b=n8Q#2 z8WD>U{*MZcT_=HD2T}`zyHE!sq0hngoPE?%8NeoxMQ)%!f^o(WDbB1t#LV!r^s&PI zM0Y2ph&<-kATPlr98#tYb}GB#mHG|@SQHrS0%s8wN^y)o37)Y0ieswiJts9btct4a zg+?>XI71QaIV{V1X6zbvC{*eK+qtHjs!r4d=Ba3DUimabA zk=iX|b?9T09_|CrBZ&v`Mv%!_T6uo3w7l7>`JMx>Y5g-CYfCvx)BJfZNtk}Zn`~RVCFV#f^hyW2F W0z`la5CI}U1c(3;AOb|d5%>$?)O|q! literal 0 HcmV?d00001 diff --git a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq index 8e5fb7a1f8..869bca34ed 100644 --- a/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq +++ b/libraries/session-storage/impl/src/main/sqldelight/io/element/android/libraries/matrix/session/SessionData.sq @@ -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 ); diff --git a/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm b/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm new file mode 100644 index 0000000000..f6ba7fb28a --- /dev/null +++ b/libraries/session-storage/impl/src/main/sqldelight/migrations/12.sqm @@ -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; diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt index e40000986b..cc91ee8039 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStoreTest.kt @@ -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) diff --git a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt index 4a488588ff..a6292631e9 100644 --- a/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt +++ b/libraries/session-storage/impl/src/test/kotlin/io/element/android/libraries/sessionstorage/impl/Fixtures.kt @@ -30,4 +30,5 @@ internal fun aDbSessionData( lastUsageIndex = 0, userDisplayName = null, userAvatarUrl = null, + userAvatarData = null, ) diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt index 05e58ac623..ac54c1f1d4 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/InMemorySessionStore.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.map class InMemorySessionStore( initialList: List = 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? { diff --git a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt index c5acd77755..c7c1856f8c 100644 --- a/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt +++ b/libraries/session-storage/test/src/main/kotlin/io/element/android/libraries/sessionstorage/test/SessionData.kt @@ -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, ) } From c2a2e16f46585dcad4bd7c0b8592b84831602931 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Mon, 28 Sep 2026 11:38:45 +0200 Subject: [PATCH 2/2] Use the stored avatar thumbnail as a fallback for other accounts Add an optional thumbnail to `MatrixUser` and `AvatarData`, filled from the session database for the other accounts in the room list header, the settings and the account selection screen. `ImageAvatar` renders this thumbnail instead of the initial letter while the avatar is loading or when it fails to load, for instance when the homeserver of the current account is not federated with the one of the other account. Fixes #5571 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../impl/CurrentUserWithNeighborsBuilder.kt | 1 + .../CurrentUserWithNeighborsBuilderTest.kt | 26 +++++++++ .../impl/root/PreferencesRootPresenter.kt | 1 + .../impl/root/PreferencesRootPresenterTest.kt | 10 +++- .../impl/AccountSelectPresenter.kt | 1 + .../impl/AccountSelectPresenterTest.kt | 2 + .../components/avatar/AvatarData.kt | 2 + .../components/avatar/AvatarDataProvider.kt | 2 + .../components/avatar/internal/ImageAvatar.kt | 53 ++++++++++++++++++- .../libraries/matrix/api/user/MatrixUser.kt | 2 + .../matrix/ui/model/MatrixUserExtensions.kt | 1 + .../ui/model/MatrixUserExtensionsTest.kt | 2 + 12 files changed, 100 insertions(+), 3 deletions(-) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt index b29dc788a7..f9c5fc3bc0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilder.kt @@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder { userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt index 13c9585386..e8d041bca3 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/CurrentUserWithNeighborsBuilderTest.kt @@ -220,4 +220,30 @@ class CurrentUserWithNeighborsBuilderTest { ) ) } + + @Test + fun `other accounts use the data from db, including the avatar thumbnail`() { + val sut = CurrentUserWithNeighborsBuilder() + val matrixUser = aMatrixUser(id = A_USER_ID.value) + val list = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ), + aSessionData( + sessionId = A_USER_ID_2.value, + userDisplayName = "Carol", + userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", + ), + ) + val result = sut.build(matrixUser, list) + assertThat(result[0]).isEqualTo( + MatrixUser( + userId = A_USER_ID_2, + displayName = "Carol", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) + } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 6bada34e93..4bbe2eeaf8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -90,6 +90,7 @@ class PreferencesRootPresenter( userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } .toImmutableList() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 3d438f7044..085b74e323 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -234,6 +234,7 @@ class PreferencesRootPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -241,7 +242,14 @@ class PreferencesRootPresenterTest { val state = awaitFirstItem() assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.otherSessions).hasSize(1) - assertThat(state.otherSessions[0]).isEqualTo(MatrixUser(userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl")) + assertThat(state.otherSessions[0]).isEqualTo( + MatrixUser( + userId = A_SESSION_ID_2, + displayName = "Bob", + avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", + ) + ) } } diff --git a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt index be6a8bea6f..e1d6b2800e 100644 --- a/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt +++ b/libraries/accountselect/impl/src/main/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenter.kt @@ -34,6 +34,7 @@ class AccountSelectPresenter( userId = UserId(it.userId), displayName = it.userDisplayName, avatarUrl = it.userAvatarUrl, + avatarThumbnail = it.userAvatarData, ) } .toImmutableList() diff --git a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt index 8b0f581f6d..f9b9aeebcd 100644 --- a/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt +++ b/libraries/accountselect/impl/src/test/kotlin/io/element/android/libraries/accountselect/impl/AccountSelectPresenterTest.kt @@ -44,6 +44,7 @@ class AccountSelectPresenterTest { sessionId = A_SESSION_ID_2.value, userDisplayName = "Bob", userAvatarUrl = "avatarUrl", + userAvatarData = "avatarData", ), ) ) @@ -66,6 +67,7 @@ class AccountSelectPresenterTest { userId = A_SESSION_ID_2, displayName = "Bob", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarData", ) ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt index 99c45c67d9..88f8f3abb5 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarData.kt @@ -18,6 +18,8 @@ data class AvatarData( val name: String?, val url: String? = null, val size: AvatarSize, + /** Base64 encoded thumbnail of [url], stored locally, used as a fallback when the avatar cannot be loaded. */ + val thumbnail: String? = null, ) { val initialLetter by lazy { // For roomIds, use "#" as initial diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt index 53ef5593ed..ee069f71fd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/AvatarDataProvider.kt @@ -16,9 +16,11 @@ fun anAvatarData( name: String? = USER_NAME_ALICE, url: String? = null, size: AvatarSize = AvatarSize.RoomListItem, + thumbnail: String? = null, ) = AvatarData( id = id, name = name, url = url, size = size, + thumbnail = thumbnail, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt index da57fbcbe1..e8dcd94a77 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/internal/ImageAvatar.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape @@ -21,8 +22,10 @@ import androidx.compose.ui.unit.Dp import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.designsystem.components.avatar.AvatarData import timber.log.Timber +import kotlin.io.encoding.Base64 @Composable internal fun ImageAvatar( @@ -33,6 +36,9 @@ internal fun ImageAvatar( contentDescription: String? = null, ) { val size = forcedAvatarSize ?: avatarData.size.dp + val thumbnailBytes = remember(avatarData.thumbnail) { + avatarData.thumbnail?.let { runCatchingExceptions { Base64.decode(it) }.getOrNull() } + } SubcomposeAsyncImage( model = avatarData, contentDescription = contentDescription, @@ -51,15 +57,17 @@ internal fun ImageAvatar( "Error loading avatar $state\n${state.result}" ) } - InitialLetterAvatar( + ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, ) } - else -> InitialLetterAvatar( + else -> ThumbnailOrInitialLetterAvatar( avatarData = avatarData, + thumbnailBytes = thumbnailBytes, avatarShape = avatarShape, forcedAvatarSize = forcedAvatarSize, contentDescription = contentDescription, @@ -67,3 +75,44 @@ internal fun ImageAvatar( } } } + +/** + * Render the locally stored thumbnail of the avatar if any, else the initial letter. + */ +@Composable +private fun ThumbnailOrInitialLetterAvatar( + avatarData: AvatarData, + thumbnailBytes: ByteArray?, + avatarShape: Shape, + forcedAvatarSize: Dp?, + contentDescription: String?, +) { + if (thumbnailBytes == null) { + InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } else { + SubcomposeAsyncImage( + model = thumbnailBytes, + contentDescription = contentDescription, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(forcedAvatarSize ?: avatarData.size.dp) + .clip(avatarShape) + ) { + val collectedState by painter.state.collectAsState() + when (collectedState) { + is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent() + else -> InitialLetterAvatar( + avatarData = avatarData, + avatarShape = avatarShape, + forcedAvatarSize = forcedAvatarSize, + contentDescription = contentDescription, + ) + } + } + } +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt index 7a8ec739a5..f27557bda2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/MatrixUser.kt @@ -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 diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt index 83a707347d..ac3cdf0e3b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensions.kt @@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData( name = displayName, url = avatarUrl, size = size, + thumbnail = avatarThumbnail, ) fun MatrixUser.getBestName(): String { diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt index 035548a4ec..ae8a04b7cd 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/model/MatrixUserExtensionsTest.kt @@ -33,12 +33,14 @@ class MatrixUserExtensionsTest : RobolectricTest() { userId = A_USER_ID, displayName = "displayName", avatarUrl = "avatarUrl", + avatarThumbnail = "avatarThumbnail", ) val expected = AvatarData( id = A_USER_ID.value, name = "displayName", url = "avatarUrl", size = AvatarSize.UserHeader, + thumbnail = "avatarThumbnail", ) assertThat(matrixUser.getAvatarData(AvatarSize.UserHeader)).isEqualTo(expected) }