First Commit

This commit is contained in:
2025-12-18 16:28:50 +07:00
commit 8c3e4f491f
9974 changed files with 396488 additions and 0 deletions
@@ -0,0 +1,21 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.matrix.ui.media.api"
}
dependencies {
implementation(projects.libraries.designsystem)
implementation(projects.libraries.matrix.api)
implementation(libs.coil.compose)
}
@@ -0,0 +1,24 @@
/*
* 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
/**
* 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
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations 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.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
interface ImageLoaderHolder {
fun get(): ImageLoader
fun get(client: MatrixClient): ImageLoader
fun remove(sessionId: SessionId)
}
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2025 Element Creations 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.Bitmap
import io.element.android.libraries.designsystem.components.avatar.AvatarData
/**
* Generates a bitmap for an initials avatar based on the provided [io.element.android.libraries.designsystem.components.avatar.AvatarData].
*/
interface InitialsAvatarBitmapGenerator {
fun generateBitmap(
size: Int,
avatarData: AvatarData,
useDarkTheme: Boolean,
fontSizePercentage: Float = 0.5f,
): Bitmap?
}
@@ -0,0 +1,46 @@
/*
* 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
@@ -0,0 +1,33 @@
import extension.setupDependencyInjection
import extension.testCommonDependencies
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.libraries.matrix.ui.media.impl"
}
setupDependencyInjection()
dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.designsystem)
implementation(libs.coil.compose)
implementation(libs.coil.gif)
implementation(libs.coil.network.okhttp)
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.libraries.sessionStorage.test)
}
@@ -0,0 +1,18 @@
/*
* Copyright (c) 2025 Element Creations 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
internal fun AvatarData.toMediaRequestData(): MediaRequestData {
return MediaRequestData(
source = url?.let { MediaSource(it) },
kind = MediaRequestData.Kind.Thumbnail(AVATAR_THUMBNAIL_SIZE_IN_PIXEL)
)
}
@@ -0,0 +1,30 @@
/*
* 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(),
)
}
}
@@ -0,0 +1,105 @@
/*
* 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? {
val source = mediaData.source
if (source == null) {
Timber.e("MediaData source is null")
return null
}
return when (val kind = mediaData.kind) {
is MediaRequestData.Kind.Content -> fetchContent(source)
is MediaRequestData.Kind.Thumbnail -> fetchThumbnail(source, kind)
is MediaRequestData.Kind.File -> fetchFile(source, 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
)
}
}
@@ -0,0 +1,61 @@
/*
* 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
@ContributesBinding(AppScope::class)
@SingleIn(AppScope::class)
class DefaultImageLoaderHolder(
private val imageLoaderFactory: ImageLoaderFactory,
private val sessionObserver: SessionObserver,
) : ImageLoaderHolder {
private val map = mutableMapOf<SessionId, ImageLoader>()
private val notLoggedInImageLoader by lazy {
imageLoaderFactory.newImageLoader()
}
init {
observeSessions()
}
private fun observeSessions() {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) {
remove(SessionId(userId))
}
})
}
override fun get(): ImageLoader {
return notLoggedInImageLoader
}
override fun get(client: MatrixClient): ImageLoader {
return synchronized(map) {
map.getOrPut(client.sessionId) {
imageLoaderFactory
.newImageLoader(client.matrixMediaLoader)
}
}
}
override fun remove(sessionId: SessionId) {
synchronized(map) {
map.remove(sessionId)
}
}
}
@@ -0,0 +1,127 @@
/*
* 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 dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
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
@ContributesBinding(AppScope::class)
class DefaultInitialsAvatarBitmapGenerator : InitialsAvatarBitmapGenerator {
// List of predefined avatar colors to use for initials avatars, in light mode
private val lightAvatarColors: List<AvatarColors> = compoundColorsLight.buildAvatarColors()
// List of predefined avatar colors to use for initials avatars, in dark mode
private val darkAvatarColors: List<AvatarColors> = compoundColorsDark.buildAvatarColors()
/**
* 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.
* @param useDarkTheme Whether the theme is dark.
* @param fontSizePercentage The percentage of the avatar size to use for the font size.
*/
override fun generateBitmap(
size: Int,
avatarData: AvatarData,
useDarkTheme: Boolean,
fontSizePercentage: Float,
): 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 colors = if (useDarkTheme) darkAvatarColors else lightAvatarColors
val avatarColors = colors[avatarData.id.sumOf { it.code } % colors.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
}
}
private fun SemanticColors.buildAvatarColors(): List<AvatarColors> = listOf(
AvatarColors(background = bgDecorative1, foreground = textDecorative1),
AvatarColors(background = bgDecorative2, foreground = textDecorative2),
AvatarColors(background = bgDecorative3, foreground = textDecorative3),
AvatarColors(background = bgDecorative4, foreground = textDecorative4),
AvatarColors(background = bgDecorative5, foreground = textDecorative5),
AvatarColors(background = bgDecorative6, foreground = textDecorative6),
)
@Composable
@PreviewsDayNight
internal fun InitialsAvatarBitmapGeneratorPreview() = ElementPreview {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
val generator = remember { DefaultInitialsAvatarBitmapGenerator() }
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) {
generator.generateBitmap(
size = 512,
avatarData = avatarData,
useDarkTheme = !isLightTheme,
)?.asImageBitmap()
}
bitmap?.let {
Image(bitmap = it, contentDescription = null, modifier = Modifier.size(48.dp))
} ?: Text("No avatar generated")
}
}
}
@@ -0,0 +1,66 @@
/*
* 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.Provider
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import okhttp3.OkHttpClient
interface ImageLoaderFactory {
fun newImageLoader(): ImageLoader
fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader
}
@ContributesBinding(AppScope::class)
class DefaultImageLoaderFactory(
@ApplicationContext private val context: Context,
private val okHttpClient: Provider<OkHttpClient>,
) : ImageLoaderFactory {
private val okHttpNetworkFetcherFactory = OkHttpNetworkFetcherFactory(
callFactory = {
// Use newBuilder, see https://coil-kt.github.io/coil/network/#using-a-custom-okhttpclient
okHttpClient().newBuilder().build()
}
)
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(okHttpNetworkFetcherFactory)
}
.build()
}
override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(okHttpNetworkFetcherFactory)
// 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()
}
}
@@ -0,0 +1,29 @@
/*
* 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,
)
}
}
@@ -0,0 +1,29 @@
/*
* 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? {
return source?.let { "${it.url}_$kind" }
}
@@ -0,0 +1,92 @@
/*
* 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.api.observer.SessionObserver
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 = createDefaultImageLoaderHolder(
imageLoaderFactory = FakeImageLoaderFactory(
newMatrixImageLoaderLambda = lambda,
),
)
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(
imageLoaderFactory = FakeImageLoaderFactory(
newMatrixImageLoaderLambda = 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(
imageLoaderFactory = FakeImageLoaderFactory(
newMatrixImageLoaderLambda = lambda,
),
sessionObserver = sessionObserver,
)
assertThat(sessionObserver.listeners.size).isEqualTo(1)
sessionObserver.onSessionCreated(A_SESSION_ID.value)
}
}
private fun createDefaultImageLoaderHolder(
imageLoaderFactory: ImageLoaderFactory = FakeImageLoaderFactory(),
sessionObserver: SessionObserver = NoOpSessionObserver(),
) = DefaultImageLoaderHolder(
imageLoaderFactory = imageLoaderFactory,
sessionObserver = sessionObserver,
)
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2025 Element Creations 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
import io.element.android.tests.testutils.lambda.lambdaError
class FakeImageLoaderFactory(
private val newImageLoaderLambda: () -> ImageLoader = { lambdaError() },
private val newMatrixImageLoaderLambda: (MatrixMediaLoader) -> ImageLoader = { lambdaError() },
) : ImageLoaderFactory {
override fun newImageLoader(): ImageLoader {
return newImageLoaderLambda()
}
override fun newImageLoader(matrixMediaLoader: MatrixMediaLoader): ImageLoader {
return newMatrixImageLoaderLambda(matrixMediaLoader)
}
}
@@ -0,0 +1,22 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.matrix.ui.media.test"
}
dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixmedia.api)
implementation(projects.libraries.designsystem)
implementation(projects.tests.testutils)
implementation(libs.coil.compose)
}
@@ -0,0 +1,51 @@
/*
* 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.test
import coil3.ComponentRegistry
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.request.Disposable
import coil3.request.ImageRequest
import coil3.request.ImageResult
class FakeImageLoader : ImageLoader {
private val executedRequests = mutableListOf<ImageRequest>()
override val defaults: ImageRequest.Defaults
get() = error("Not implemented")
override val components: ComponentRegistry
get() = error("Not implemented")
override val memoryCache: MemoryCache?
get() = error("Not implemented")
override val diskCache: DiskCache?
get() = error("Not implemented")
override fun enqueue(request: ImageRequest): Disposable {
error("Not implemented")
}
override suspend fun execute(request: ImageRequest): ImageResult {
executedRequests.add(request)
error("Not implemented")
}
override fun shutdown() {
error("Not implemented")
}
override fun newBuilder(): ImageLoader.Builder {
error("Not implemented")
}
fun getExecutedRequestsData(): List<Any> {
return executedRequests.map { it.data }
}
}
@@ -0,0 +1,30 @@
/*
* 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.test
import coil3.ImageLoader
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
class FakeImageLoaderHolder(
val fakeImageLoader: ImageLoader = FakeImageLoader(),
) : ImageLoaderHolder {
override fun get(): ImageLoader {
return fakeImageLoader
}
override fun get(client: MatrixClient): ImageLoader {
return fakeImageLoader
}
override fun remove(sessionId: SessionId) {
// No-op
}
}
@@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Element Creations 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.test
import coil3.Bitmap
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.ui.media.InitialsAvatarBitmapGenerator
import io.element.android.tests.testutils.lambda.lambdaError
class FakeInitialsAvatarBitmapGenerator(
private val generateBitmapResult: (Int, AvatarData, Boolean, Float) -> Bitmap? = { _, _, _, _ -> lambdaError() }
) : InitialsAvatarBitmapGenerator {
override fun generateBitmap(
size: Int,
avatarData: AvatarData,
useDarkTheme: Boolean,
fontSizePercentage: Float,
): Bitmap? {
return generateBitmapResult(size, avatarData, useDarkTheme, fontSizePercentage)
}
}