Merge pull request #7793 from element-hq/feature/bma/storeUserAvatar

Display the avatar of other accounts even if it cannot be loaded from the current account
This commit is contained in:
Benoit Marty
2026-09-28 12:43:03 +02:00
committed by GitHub
36 changed files with 355 additions and 64 deletions
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
}
@@ -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",
)
)
}
}
@@ -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 {
@@ -90,6 +90,7 @@ class PreferencesRootPresenter(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.toImmutableList()
@@ -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",
)
)
}
}
@@ -47,5 +47,6 @@ private fun aSessionData(
lastUsageIndex = 0,
userDisplayName = null,
userAvatarUrl = null,
userAvatarData = null,
)
}
@@ -34,6 +34,7 @@ class AccountSelectPresenter(
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
.toImmutableList()
@@ -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",
)
)
}
@@ -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
@@ -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,
)
@@ -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,
)
}
}
}
}
@@ -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
@@ -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()
}
@@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
name = displayName,
url = avatarUrl,
size = size,
thumbnail = avatarThumbnail,
)
fun MatrixUser.getBestName(): String {
@@ -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)
}
@@ -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,
)
}