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) }