Let notifications use avatar fallback.

Extract code which handles Matrix image to its own api / impl / test modules.
This commit is contained in:
Benoit Marty 2025-11-12 11:15:02 +01:00
parent 4fa950b25e
commit 185d4fadde
42 changed files with 410 additions and 194 deletions

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import coil3.fetch.Fetcher
import coil3.request.Options
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
internal class AvatarDataFetcherFactory(
private val matrixMediaLoader: MatrixMediaLoader
) : Fetcher.Factory<AvatarData> {
override fun create(
data: AvatarData,
options: Options,
imageLoader: ImageLoader
): Fetcher {
return CoilMediaFetcher(
mediaLoader = matrixMediaLoader,
mediaData = data.toMediaRequestData(),
)
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.media.MediaSource
/**
* The size in pixel of the thumbnail to generate for the avatar.
* This is not the size of the avatar displayed in the UI but the size to get from the servers.
* Servers SHOULD produce thumbnails with the following dimensions and methods:
*
* 32x32, crop
* 96x96, crop
* 320x240, scale
* 640x480, scale
* 800x600, scale
*
* Let's always use the same size so coil caching works properly.
*/
const val AVATAR_THUMBNAIL_SIZE_IN_PIXEL = 240L
internal fun AvatarData.toMediaRequestData(): MediaRequestData {
return MediaRequestData(
source = url?.let { MediaSource(it) },
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
)
}

View file

@ -1,104 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.SourceFetchResult
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.media.toFile
import okio.Buffer
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import timber.log.Timber
import java.nio.ByteBuffer
internal class CoilMediaFetcher(
private val mediaLoader: MatrixMediaLoader,
private val mediaData: MediaRequestData,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
if (mediaData.source == null) {
Timber.e("MediaData source is null")
return null
}
return when (mediaData.kind) {
is MediaRequestData.Kind.Content -> fetchContent(mediaData.source)
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(mediaData.source, mediaData.kind)
is MediaRequestData.Kind.File -> fetchFile(mediaData.source, mediaData.kind)
}
}
/**
* This method is here to avoid using [MatrixMediaLoader.loadMediaContent] as too many ByteArray allocations will flood the memory and cause lots of GC.
* The MediaFile will be closed (and so destroyed from disk) when the image source is closed.
*
*/
private suspend fun fetchFile(mediaSource: MediaSource, kind: MediaRequestData.Kind.File): FetchResult? {
return mediaLoader.downloadMediaFile(mediaSource, kind.mimeType, kind.fileName)
.map { mediaFile ->
val file = mediaFile.toFile()
SourceFetchResult(
source = ImageSource(
file = file.toOkioPath(),
fileSystem = FileSystem.SYSTEM,
closeable = mediaFile,
),
mimeType = null,
dataSource = DataSource.DISK
)
}
.onFailure {
Timber.e(it)
}
.getOrNull()
}
private suspend fun fetchContent(mediaSource: MediaSource): FetchResult? {
return mediaLoader.loadMediaContent(
source = mediaSource,
).map { byteArray ->
byteArray.asSourceResult()
}.onFailure {
Timber.e(it)
}.getOrNull()
}
private suspend fun fetchThumbnail(mediaSource: MediaSource, kind: MediaRequestData.Kind.Thumbnail): FetchResult? {
return mediaLoader.loadMediaThumbnail(
source = mediaSource,
width = kind.width,
height = kind.height,
).map { byteArray ->
byteArray.asSourceResult()
}.onFailure {
Timber.e(it)
}.getOrNull()
}
private fun ByteArray.asSourceResult(): SourceFetchResult {
val byteBuffer = ByteBuffer.wrap(this)
val bufferedSource = try {
Buffer().apply { write(byteBuffer) }
} finally {
byteBuffer.position(0)
}
return SourceFetchResult(
source = ImageSource(
source = bufferedSource,
fileSystem = FileSystem.SYSTEM,
),
mimeType = null,
dataSource = DataSource.MEMORY
)
}
}

View file

@ -1,79 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import android.content.Context
import android.os.Build
import coil3.ImageLoader
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.Provider
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import okhttp3.OkHttpClient
interface LoggedInImageLoaderFactory {
fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader
}
@ContributesBinding(AppScope::class)
class DefaultLoggedInImageLoaderFactory(
@ApplicationContext private val context: Context,
private val okHttpClient: Provider<OkHttpClient>,
) : LoggedInImageLoaderFactory {
override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(
OkHttpNetworkFetcherFactory(
callFactory = {
// Use newBuilder, see https://coil-kt.github.io/coil/network/#using-a-custom-okhttpclient
okHttpClient().newBuilder().build()
}
)
)
// Add gif support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(AvatarDataKeyer())
add(MediaRequestDataKeyer())
add(AvatarDataFetcherFactory(matrixMediaLoader))
add(MediaRequestDataFetcherFactory(matrixMediaLoader))
}
.build()
}
}
@Inject
class NotLoggedInImageLoaderFactory(
@ApplicationContext private val context: Context,
private val okHttpClient: Provider<OkHttpClient>,
) {
fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(
OkHttpNetworkFetcherFactory(
callFactory = {
// Use newBuilder, see https://coil-kt.github.io/coil/network/#using-a-custom-okhttpclient
okHttpClient().newBuilder().build()
}
)
)
}
.build()
}
}

View file

@ -1,59 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
interface ImageLoaderHolder {
fun get(client: MatrixClient): ImageLoader
fun remove(sessionId: SessionId)
}
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultImageLoaderHolder(
private val loggedInImageLoaderFactory: LoggedInImageLoaderFactory,
private val sessionObserver: SessionObserver,
) : ImageLoaderHolder {
private val map = mutableMapOf<SessionId, ImageLoader>()
init {
observeSessions()
}
private fun observeSessions() {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
remove(SessionId(userId))
}
})
}
override fun get(client: MatrixClient): ImageLoader {
return synchronized(map) {
map.getOrPut(client.sessionId) {
loggedInImageLoaderFactory
.newImageLoader(client.matrixMediaLoader)
}
}
}
override fun remove(sessionId: SessionId) {
synchronized(map) {
map.remove(sessionId)
}
}
}

View file

@ -1,138 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import coil3.Bitmap
import io.element.android.compound.theme.AvatarColors
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.SemanticColors
import io.element.android.compound.tokens.generated.compoundColorsDark
import io.element.android.compound.tokens.generated.compoundColorsLight
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text
/**
* Generates a bitmap for an initials avatar based on the provided [AvatarData].
*/
class InitialsAvatarBitmapGenerator(
useDarkTheme: Boolean = false,
private val fontSizePercentage: Float = 0.5f,
) {
private val compoundColors: SemanticColors = if (useDarkTheme) {
compoundColorsDark
} else {
compoundColorsLight
}
// List of predefined avatar colors to use for initials avatars, in light mode
private val allAvatarColors: List<AvatarColors> = listOf(
AvatarColors(
background = compoundColors.bgDecorative1,
foreground = compoundColors.textDecorative1,
),
AvatarColors(
background = compoundColors.bgDecorative2,
foreground = compoundColors.textDecorative2,
),
AvatarColors(
background = compoundColors.bgDecorative3,
foreground = compoundColors.textDecorative3,
),
AvatarColors(
background = compoundColors.bgDecorative4,
foreground = compoundColors.textDecorative4,
),
AvatarColors(
background = compoundColors.bgDecorative5,
foreground = compoundColors.textDecorative5,
),
AvatarColors(
background = compoundColors.bgDecorative6,
foreground = compoundColors.textDecorative6,
),
)
/**
* Generates a bitmap for an avatar with no URL, using the initials from the [AvatarData].
* @param size The size of the bitmap to generate, in pixels.
* @param avatarData The [AvatarData] containing the initials and other information.
*/
fun generateBitmap(size: Int, avatarData: AvatarData): Bitmap? {
if (avatarData.url != null) {
// This generator is only for initials avatars, not for avatars with URLs
return null
}
// Get the color pair to use for the initials avatar
val avatarColors = allAvatarColors[avatarData.id.sumOf { it.code } % allAvatarColors.size]
val bitmap = createBitmap(size, size)
Canvas(bitmap).run {
drawColor(avatarColors.background.toArgb())
val letter = avatarData.initialLetter
val textPaint = Paint().apply {
color = avatarColors.foreground.toArgb()
textSize = size * fontSizePercentage // Adjust text size relative to the avatar size
isAntiAlias = true
textAlign = Paint.Align.CENTER
typeface = Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD)
}
val bounds = Rect()
textPaint.getTextBounds(letter, 0, letter.length, bounds)
drawText(
letter,
size / 2f,
size.toFloat() / 2 - (textPaint.descent() + textPaint.ascent()) / 2,
textPaint
)
}
return bitmap
}
}
@Composable
@PreviewsDayNight
internal fun InitialsAvatarBitmapGeneratorPreview() = ElementPreview {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
repeat(6) { index ->
val avatarData = remember { AvatarData(id = index.toString(), name = Char('0'.code + index).toString(), size = AvatarSize.IncomingCall) }
val isLightTheme = ElementTheme.isLightTheme
val bitmap = remember(isLightTheme) {
val generator = InitialsAvatarBitmapGenerator(useDarkTheme = !isLightTheme)
generator.generateBitmap(512, avatarData)?.asImageBitmap()
}
bitmap?.let {
Image(bitmap = it, contentDescription = null, modifier = Modifier.size(48.dp))
} ?: Text("No avatar generated")
}
}
}

View file

@ -1,46 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import io.element.android.libraries.matrix.api.media.MediaSource
/**
* Can be use with [coil3.compose.AsyncImage] to load a [MediaSource].
* This will go internally through our [CoilMediaFetcher].
*
* Example of usage:
* AsyncImage(
* model = MediaRequestData(mediaSource, MediaRequestData.Kind.Content),
* contentScale = ContentScale.Fit,
* )
*
*/
data class MediaRequestData(
val source: MediaSource?,
val kind: Kind
) {
sealed interface Kind {
data object Content : Kind
data class File(
val fileName: String,
val mimeType: String,
) : Kind
data class Thumbnail(val width: Long, val height: Long) : Kind {
constructor(size: Long) : this(size, size)
}
}
}
/** Max width a thumbnail can have according to [the spec](https://spec.matrix.org/v1.10/client-server-api/#thumbnails). */
const val MAX_THUMBNAIL_WIDTH = 800L
/** Max height a thumbnail can have according to [the spec](https://spec.matrix.org/v1.10/client-server-api/#thumbnails). */
const val MAX_THUMBNAIL_HEIGHT = 600L

View file

@ -1,29 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import coil3.fetch.Fetcher
import coil3.request.Options
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
internal class MediaRequestDataFetcherFactory(
private val matrixMediaLoader: MatrixMediaLoader,
) : Fetcher.Factory<MediaRequestData> {
override fun create(
data: MediaRequestData,
options: Options,
imageLoader: ImageLoader
): Fetcher {
return CoilMediaFetcher(
mediaLoader = matrixMediaLoader,
mediaData = data,
)
}
}

View file

@ -1,30 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2022-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.key.Keyer
import coil3.request.Options
import io.element.android.libraries.designsystem.components.avatar.AvatarData
internal class AvatarDataKeyer : Keyer<AvatarData> {
override fun key(data: AvatarData, options: Options): String? {
return data.toMediaRequestData().toKey()
}
}
internal class MediaRequestDataKeyer : Keyer<MediaRequestData> {
override fun key(data: MediaRequestData, options: Options): String? {
return data.toKey()
}
}
private fun MediaRequestData.toKey(): String? {
if (source == null) return null
return "${source.url}_$kind"
}

View file

@ -1,78 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import androidx.test.platform.app.InstrumentationRegistry
import coil3.ImageLoader
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver
import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultImageLoaderHolderTest {
@Test
fun `get - returns the same ImageLoader for the same client`() {
val context = InstrumentationRegistry.getInstrumentation().context
val lambda = lambdaRecorder<MatrixMediaLoader, ImageLoader> { ImageLoader.Builder(context).build() }
val holder = DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = NoOpSessionObserver()
)
val client = FakeMatrixClient()
val imageLoader1 = holder.get(client)
val imageLoader2 = holder.get(client)
assert(imageLoader1 === imageLoader2)
lambda.assertions()
.isCalledOnce()
.with(value(client.matrixMediaLoader))
}
@Test
fun `when session is deleted, the image loader is deleted`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val lambda =
lambdaRecorder<MatrixMediaLoader, ImageLoader> { ImageLoader.Builder(context).build() }
val sessionObserver = FakeSessionObserver()
val holder = DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = sessionObserver
)
assertThat(sessionObserver.listeners.size).isEqualTo(1)
val client = FakeMatrixClient()
holder.get(client)
sessionObserver.onSessionDeleted(client.sessionId.value)
holder.get(client)
lambda.assertions()
.isCalledExactly(2)
}
@Test
fun `when session is created, nothing happen`() = runTest {
val context = InstrumentationRegistry.getInstrumentation().context
val lambda =
lambdaRecorder<MatrixMediaLoader, ImageLoader> { ImageLoader.Builder(context).build() }
val sessionObserver = FakeSessionObserver()
DefaultImageLoaderHolder(
loggedInImageLoaderFactory = FakeLoggedInImageLoaderFactory(lambda),
sessionObserver = sessionObserver
)
assertThat(sessionObserver.listeners.size).isEqualTo(1)
sessionObserver.onSessionCreated(A_SESSION_ID.value)
}
}

View file

@ -1,20 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.ui.media
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
class FakeLoggedInImageLoaderFactory(
private val newImageLoaderLambda: (MatrixMediaLoader) -> ImageLoader
) : LoggedInImageLoaderFactory {
override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader {
return newImageLoaderLambda(matrixMediaLoader)
}
}