mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
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:
+1
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
+1
@@ -90,6 +90,7 @@ class PreferencesRootPresenter(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
||||
+9
-1
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -47,5 +47,6 @@ private fun aSessionData(
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
userAvatarData = null,
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ class AccountSelectPresenter(
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
.toImmutableList()
|
||||
|
||||
+2
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+51
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+39
-6
@@ -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 {
|
||||
|
||||
+1
@@ -38,4 +38,5 @@ internal fun Session.toSessionData(
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
userAvatarData = null,
|
||||
)
|
||||
|
||||
+17
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-17
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ class RustMatrixClientFactoryTest {
|
||||
fun TestScope.createRustMatrixClientFactory(
|
||||
cacheDirectory: File = File("/cache"),
|
||||
sessionStore: SessionStore = InMemorySessionStore(
|
||||
updateUserProfileResult = { _, _, _ -> },
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
),
|
||||
clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(),
|
||||
workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(),
|
||||
|
||||
+151
-12
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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")),
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+6
@@ -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()
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ fun MatrixUser.getAvatarData(size: AvatarSize) = AvatarData(
|
||||
name = displayName,
|
||||
url = avatarUrl,
|
||||
size = size,
|
||||
thumbnail = avatarThumbnail,
|
||||
)
|
||||
|
||||
fun MatrixUser.getBestName(): String {
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
+2
@@ -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?,
|
||||
)
|
||||
|
||||
+2
-1
@@ -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.
|
||||
|
||||
+3
-1
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
+4
-1
@@ -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;
|
||||
+8
-2
@@ -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)
|
||||
|
||||
|
||||
+1
@@ -30,4 +30,5 @@ internal fun aDbSessionData(
|
||||
lastUsageIndex = 0,
|
||||
userDisplayName = null,
|
||||
userAvatarUrl = null,
|
||||
userAvatarData = null,
|
||||
)
|
||||
|
||||
+3
-3
@@ -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? {
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user