First Commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
+24
@@ -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
|
||||
+18
@@ -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)
|
||||
}
|
||||
+23
@@ -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?
|
||||
}
|
||||
+46
@@ -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)
|
||||
}
|
||||
+18
@@ -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)
|
||||
)
|
||||
}
|
||||
+30
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
+105
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
+61
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+127
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+66
@@ -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()
|
||||
}
|
||||
}
|
||||
+29
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+29
@@ -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" }
|
||||
}
|
||||
+92
@@ -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,
|
||||
)
|
||||
+25
@@ -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)
|
||||
}
|
||||
+51
@@ -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 }
|
||||
}
|
||||
}
|
||||
+30
@@ -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
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user